diff --git a/.changeset/twenty-poets-hear.md b/.changeset/twenty-poets-hear.md new file mode 100644 index 0000000000..b96b9b64f4 --- /dev/null +++ b/.changeset/twenty-poets-hear.md @@ -0,0 +1,5 @@ +--- +"@core/electric": patch +--- + +Add server-side enforcement of "NOT NULL" for values incoming from Satellite clients diff --git a/clients/typescript/src/_generated/protocol/satellite.ts b/clients/typescript/src/_generated/protocol/satellite.ts index dff79d7962..8c51917751 100644 --- a/clients/typescript/src/_generated/protocol/satellite.ts +++ b/clients/typescript/src/_generated/protocol/satellite.ts @@ -187,6 +187,7 @@ export interface SatRelationColumn { name: string; type: string; primaryKey: boolean; + isNullable: boolean; } export interface SatRelation { @@ -1242,7 +1243,13 @@ export const SatInStopReplicationResp = { messageTypeRegistry.set(SatInStopReplicationResp.$type, SatInStopReplicationResp); function createBaseSatRelationColumn(): SatRelationColumn { - return { $type: "Electric.Satellite.v1_4.SatRelationColumn", name: "", type: "", primaryKey: false }; + return { + $type: "Electric.Satellite.v1_4.SatRelationColumn", + name: "", + type: "", + primaryKey: false, + isNullable: false, + }; } export const SatRelationColumn = { @@ -1258,6 +1265,9 @@ export const SatRelationColumn = { if (message.primaryKey === true) { writer.uint32(24).bool(message.primaryKey); } + if (message.isNullable === true) { + writer.uint32(32).bool(message.isNullable); + } return writer; }, @@ -1289,6 +1299,13 @@ export const SatRelationColumn = { message.primaryKey = reader.bool(); continue; + case 4: + if (tag !== 32) { + break; + } + + message.isNullable = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1307,6 +1324,7 @@ export const SatRelationColumn = { message.name = object.name ?? ""; message.type = object.type ?? ""; message.primaryKey = object.primaryKey ?? false; + message.isNullable = object.isNullable ?? false; return message; }, }; diff --git a/clients/typescript/src/satellite/client.ts b/clients/typescript/src/satellite/client.ts index 811c6c201b..95cfad5821 100644 --- a/clients/typescript/src/satellite/client.ts +++ b/clients/typescript/src/satellite/client.ts @@ -550,7 +550,11 @@ export class SatelliteClient extends EventEmitter implements Client { tableName: relation.table, tableType: relation.tableType, columns: relation.columns.map((c) => - SatRelationColumn.fromPartial({ name: c.name, type: c.type }) + SatRelationColumn.fromPartial({ + name: c.name, + type: c.type, + isNullable: c.isNullable, + }) ), }) @@ -750,6 +754,7 @@ export class SatelliteClient extends EventEmitter implements Client { columns: message.columns.map((c) => ({ name: c.name, type: c.type, + isNullable: c.isNullable, primaryKey: c.primaryKey, })), } diff --git a/clients/typescript/src/satellite/process.ts b/clients/typescript/src/satellite/process.ts index 4ad69a8180..ae4f1096d3 100644 --- a/clients/typescript/src/satellite/process.ts +++ b/clients/typescript/src/satellite/process.ts @@ -1375,6 +1375,7 @@ export class SatelliteProcess implements Satellite { relation.columns.push({ name: c.name!.toString(), type: c.type!.toString(), + isNullable: Boolean(!c.notnull!.valueOf()), primaryKey: Boolean(c.pk!.valueOf()), }) } diff --git a/clients/typescript/src/util/types.ts b/clients/typescript/src/util/types.ts index 4b00c926db..5966f23b51 100644 --- a/clients/typescript/src/util/types.ts +++ b/clients/typescript/src/util/types.ts @@ -152,6 +152,7 @@ export type Relation = { export type RelationColumn = { name: string type: string + isNullable: boolean primaryKey?: boolean } diff --git a/clients/typescript/test/client/model/shapes.test.ts b/clients/typescript/test/client/model/shapes.test.ts index 6cdc5083c7..b0fa147b37 100644 --- a/clients/typescript/test/client/model/shapes.test.ts +++ b/clients/typescript/test/client/model/shapes.test.ts @@ -189,26 +189,31 @@ const relations = { { name: 'id', type: 'INTEGER', + isNullable: false, primaryKey: true, }, { name: 'title', type: 'TEXT', + isNullable: true, primaryKey: false, }, { name: 'contents', type: 'TEXT', + isNullable: true, primaryKey: false, }, { name: 'nbr', type: 'INTEGER', + isNullable: true, primaryKey: false, }, { name: 'authorId', type: 'INTEGER', + isNullable: true, primaryKey: false, }, ], @@ -222,16 +227,19 @@ const relations = { { name: 'id', type: 'INTEGER', + isNullable: false, primaryKey: true, }, { name: 'bio', type: 'TEXT', + isNullable: true, primaryKey: false, }, { name: 'userId', type: 'INTEGER', + isNullable: true, primaryKey: false, }, ], diff --git a/clients/typescript/test/satellite/client.test.ts b/clients/typescript/test/satellite/client.test.ts index 1d2271c1f7..1a9cb1ddfd 100644 --- a/clients/typescript/test/satellite/client.test.ts +++ b/clients/typescript/test/satellite/client.test.ts @@ -309,8 +309,8 @@ test.serial('receive transaction over multiple messages', async (t) => { table: 'table', tableType: Proto.SatRelation_RelationType.TABLE, columns: [ - { name: 'name1', type: 'TEXT' }, - { name: 'name2', type: 'TEXT' }, + { name: 'name1', type: 'TEXT', isNullable: true }, + { name: 'name2', type: 'TEXT', isNullable: true }, ], } @@ -320,8 +320,16 @@ test.serial('receive transaction over multiple messages', async (t) => { tableName: 'table', tableType: Proto.SatRelation_RelationType.TABLE, columns: [ - Proto.SatRelationColumn.fromPartial({ name: 'name1', type: 'TEXT' }), - Proto.SatRelationColumn.fromPartial({ name: 'name2', type: 'TEXT' }), + Proto.SatRelationColumn.fromPartial({ + name: 'name1', + type: 'TEXT', + isNullable: true, + }), + Proto.SatRelationColumn.fromPartial({ + name: 'name2', + type: 'TEXT', + isNullable: true, + }), ], }) @@ -614,12 +622,12 @@ test.serial('default and null test', async (t) => { table: 'Items', tableType: Proto.SatRelation_RelationType.TABLE, columns: [ - { name: 'id', type: 'uuid' }, - { name: 'content', type: 'text' }, - { name: 'text_null', type: 'text' }, - { name: 'text_null_default', type: 'text' }, - { name: 'intvalue_null', type: 'integer' }, - { name: 'intvalue_null_default', type: 'integer' }, + { name: 'id', type: 'uuid', isNullable: false }, + { name: 'content', type: 'text', isNullable: false }, + { name: 'text_null', type: 'text', isNullable: true }, + { name: 'text_null_default', type: 'text', isNullable: true }, + { name: 'intvalue_null', type: 'integer', isNullable: true }, + { name: 'intvalue_null_default', type: 'integer', isNullable: true }, ], } @@ -929,8 +937,8 @@ test.serial('subscription correct protocol sequence with data', async (t) => { table: 'table', tableType: Proto.SatRelation_RelationType.TABLE, columns: [ - { name: 'name1', type: 'TEXT' }, - { name: 'name2', type: 'TEXT' }, + { name: 'name1', type: 'TEXT', isNullable: true }, + { name: 'name2', type: 'TEXT', isNullable: true }, ], } diff --git a/clients/typescript/test/satellite/common.ts b/clients/typescript/test/satellite/common.ts index 0b36957bd2..5684d5ea50 100644 --- a/clients/typescript/test/satellite/common.ts +++ b/clients/typescript/test/satellite/common.ts @@ -27,11 +27,13 @@ export const relations = { { name: 'id', type: 'INTEGER', + isNullable: false, primaryKey: true, }, { name: 'parent', type: 'INTEGER', + isNullable: true, primaryKey: false, }, ], @@ -45,16 +47,19 @@ export const relations = { { name: 'id', type: 'INTEGER', + isNullable: false, primaryKey: true, }, { name: 'value', type: 'TEXT', + isNullable: true, primaryKey: false, }, { name: 'other', type: 'INTEGER', + isNullable: true, primaryKey: false, }, ], @@ -68,6 +73,7 @@ export const relations = { { name: 'id', type: 'INTEGER', + isNullable: false, primaryKey: true, }, ], diff --git a/clients/typescript/test/satellite/process.migration.test.ts b/clients/typescript/test/satellite/process.migration.test.ts index 4bddbf98c3..e80c1e8de1 100644 --- a/clients/typescript/test/satellite/process.migration.test.ts +++ b/clients/typescript/test/satellite/process.migration.test.ts @@ -157,21 +157,25 @@ const addColumnRelation = { { name: 'id', type: 'INTEGER', + isNullable: false, primaryKey: true, }, { name: 'value', type: 'TEXT', + isNullable: true, primaryKey: false, }, { name: 'other', type: 'INTEGER', + isNullable: true, primaryKey: false, }, { name: 'baz', type: 'TEXT', + isNullable: true, primaryKey: false, }, ], @@ -185,16 +189,19 @@ const newTableRelation = { { name: 'id', type: 'TEXT', + isNullable: false, primaryKey: true, }, { name: 'foo', type: 'INTEGER', + isNullable: true, primaryKey: false, }, { name: 'bar', type: 'TEXT', + isNullable: true, primaryKey: false, }, ], diff --git a/clients/typescript/test/satellite/serialization.test.ts b/clients/typescript/test/satellite/serialization.test.ts index 30ee783cf7..8d378fbcfc 100644 --- a/clients/typescript/test/satellite/serialization.test.ts +++ b/clients/typescript/test/satellite/serialization.test.ts @@ -10,13 +10,13 @@ test('serialize/deserialize row data', async (t) => { table: 'table', tableType: SatRelation_RelationType.TABLE, columns: [ - { name: 'name1', type: 'TEXT' }, - { name: 'name2', type: 'TEXT' }, - { name: 'name3', type: 'TEXT' }, - { name: 'int1', type: 'INTEGER' }, - { name: 'int2', type: 'INTEGER' }, - { name: 'float1', type: 'FLOAT4' }, - { name: 'float2', type: 'FLOAT4' }, + { name: 'name1', type: 'TEXT', isNullable: true }, + { name: 'name2', type: 'TEXT', isNullable: true }, + { name: 'name3', type: 'TEXT', isNullable: true }, + { name: 'int1', type: 'INTEGER', isNullable: true }, + { name: 'int2', type: 'INTEGER', isNullable: true }, + { name: 'float1', type: 'FLOAT4', isNullable: true }, + { name: 'float2', type: 'FLOAT4', isNullable: true }, ], } @@ -42,15 +42,15 @@ test('Null mask uses bits as if they were a list', async (t) => { table: 'table', tableType: SatRelation_RelationType.TABLE, columns: [ - { name: 'bit0', type: 'TEXT' }, - { name: 'bit1', type: 'TEXT' }, - { name: 'bit2', type: 'TEXT' }, - { name: 'bit3', type: 'TEXT' }, - { name: 'bit4', type: 'TEXT' }, - { name: 'bit5', type: 'TEXT' }, - { name: 'bit6', type: 'TEXT' }, - { name: 'bit7', type: 'TEXT' }, - { name: 'bit8', type: 'TEXT' }, + { name: 'bit0', type: 'TEXT', isNullable: true }, + { name: 'bit1', type: 'TEXT', isNullable: true }, + { name: 'bit2', type: 'TEXT', isNullable: true }, + { name: 'bit3', type: 'TEXT', isNullable: true }, + { name: 'bit4', type: 'TEXT', isNullable: true }, + { name: 'bit5', type: 'TEXT', isNullable: true }, + { name: 'bit6', type: 'TEXT', isNullable: true }, + { name: 'bit7', type: 'TEXT', isNullable: true }, + { name: 'bit8', type: 'TEXT', isNullable: true }, ], } diff --git a/components/electric/lib/electric/postgres/replication.ex b/components/electric/lib/electric/postgres/replication.ex index f47fdd5f6c..c45ed84b90 100644 --- a/components/electric/lib/electric/postgres/replication.ex +++ b/components/electric/lib/electric/postgres/replication.ex @@ -10,6 +10,7 @@ defmodule Electric.Postgres.Replication do defstruct [ :name, :type, + :nullable?, type_modifier: -1, part_of_identity?: false ] @@ -19,6 +20,7 @@ defmodule Electric.Postgres.Replication do @type t() :: %__MODULE__{ name: name(), type: atom(), + nullable?: boolean(), type_modifier: integer(), part_of_identity?: boolean() | nil } diff --git a/components/electric/lib/electric/postgres/schema.ex b/components/electric/lib/electric/postgres/schema.ex index b2886a32f3..864ba4d6e8 100644 --- a/components/electric/lib/electric/postgres/schema.ex +++ b/components/electric/lib/electric/postgres/schema.ex @@ -192,6 +192,7 @@ defmodule Electric.Postgres.Schema do %Replication.Column{ name: col.name, type: col_type(col.type), + nullable?: col_nullable?(col), type_modifier: List.first(col.type.size, -1), # since we're using replication identity "full" all columns # are identity columns in replication terms @@ -219,6 +220,12 @@ defmodule Electric.Postgres.Schema do defp col_type("serial8"), do: :int8 defp col_type(t) when is_binary(t), do: String.to_atom(t) + defp col_nullable?(col) do + col.constraints + |> Enum.find(&match?(%Proto.Constraint{constraint: {:not_null, _}}, &1)) + |> is_nil() + end + def relation(schema, sname, tname) do with {:ok, table} <- fetch_table(schema, {sname, tname}) do table_info(table) diff --git a/components/electric/lib/electric/satellite/protobuf_messages.ex b/components/electric/lib/electric/satellite/protobuf_messages.ex index 0ef441b3e8..d2c6417e86 100644 --- a/components/electric/lib/electric/satellite/protobuf_messages.ex +++ b/components/electric/lib/electric/satellite/protobuf_messages.ex @@ -5972,7 +5972,7 @@ end, defmodule Electric.Satellite.V14.SatRelationColumn do @moduledoc false - defstruct name: "", type: "", primaryKey: false, __uf__: [] + defstruct name: "", type: "", primaryKey: false, is_nullable: false, __uf__: [] ( ( @@ -5991,6 +5991,7 @@ |> encode_name(msg) |> encode_type(msg) |> encode_primaryKey(msg) + |> encode_is_nullable(msg) |> encode_unknown_fields(msg) end ) @@ -6033,6 +6034,19 @@ ArgumentError -> reraise Protox.EncodingError.new(:primaryKey, "invalid field value"), __STACKTRACE__ end + end, + defp encode_is_nullable(acc, msg) do + try do + if msg.is_nullable == false do + acc + else + [acc, " ", Protox.Encode.encode_bool(msg.is_nullable)] + end + rescue + ArgumentError -> + reraise Protox.EncodingError.new(:is_nullable, "invalid field value"), + __STACKTRACE__ + end end ] @@ -6102,6 +6116,10 @@ {value, rest} = Protox.Decode.parse_bool(bytes) {[primaryKey: value], rest} + {4, _, bytes} -> + {value, rest} = Protox.Decode.parse_bool(bytes) + {[is_nullable: value], rest} + {tag, wire_type, rest} -> {value, rest} = Protox.Decode.parse_unknown(tag, wire_type, rest) @@ -6165,7 +6183,8 @@ %{ 1 => {:name, {:scalar, ""}, :string}, 2 => {:type, {:scalar, ""}, :string}, - 3 => {:primaryKey, {:scalar, false}, :bool} + 3 => {:primaryKey, {:scalar, false}, :bool}, + 4 => {:is_nullable, {:scalar, false}, :bool} } end @@ -6175,6 +6194,7 @@ } def defs_by_name() do %{ + is_nullable: {4, {:scalar, false}, :bool}, name: {1, {:scalar, ""}, :string}, primaryKey: {3, {:scalar, false}, :bool}, type: {2, {:scalar, ""}, :string} @@ -6212,6 +6232,15 @@ name: :primaryKey, tag: 3, type: :bool + }, + %{ + __struct__: Protox.Field, + json_name: "isNullable", + kind: {:scalar, false}, + label: :optional, + name: :is_nullable, + tag: 4, + type: :bool } ] end @@ -6305,6 +6334,46 @@ [] ), + ( + def field_def(:is_nullable) do + {:ok, + %{ + __struct__: Protox.Field, + json_name: "isNullable", + kind: {:scalar, false}, + label: :optional, + name: :is_nullable, + tag: 4, + type: :bool + }} + end + + def field_def("isNullable") do + {:ok, + %{ + __struct__: Protox.Field, + json_name: "isNullable", + kind: {:scalar, false}, + label: :optional, + name: :is_nullable, + tag: 4, + type: :bool + }} + end + + def field_def("is_nullable") do + {:ok, + %{ + __struct__: Protox.Field, + json_name: "isNullable", + kind: {:scalar, false}, + label: :optional, + name: :is_nullable, + tag: 4, + type: :bool + }} + end + ), def field_def(_) do {:error, :no_such_field} end @@ -6353,6 +6422,9 @@ def default(:primaryKey) do {:ok, false} end, + def default(:is_nullable) do + {:ok, false} + end, def default(_) do {:error, :no_such_field} end diff --git a/components/electric/lib/electric/satellite/protocol.ex b/components/electric/lib/electric/satellite/protocol.ex index 03d8c7d6f3..e8b2327a5f 100644 --- a/components/electric/lib/electric/satellite/protocol.ex +++ b/components/electric/lib/electric/satellite/protocol.ex @@ -429,8 +429,8 @@ defmodule Electric.Satellite.Protocol do relation_columns = Map.new(columns, &{&1.name, &1.type}) columns = - Enum.map(msg.columns, fn %SatRelationColumn{name: name} -> - %{name: name, type: Map.fetch!(relation_columns, name)} + Enum.map(msg.columns, fn %SatRelationColumn{name: name} = col -> + %{name: name, type: Map.fetch!(relation_columns, name), nullable?: col.is_nullable} end) relations = diff --git a/components/electric/lib/electric/satellite/serialization.ex b/components/electric/lib/electric/satellite/serialization.ex index 332bb8f24f..8eddd4a4fa 100644 --- a/components/electric/lib/electric/satellite/serialization.ex +++ b/components/electric/lib/electric/satellite/serialization.ex @@ -110,10 +110,7 @@ defmodule Electric.Satellite.Serialization do # unlikely since the extension tables have constraints that prevent this if version && version != v, - do: - raise(RuntimeError, - message: "Got DDL transaction with differing migration versions" - ) + do: raise("Got DDL transaction with differing migration versions") {:ok, schema} = maybe_load_schema(origin, schema, v) @@ -293,8 +290,13 @@ defmodule Electric.Satellite.Serialization do end defp serialize_table_columns(columns, pks) do - Enum.map(columns, fn %{name: name, type: type} -> - %SatRelationColumn{name: name, type: to_string(type), primaryKey: MapSet.member?(pks, name)} + Enum.map(columns, fn %{name: name, type: type, nullable?: nullable?} -> + %SatRelationColumn{ + name: name, + type: to_string(type), + is_nullable: nullable?, + primaryKey: MapSet.member?(pks, name) + } end) end @@ -419,6 +421,10 @@ defmodule Electric.Satellite.Serialization do ] end + defp decode_values(_, <<1::1, _::bits>>, [%{nullable?: false} | _]) do + raise "protocol violation, null value for a not null column" + end + defp decode_values([_val | values], <<1::1, bitmask::bits>>, [col | columns]) do [{col.name, nil} | decode_values(values, bitmask, columns)] end diff --git a/components/electric/lib/satellite/satellite_ws_client.ex b/components/electric/lib/satellite/satellite_ws_client.ex index 0774538c57..9967afe668 100644 --- a/components/electric/lib/satellite/satellite_ws_client.ex +++ b/components/electric/lib/satellite/satellite_ws_client.ex @@ -145,9 +145,9 @@ defmodule Electric.Test.SatelliteWsClient do def send_test_relation(conn \\ __MODULE__) do relation = %SatRelation{ columns: [ - %SatRelationColumn{name: "id", type: "uuid"}, - %SatRelationColumn{name: "content", type: "varchar"}, - %SatRelationColumn{name: "content_b", type: "varchar"} + %SatRelationColumn{name: "id", type: "uuid", is_nullable: false}, + %SatRelationColumn{name: "content", type: "varchar", is_nullable: false}, + %SatRelationColumn{name: "content_b", type: "varchar", is_nullable: true} ], relation_id: 11111, schema_name: "public", @@ -162,9 +162,9 @@ defmodule Electric.Test.SatelliteWsClient do def send_test_relation_owned(conn \\ __MODULE__) do relation = %SatRelation{ columns: [ - %SatRelationColumn{name: "id", type: "uuid"}, - %SatRelationColumn{name: "electric_user_id", type: "varchar"}, - %SatRelationColumn{name: "content", type: "varchar"} + %SatRelationColumn{name: "id", type: "uuid", is_nullable: false}, + %SatRelationColumn{name: "electric_user_id", type: "varchar", is_nullable: false}, + %SatRelationColumn{name: "content", type: "varchar", is_nullable: false} ], relation_id: 22222, schema_name: "public", diff --git a/components/electric/test/electric/postgres/extension/schema_cache_test.exs b/components/electric/test/electric/postgres/extension/schema_cache_test.exs index 98ed64cabf..d0e95acdf7 100644 --- a/components/electric/test/electric/postgres/extension/schema_cache_test.exs +++ b/components/electric/test/electric/postgres/extension/schema_cache_test.exs @@ -65,7 +65,7 @@ defmodule Electric.Postgres.Extension.SchemaCacheTest do ] setup do - # we run the sql on the db which sets up a valid environment then simulate the + # we run the sql on the db which sets up a valid environment then simulate the # same things here to avoid having to commit the transaction migrations = [ {"20230620160340", [@create_a]}, @@ -189,12 +189,14 @@ defmodule Electric.Postgres.Extension.SchemaCacheTest do %Column{ name: "aid", type: :uuid, + nullable?: false, type_modifier: -1, part_of_identity?: true }, %Column{ name: "avalue", type: :text, + nullable?: true, type_modifier: -1, part_of_identity?: true } @@ -219,12 +221,14 @@ defmodule Electric.Postgres.Extension.SchemaCacheTest do %Column{ name: "aid", type: :uuid, + nullable?: false, type_modifier: -1, part_of_identity?: true }, %Column{ name: "avalue", type: :text, + nullable?: true, type_modifier: -1, part_of_identity?: true } @@ -251,24 +255,28 @@ defmodule Electric.Postgres.Extension.SchemaCacheTest do %Column{ name: "aid", type: :uuid, + nullable?: false, type_modifier: -1, part_of_identity?: true }, %Column{ name: "avalue", type: :text, + nullable?: true, type_modifier: -1, part_of_identity?: true }, %Column{ name: "aupdated", type: :timestamptz, + nullable?: true, type_modifier: -1, part_of_identity?: true }, %Column{ name: "aname", type: :varchar, + nullable?: true, type_modifier: 63, part_of_identity?: true } @@ -297,24 +305,28 @@ defmodule Electric.Postgres.Extension.SchemaCacheTest do %Column{ name: "aid", type: :uuid, + nullable?: false, type_modifier: -1, part_of_identity?: true }, %Column{ name: "avalue", type: :text, + nullable?: true, type_modifier: -1, part_of_identity?: true }, %Column{ name: "aupdated", type: :timestamptz, + nullable?: true, type_modifier: -1, part_of_identity?: true }, %Column{ name: "aname", type: :varchar, + nullable?: true, type_modifier: 63, part_of_identity?: true } @@ -327,12 +339,14 @@ defmodule Electric.Postgres.Extension.SchemaCacheTest do %Column{ name: "aid", type: :uuid, + nullable?: false, type_modifier: -1, part_of_identity?: true }, %Column{ name: "avalue", type: :text, + nullable?: true, type_modifier: -1, part_of_identity?: true } @@ -352,18 +366,21 @@ defmodule Electric.Postgres.Extension.SchemaCacheTest do %Column{ name: "bid1", type: :int4, + nullable?: false, type_modifier: -1, part_of_identity?: true }, %Column{ name: "bid2", type: :int4, + nullable?: false, type_modifier: -1, part_of_identity?: true }, %Column{ name: "bvalue", type: :text, + nullable?: true, type_modifier: -1, part_of_identity?: true } diff --git a/components/electric/test/electric/postgres/table_test.exs b/components/electric/test/electric/postgres/table_test.exs index 715ee79aa1..f19c5ddd6f 100644 --- a/components/electric/test/electric/postgres/table_test.exs +++ b/components/electric/test/electric/postgres/table_test.exs @@ -1047,8 +1047,20 @@ defmodule Electric.Postgres.TableTest do primary_keys: ["c1", "c2"], replica_identity: :all_columns, columns: [ - %Column{name: "c1", type: :int4, type_modifier: -1, part_of_identity?: true}, - %Column{name: "c2", type: :int4, type_modifier: -1, part_of_identity?: true} + %Column{ + name: "c1", + type: :int4, + nullable?: false, + type_modifier: -1, + part_of_identity?: true + }, + %Column{ + name: "c2", + type: :int4, + nullable?: false, + type_modifier: -1, + part_of_identity?: true + } ] } end @@ -1074,10 +1086,17 @@ defmodule Electric.Postgres.TableTest do primary_keys: ["id"], replica_identity: :all_columns, columns: [ - %Column{name: "id", type: :uuid, type_modifier: -1, part_of_identity?: true}, + %Column{ + name: "id", + type: :uuid, + nullable?: false, + type_modifier: -1, + part_of_identity?: true + }, %Column{ name: "values", type: {:array, :int4}, + nullable?: true, type_modifier: -1, part_of_identity?: true } @@ -1093,10 +1112,17 @@ defmodule Electric.Postgres.TableTest do primary_keys: ["id"], replica_identity: :all_columns, columns: [ - %Column{name: "id", type: :uuid, type_modifier: -1, part_of_identity?: true}, + %Column{ + name: "id", + type: :uuid, + nullable?: false, + type_modifier: -1, + part_of_identity?: true + }, %Column{ name: "values", type: {:array, :int4}, + nullable?: true, type_modifier: -1, part_of_identity?: true } diff --git a/components/electric/test/electric/satellite/ws_validations_test.exs b/components/electric/test/electric/satellite/ws_validations_test.exs index 6458a3adb7..75a72eb09b 100644 --- a/components/electric/test/electric/satellite/ws_validations_test.exs +++ b/components/electric/test/electric/satellite/ws_validations_test.exs @@ -79,7 +79,9 @@ defmodule Electric.Satellite.WsValidationsTest do records = [ %{"id" => "1", "num" => "abc", "t2" => "hello"}, %{"id" => "2", "num" => "32768", "t2" => ""}, - %{"id" => "3", "num" => "-32769", "t2" => ""} + %{"id" => "3", "num" => "-32769", "t2" => ""}, + %{"id" => "4", "t2" => nil}, + %{"id" => nil, "t2" => "..."} ] Enum.each(records, fn record -> diff --git a/protocol/satellite.proto b/protocol/satellite.proto index a615740b3a..bce3cccc62 100644 --- a/protocol/satellite.proto +++ b/protocol/satellite.proto @@ -176,6 +176,7 @@ message SatRelationColumn { string name = 1; string type = 2; bool primaryKey = 3; + bool is_nullable = 4; } message SatRelation {