Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Storing Postgres Array of Jsonb in Rails 5 Escapes Strings Unexpectedly

Perhaps my understanding of how this is supposed to work is wrong, but I seeing strings stored in my DB when I would expect them to be a jsonb array. Here is how I have things setup:

Migration

t.jsonb :variables, array: true

Model

attribute :variables, :variable, array: true

Custom ActiveRecord::Type

ActiveRecord::Type.register(:variable, Variable::Type)

Custom Variable Type

class Variable::Type < ActiveRecord::Type::Json
  include ActiveModel::Type::Helpers::Mutable

  # Type casts a value from user input (e.g. from a setter). This value may be a string from the form builder, or a ruby object passed to a setter. There is currently no way to differentiate between which source it came from.
  # - value: The raw input, as provided to the attribute setter.
  def cast(value)
    unless value.nil?
      value = Variable.new(value) if !value.kind_of?(Variable)
      value
    end
  end

  # Converts a value from database input to the appropriate ruby type. The return value of this method will be returned from ActiveRecord::AttributeMethods::Read#read_attribute. The default implementation just calls #cast.
  #  - value: The raw input, as provided from the database.
  def deserialize(value)
    unless value.nil?
      value = super if value.kind_of?(String)
      value = Variable.new(value) if value.kind_of?(Hash)
      value
    end
  end

So this method does work from the application's perspective. I can set the value as variables = [Variable.new, Variable.new] and it correctly stores in the DB, and retrieves back as an array of [Variable, Variable].

What concerns me, and the root of this question, is that in the database, the variable is stored using double escaped strings rather than json objects:

{
  "{\"token\": \"a\", \"value\": 1, \"default_value\": 1}",
  "{\"token\": \"b\", \"value\": 2, \"default_value\": 2}"
}

I would expect them to be stored something more resembling a json object like this:

{
  {"token": "a", "value": 1, "default_value": 1},
  {"token": "b", "value": 2, "default_value": 2}
}

The reason for this is that, from my understanding, future querying on this column directly from the DB will be faster/easier if in a json format, rather than a string format. Querying through rails would remain unaffected.

How can I get my Postgres DB to store the array of jsonb properly through rails?

like image 767
coneybeare Avatar asked Sep 05 '25 03:09

coneybeare


1 Answers

So it turns out that the Rails 5 attribute api is not perfect yet (and not well documented), and the Postgres array support was causing some problems, at least with the way I wanted to use it. I used the same approach to the problem for the solution, but rather than telling rails to use an array of my custom type, I am using a custom type array. Code speaks louder than words:

Migration

t.jsonb :variables, default: []

Model

attribute :variables, :variable_array, default: []

Custom ActiveRecord::Type

ActiveRecord::Type.register(:variable_array, VariableArrayType)

Custom Variable Type

class VariableArrayType < ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb

  def deserialize(value)
    value = super # turns raw json string into array of hashes
    if value.kind_of? Array
      value.map {|h| Variable.new(h)} # turns array of hashes into array of Variables
    else
      value
    end
  end

end

And now, as expected, the db entry is no longer stored as a string, but rather as searchable/indexable jsonb. The whole reason for this song and dance is that I can set the variables attribute using plain old ruby objects...

template.variables = [Variable.new(token: "a", default_value: 1), Variable.new(token: "b", default_value: 2)]

...then have it serialized as its jsonb representation in the DB...

[
  {"token": "a", "default_value": 1},
  {"token": "b", "default_value": 2}
]

...but more importantly, automatically deserialized and rehydrated back into the plain old ruby object, ready for me to interact with it.

Template.find(123).variables = [#<Variable:0x87654321 token: "a", default_value: 1>, #<Variable:0x12345678 token: "b", default_value: 2>]

Using the old serialize api causes a write with every save (intentionally by Rails architectural design), regardless of whether or not the serialized attribute had changed. Doing this all manually by overriding setters/getters is an unnecessary complication due to the numerous ways attributes can be assigned, and is partly the reason for the newer attributes api.

like image 179
coneybeare Avatar answered Sep 07 '25 21:09

coneybeare