Relation create command returns Array, expect Hash! ROM crash

Thank you for creating such an amazing tool. I appreciate the separation of concerns! I can change the underlying db from mysql to pg to sqlite memory via only simple config changes - amazing!!!

I’m running into an issue where a relation command sometimes passes a hash to the mapper and sometimes passes the hash wrapped in an array causing a rom crash.

Here are the two relations and mapper involved (note one of the names is not pluralized)

module Relations
  class RangeEachYear < ROM::Relation[:sql]
    gateway :default

    schema "#{ENV["DB_TABLE_PREFIX"]}_range_each_year".to_sym, infer: true, as: :range_each_year do

      associations do
        belongs_to :temporal_set_expressions
      end

      use :timestamps, attributes: %i[created_at created_at]
    end

  end
end

module Relations
  class TemporalSetExpressions < ROM::Relation[:sql]
    gateway :default

    schema "#{ENV["DB_TABLE_PREFIX"]}_temporal_set_expressions".to_sym, infer: true, as: :temporal_set_expressions do
      attribute :type, Types::Strict::String.enum(
        'Union',
        'Intersection',
        'Difference')

      associations do
        has_many :range_each_year
      end

      use :timestamps, attributes: %i[created_at created_at]
    end

  end
end

module Mappers
  class TemporalSetExpressionsMapper < ROM::Transformer
    relation :temporal_set_expressions
    register_as :temporal_set_expressions_mapper

    map do
      reject_keys [:foobar]
    end

  end
end

I get the relation and create 3 commands, one with combine, one with mapper, and one with both…

tse_rel = MyApp.container.relations[:temporal_set_expressions]
# cmd with combine
cmd_c = tse_rel.combine(:range_each_year)
  .command(:create, use: :timestamps, plugins_options: {timestamps: {timestamps: %i[created_at updated_at]}})
# cmd with mapper
cmd_m = tse_rel
  .command(:create, mapper: :temporal_set_expressions_mapper, use: :timestamps, plugins_options: {timestamps: {timestamps: %i[created_at updated_at]}})
# cmd with combine and mapper
cmd_cm = tse_rel.combine(:range_each_year)
  .command(:create, mapper: :temporal_set_expressions_mapper, use: :timestamps, plugins_options: {timestamps: {timestamps: %i[created_at updated_at]}})

Below is the results of calling each command with data to create:

> cmd_c.call(parent_id: nil, type: 'Union', range_each_year: [])
=> {:type=>"Union", :created_at=>2022-03-25 16:22:15 -0400, :id=>1, :parent_id=>nil, :updated_at=>2022-03-25 16:22:15 -0400, :range_each_year=>[]}

> cmd_m.call(parent_id: nil, type: 'Union', range_each_year: [])
=> {:type=>"Union", :created_at=>2022-03-25 16:22:17 -0400, :id=>2, :parent_id=>nil, :updated_at=>2022-03-25 16:22:17 -0400}

