Make map encoding configurable
Closed this issue · 4 comments
I wanted to try the SQLite JSONB format, i.e. JSON stored as a BLOB instead of a TEXT.
I was able to do so by specifying the :blob type instead of :map in a migration.
defmodule Migration do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_sqlite.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:resources, primary_key: false) do
# add :my_map, :map, null: false <- generated line
add :my_map, :blob, null: false # <- edited line
add :id, :bigserial, null: false, primary_key: true
end
end
def down do
drop table(:resources)
end
endIt would be convenient if we could use :map in migrations, configuring :blob just once for the adapter, similar to how it is done in the Postgres adapter. TEXT could still be the default.
What do you think?
I think mimic'ing how it is done with the postgres adapter is a good idea. Since this feature was added to SQLite 3.45, (we are at 3.49), we will need to have the default be what it is now and set a timeline on when it will be swapped to be jsonb by default.
Feel free to open a PR for it! I don't have time this week to add it.
So this change allowed configuring the database column type (BLOB or TEXT) for :map fields
However actually using SQLite's JSONB format seems to require explicit usage of the functions jsonb during inserts, and json for reads, which doesn't integrate smoothly with how Ecto currently works.
I initially thought that inserting JSON data in a BLOB column would cast automatically to JSONB, but that doesn't appear to be the case. Without a strict table, SQLite still allows storing TEXT in BLOB columns without any type enforcement.
As it stands, this setting probably cannot become the default in the future. In hindsight I am unsure whether supporting this configuration adds value without deeper Ecto integration.
Here is a Livebook exploring some issues that arise when using SQLite's JSONB with Ecto
What do you think?
SQLite JSONB with Ecto
SQLite JSONB Ecto
Mix.install([:jason, {:ecto_sqlite3, "0.19.0"}, :kino_db])Section
defmodule Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.SQLite3
end{:module, Repo, <<70, 79, 82, 49, 0, 0, 86, ...>>, :ok}
database = System.tmp_dir!() |> Path.join(Ecto.UUID.generate())"/var/folders/1r/_mzmjvtd0hq5n9b7mwwcktnc0000gn/T/ca202987-aa60-4b96-b6e2-d9b0c653a47f"
Kino.start_child({Repo, database: database}){:ok, #PID<0.239.0>}
defmodule Migration do
use Ecto.Migration
def change do
create table("settings") do
add(:properties, :blob)
end
end
end
00:15:55.183 [error] Exqlite.Connection (#PID<0.246.0>) failed to connect: ** (Exqlite.Error) database is locked
{:module, Migration, <<70, 79, 82, 49, 0, 0, 12, ...>>, {:change, 0}}
Ecto.Migrator.down(Repo, 1, Migration)
Ecto.Migrator.up(Repo, 1, Migration)
00:15:55.225 [info] == Running 1 Migration.change/0 forward
00:15:55.226 [info] create table settings
00:15:55.231 [info] == Migrated 1 in 0.0s
:ok
defmodule Setting do
use Ecto.Schema
schema "settings" do
field(:properties, :map)
end
end{:module, Setting, <<70, 79, 82, 49, 0, 0, 41, ...>>, :ok}
Data is inserted without error but as TEXT
%Setting{properties: %{a: 1, b: 2, c: 3}}
|> Repo.insert!
00:15:55.284 [debug] QUERY OK source="settings" db=0.2ms idle=96.0ms
INSERT INTO "settings" ("properties") VALUES (?1) RETURNING "id" [%{c: 3, a: 1, b: 2}]
%Setting{
__meta__: #Ecto.Schema.Metadata<:loaded, "settings">,
id: 1,
properties: %{c: 3, a: 1, b: 2}
}
No error reading either
import Ecto.Query
Setting |> last() |> Repo.one()
00:15:55.288 [debug] QUERY OK source="settings" db=0.0ms idle=102.0ms
SELECT s0."id", s0."properties" FROM "settings" AS s0 ORDER BY s0."id" DESC LIMIT 1 []
%Setting{
__meta__: #Ecto.Schema.Metadata<:loaded, "settings">,
id: 1,
properties: %{"a" => 1, "b" => 2, "c" => 3}
}
defmodule StrictMigration do
use Ecto.Migration
def change do
create table("strict_settings", options: "STRICT") do
add(:properties, :blob)
end
end
end{:module, StrictMigration, <<70, 79, 82, 49, 0, 0, 13, ...>>, {:change, 0}}
Ecto.Migrator.down(Repo, 2, StrictMigration)
Ecto.Migrator.up(Repo, 2, StrictMigration)
00:15:55.306 [info] == Running 2 StrictMigration.change/0 forward
00:15:55.306 [info] create table strict_settings
00:15:55.306 [info] == Migrated 2 in 0.0s
:ok
defmodule StrictSetting do
use Ecto.Schema
schema "strict_settings" do
field(:properties, :map)
end
end{:module, StrictSetting, <<70, 79, 82, 49, 0, 0, 42, ...>>, :ok}
%StrictSetting{properties: %{a: 1, b: 2, c: 3}}
|> Repo.insert!
00:15:55.329 [debug] QUERY ERROR source="strict_settings" db=0.1ms idle=108.6ms
INSERT INTO "strict_settings" ("properties") VALUES (?1) RETURNING "id" [%{c: 3, a: 1, b: 2}]
** (Exqlite.Error) cannot store TEXT value in BLOB column strict_settings.properties
INSERT INTO "strict_settings" ("properties") VALUES (?1) RETURNING "id"
(ecto_sql 3.12.1) lib/ecto/adapters/sql.ex:1096: Ecto.Adapters.SQL.raise_sql_call_error/1
(ecto 3.12.5) lib/ecto/repo/schema.ex:837: Ecto.Repo.Schema.apply/4
(ecto 3.12.5) lib/ecto/repo/schema.ex:416: anonymous fn/15 in Ecto.Repo.Schema.do_insert/4
(ecto 3.12.5) lib/ecto/repo/schema.ex:311: Ecto.Repo.Schema.insert!/4
#cell:qfo3w7xwxhptdmw4:2: (file)
This is the intended way to store data as JSONB, not sure whether this could be integrated in Ecto easily
value = %{a: 1, b: 2, c: 3} |> Jason.encode!()
Repo.query!("insert into strict_settings (properties) select jsonb('#{value}')")
00:16:03.149 [debug] QUERY OK db=1.1ms idle=1979.2ms
insert into strict_settings (properties) select jsonb('{"c":3,"a":1,"b":2}') []
%Exqlite.Result{command: :execute, columns: [], rows: [], num_rows: 0}
import Ecto.Query
StrictSetting |> last() |> Repo.one()
00:16:07.426 [debug] QUERY OK source="strict_settings" db=0.4ms queue=0.4ms idle=1256.2ms
SELECT s0."id", s0."properties" FROM "strict_settings" AS s0 ORDER BY s0."id" DESC LIMIT 1 []
** (ArgumentError) cannot load `<<204, 12, 23, 99, 19, 51, 23, 97, 19, 49, 23, 98, 19, 50>>` as type :map for field :properties in %StrictSetting{__meta__: #Ecto.Schema.Metadata<:loaded, "strict_settings">, id: nil, properties: nil}
(ecto 3.12.5) lib/ecto/repo/queryable.ex:431: Ecto.Repo.Queryable.struct_load!/6
(ecto 3.12.5) lib/ecto/repo/queryable.ex:246: anonymous fn/5 in Ecto.Repo.Queryable.preprocessor/3
(elixir 1.18.4) lib/enum.ex:1714: Enum."-map/2-lists^map/1-1-"/2
(ecto 3.12.5) lib/ecto/repo/queryable.ex:237: Ecto.Repo.Queryable.execute/4
(ecto 3.12.5) lib/ecto/repo/queryable.ex:19: Ecto.Repo.Queryable.all/3
(ecto 3.12.5) lib/ecto/repo/queryable.ex:154: Ecto.Repo.Queryable.one/3
#cell:ephtgpofnke6eas4:2: (file)
Repo.query!("select json(properties) from strict_settings")
00:16:13.010 [debug] QUERY OK db=2.1ms queue=0.8ms idle=1835.0ms
select json(properties) from strict_settings []
%Exqlite.Result{command: :execute, columns: ["json(properties)"], rows: [["{\"c\":3,\"a\":1,\"b\":2}"]], num_rows: 1}
JSONB and strict settings on tables are fairly recent addition to sqlite.
Longer term, I think ecto probably needs to change a little bit up stream and send along type encoding where possible for columns so that the adapters know which cases we can use specific functions. Do I think this will happen? Probably not.
This also falls a bit on the SQLite team as well. We only have 5 native column types: NULL, INTEGER, REAL, TEXT, or BLOB. There is no dedicated JSONB type and if we were to set that for a column type, under the hood IIRC gets set as a BLOB.
https://www.sqlite.org/datatype3.html
https://sqlite.org/jsonb.html