> cmd_cm.call(parent_id: nil, type: 'Union', range_each_year: [])
.../gems/transproc-1.1.1/lib/transproc/hash.rb:212: warning: wrong element type Hash at 0 (expected array)
.../gems/transproc-1.1.1/lib/transproc/hash.rb:212: warning: ignoring wrong elements is deprecated, remove them explicitly
.../gems/transproc-1.1.1/lib/transproc/hash.rb:212: warning: this causes ArgumentError in the next release
ArgumentError: invalid number of elements (0 for 1..2)
.../gems/transproc-1.1.1/lib/transproc/hash.rb:212:in `[]'

I investigated to find the following:

    211:     def self.reject_keys(hash, keys)
 => 212: require 'pry'; binding.pry;
    213:       Hash[hash].reject { |k, _| keys.include?(k) }
    214:     end

[1] pry(Transproc::HashTransformations)> hash
=> [{:type=>"Union", :created_at=>2022-03-25 16:39:04 -0400, :id=>1, :parent_id=>nil, :updated_at=>2022-03-25 16:39:04 -0400}]

Why is the combination of mapper + combine on the create command returning an array?
What is the mistake I am making or what workaround can I use?

Many Thanks!!!

It’s because a combined relation is no longer the same relation. rom doesn’t automatically adjust your mapper to work with the combined one.

I’m not sure if it’s gonna work, but try this:

cmd_m = tse_rel
  .map_with(:temporal_set_expressions_mapper)
  .combine(:range_each_year)
  .command(:create, use: :timestamps, plugins_options: {timestamps: {timestamps: %i[created_at updated_at]}})

Thank you for taking the time to respond! I tried your suggestion and it looks like the mapper is not being triggered at all. If it’s not automatic is there a way to manually configure a “combined relation mapper”?

[EDIT]
Am I correct in assuming I need something like this? …

You can use #map_with but it seems like the behavior when used with commands is not what you need. Can you tell me why you need a custom mapper when using commands? Maybe you should use changesets instead?

No, that’s a low level spec.

I mean could I use the ROM::Relation::Combined#map_with method that the spec demonstrates? But I guess it would help to go deeper into my intent.

temporal_set_expressions relation is a hierarchical tree structure (parent_id to self) as well as 8 other associations.

        associations do
          has_many :temporal_set_expressions, foreign_key: :parent_id
          has_many :range_each_year
          has_many :range_each_month
          has_many :range_each_week
          has_many :range_each_day
          has_many :range_after
          has_many :range_before
          has_many :day_each_month
          has_many :day_each_week
        end

So I would like to use the create command with nested hash. The result should combine all the associations recursively, nest all associations under one key/attribute called “expressions” and constructor_inject the structs into PORO. So I believe I need combine + mapper on a create command.

      set = subject.create_with_expr({
        type: "Union",
        parent_id: nil,
        range_each_year: [{
          start_month: 1,
          end_month: 4,
          start_day: 10,
          end_day: 15
          }
        ]
      })

      assert_kind_of SetExpression::Union, set
      assert_kind_of Expression::RangeEachYear, set.expressions[0]
      assert_equal 4, set.expressions[0].end_month

Here is my mapper without the constructor_inject…

    class TemporalSetExpressionsMapper < ROM::Transformer
      relation :temporal_set_expressions
      register_as :temporal_set_expressions_mapper

      map do
        recursion do
          guard(->(expr) { expr.is_a?(Hash) && ['Union', 'Intersection', 'Difference'].include?(expr[:type]) }) do

            nest :expressions, [:temporal_set_expressions, :range_each_year, :range_each_month,
              :range_each_week, :range_each_day, :range_after, :range_before, :day_each_month,
              :day_each_week]

            map_value :expressions, ->(value) {
              (value[:temporal_set_expressions].nil? ? [] : value[:temporal_set_expressions]) |
              (value[:range_each_year].nil? ? [] : value[:range_each_year]) |
              (value[:range_each_month].nil? ? [] : value[:range_each_month]) |
              (value[:range_each_week].nil? ? [] : value[:range_each_week]) |
              (value[:range_each_day].nil? ? [] : value[:range_each_day]) |
              (value[:range_after].nil? ? [] : value[:range_after]) |
              (value[:range_before].nil? ? [] : value[:range_before]) |
              (value[:day_each_month].nil? ? [] : value[:day_each_month]) |
              (value[:day_each_week].nil? ? [] : value[:day_each_week])
            }
          end
        end
      end

And here is result of the create I would like (if possible):

=> {:type=>"Union",
 :created_at=>2022-03-29 12:11:51 -0400,
 :id=>1,
 :parent_id=>nil,
 :updated_at=>2022-03-29 12:11:51 -0400,
 :expressions=>
  [{:type=>"Intersection",
    :created_at=>2022-03-29 12:11:51 -0400,
    :id=>2,
    :parent_id=>1,
    :updated_at=>2022-03-29 12:11:51 -0400,
    :expressions=>
     [{:type=>"Union", :created_at=>2022-03-29 12:11:51 -0400, :id=>3, :parent_id=>2, :updated_at=>2022-03-29 12:11:51 -0400, :expressions=>[]},
      {:created_at=>2022-03-29 12:11:51 -0400, :id=>2, :start_month=>2, :end_month=>4, :start_day=>10, :end_day=>15, :temporal_set_expression_id=>2, :updated_at=>2022-03-29 12:11:51 -0400}]},
   {:type=>"Difference", :created_at=>2022-03-29 12:11:51 -0400, :id=>4, :parent_id=>1, :updated_at=>2022-03-29 12:11:51 -0400, :expressions=>[]},
   {:created_at=>2022-03-29 12:11:51 -0400, :id=>1, :start_month=>1, :end_month=>4, :start_day=>10, :end_day=>15, :temporal_set_expression_id=>1, :updated_at=>2022-03-29 12:11:51 -0400}]}

I would then have to modify the mapper to use constructor_inject to return PORO rather than hashes/structs.

I mean, could I use ROM::Relation::Combined#map_with that the spec demonstrates in order to get combine + mapper working?

I would like (if possible) to use the create command to write aggregates and have the result combined with newly created associations and mapped.

      set = subject.create_with_expr({
        type: "Union",
        parent_id: nil,
        range_each_year: [{
          start_month: 1,
          end_month: 4,
          start_day: 10,
          end_day: 15
          }
        ]
      })

      assert_kind_of SetExpression::Union, set
      assert_kind_of Expression::RangeEachYear, set.expressions[0]
      assert_equal 4, set.expressions[0].end_month

The relation is a hierarchical structure (parent_id points to self) with 8 other associations:

        associations do
          has_many :temporal_set_expressions, foreign_key: :parent_id
          has_many :range_each_year
          has_many :range_each_month
          has_many :range_each_week
          has_many :range_each_day
          has_many :range_after
          has_many :range_before
          has_many :day_each_month
          has_many :day_each_week
        end

Here is the current mapper (without constructor_inject which will need to be added) Note: I have added import ::Transproc::Recursion to get this working:

    class TemporalSetExpressionsMapper < ROM::Transformer
      relation :temporal_set_expressions
      register_as :temporal_set_expressions_mapper

      map do
        recursion do
          guard(->(expr) { expr.is_a?(Hash) && ['Union', 'Intersection', 'Difference'].include?(expr[:type]) }) do

            nest :expressions, [:temporal_set_expressions, :range_each_year, :range_each_month,
              :range_each_week, :range_each_day, :range_after, :range_before, :day_each_month,
              :day_each_week]

            map_value :expressions, ->(value) {
              (value[:temporal_set_expressions].nil? ? [] : value[:temporal_set_expressions]) |
              (value[:range_each_year].nil? ? [] : value[:range_each_year]) |
              (value[:range_each_month].nil? ? [] : value[:range_each_month]) |
              (value[:range_each_week].nil? ? [] : value[:range_each_week]) |
              (value[:range_each_day].nil? ? [] : value[:range_each_day]) |
              (value[:range_after].nil? ? [] : value[:range_after]) |
              (value[:range_before].nil? ? [] : value[:range_before]) |
              (value[:day_each_month].nil? ? [] : value[:day_each_month]) |
              (value[:day_each_week].nil? ? [] : value[:day_each_week])
            }
          end
        end
      end

Here is an example of the desired result from the create command. I would like the mapper init PORO rather than hashes/structs (using constructor_inject?).

=> {:type=>"Union",
 :created_at=>2022-03-29 12:11:51 -0400,
 :id=>1,
 :parent_id=>nil,
 :updated_at=>2022-03-29 12:11:51 -0400,
 :expressions=>
  [{:type=>"Intersection",
    :created_at=>2022-03-29 12:11:51 -0400,
    :id=>2,
    :parent_id=>1,
    :updated_at=>2022-03-29 12:11:51 -0400,
    :expressions=>
     [{:type=>"Union", :created_at=>2022-03-29 12:11:51 -0400, :id=>3, :parent_id=>2, :updated_at=>2022-03-29 12:11:51 -0400, :expressions=>[]},
      {:created_at=>2022-03-29 12:11:51 -0400, :id=>2, :start_month=>2, :end_month=>4, :start_day=>10, :end_day=>15, :temporal_set_expression_id=>2, :updated_at=>2022-03-29 12:11:51 -0400}]},
   {:type=>"Difference", :created_at=>2022-03-29 12:11:51 -0400, :id=>4, :parent_id=>1, :updated_at=>2022-03-29 12:11:51 -0400, :expressions=>[]},
   {:created_at=>2022-03-29 12:11:51 -0400, :id=>1, :start_month=>1, :end_month=>4, :start_day=>10, :end_day=>15, :temporal_set_expression_id=>1, :updated_at=>2022-03-29 12:11:51 -0400}]}

Please let me know if I can clarify anything for you!

This sounds a bit complicated. I’d recommend handling it explicitly via changesets and then getting back the desired result via another query with your custom mappers applied. Trying to collapse this entire process into a single command with custom mappers is maybe possible now, but definitely not easy.

Thank-you, I came to a similar conclusion that trying to do an aggregate create and have the result combined and mapped did not seem possible. So I broke it up into a write command and a query command.

Is there any way to tell the write command to only return id and not all attributes of relation? Is there any performance gain in doing so?

Thanks again for your time!

Not yet, but it will be possible in rom 6.0 :slightly_smiling_face: For now you can always implement your own command type and override default behavior.