Packages
electric
1.0.23
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.6.2
1.6.1
1.6.0
1.5.1
1.5.0
1.4.16
1.4.16-beta-1
1.4.15
1.4.14
1.4.13
1.4.12
1.4.11
1.4.10
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.4
1.3.3
1.3.2
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.14
1.1.13
1.1.12
1.1.11
1.1.10
1.1.9
1.1.8
1.1.7
1.1.6
retired
1.1.5
retired
1.1.4
retired
1.1.3
retired
1.1.2
1.1.1
1.1.0
1.0.24
1.0.23
1.0.22
1.0.21
1.0.20
1.0.19
1.0.18
1.0.17
1.0.15
1.0.13
1.0.12
1.0.11
1.0.10
1.0.9
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
1.0.0-beta.23
1.0.0-beta.22
1.0.0-beta.20
1.0.0-beta.19
1.0.0-beta.18
1.0.0-beta.17
1.0.0-beta.16
1.0.0-beta.15
1.0.0-beta.14
1.0.0-beta.13
1.0.0-beta.12
1.0.0-beta.11
1.0.0-beta.10
1.0.0-beta.9
1.0.0-beta.8
1.0.0-beta.7
1.0.0-beta.6
1.0.0-beta.5
1.0.0-beta.4
1.0.0-beta.3
1.0.0-beta.2
1.0.0-beta.1
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.1
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.5.2
0.4.4
Postgres sync engine. Sync little subsets of your Postgres data into local apps and services.
Current section
Files
Jump to
Current section
Files
lib/electric/replication/changes.ex
defmodule Electric.Replication.Changes do
@moduledoc """
This module contains structs that are intermediate representation of Postgres and Satellite transactions.
Some of the core assumptions in this module:
- We require PK always to be present for all tables
- For now PK modification is not supported
- PG replication protocol is expected to always send the *whole* row
when dealing with UPDATE changes, and optionally old row if REPLICA
identity is set to FULL.
"""
alias Electric.Replication.Changes
alias Electric.Replication.LogOffset
require Logger
@type db_identifier() :: String.t()
@type xid() :: non_neg_integer()
@type relation_name() :: {schema :: db_identifier(), table :: db_identifier()}
@type record() :: %{(column_name :: db_identifier()) => column_data :: binary()}
@type relation_id() :: non_neg_integer
@typedoc """
Tag has the form of `origin@timestamp`, where origin is a unique source id
(UUID for Satellite clients) and timestamp is millisecond-precision UTC unix timestamp
"""
@type tag() :: String.t()
@type pk() :: [String.t(), ...]
@type data_change() ::
Changes.NewRecord.t()
| Changes.UpdatedRecord.t()
| Changes.DeletedRecord.t()
@type change() :: data_change() | Changes.TruncatedRelation.t()
defmodule Transaction do
alias Electric.Replication.Changes
@type t() :: %__MODULE__{
xid: Changes.xid() | nil,
changes: [Changes.change()],
num_changes: non_neg_integer(),
affected_relations: MapSet.t(Changes.relation_name()),
commit_timestamp: DateTime.t(),
lsn: Electric.Postgres.Lsn.t(),
last_log_offset: LogOffset.t()
}
defstruct [
:xid,
:commit_timestamp,
:lsn,
:last_log_offset,
changes: [],
num_changes: 0,
affected_relations: MapSet.new()
]
@spec prepend_change(t(), Changes.change()) :: t()
def prepend_change(
%__MODULE__{changes: changes, affected_relations: rels} = txn,
%change_mod{relation: rel} = change
)
when change_mod in [
Changes.NewRecord,
Changes.UpdatedRecord,
Changes.DeletedRecord,
Changes.TruncatedRelation
] do
%{
txn
| changes: [change | changes],
num_changes: txn.num_changes + 1,
affected_relations: MapSet.put(rels, rel)
}
end
end
defmodule NewRecord do
defstruct [:relation, :record, :log_offset, :key]
@type t() :: %__MODULE__{
relation: Changes.relation_name(),
record: Changes.record(),
log_offset: LogOffset.t(),
key: String.t() | nil
}
end
defmodule UpdatedRecord do
defstruct [
:relation,
:old_record,
:record,
:log_offset,
:key,
:old_key,
tags: [],
changed_columns: MapSet.new()
]
@type t() :: %__MODULE__{
relation: Changes.relation_name(),
old_record: Changes.record() | nil,
record: Changes.record(),
log_offset: LogOffset.t(),
key: String.t() | nil,
old_key: String.t() | nil,
tags: [Changes.tag()],
changed_columns: MapSet.t()
}
def new(attrs) do
__MODULE__
|> struct(attrs)
|> build_changed_columns()
end
defp build_changed_columns(%{old_record: nil} = change) do
change
end
defp build_changed_columns(change) do
%{old_record: old, record: new} = change
# if the value is in the new but NOT the old, then it's being updated
# if it's in the old but NOT the new, then it's staying the same
changed =
Enum.reduce(new, MapSet.new(), fn {col_name, new_value}, changed ->
case Map.fetch(old, col_name) do
:error ->
MapSet.put(changed, col_name)
{:ok, old_value} ->
if old_value == new_value,
do: changed,
else: MapSet.put(changed, col_name)
end
end)
%{change | changed_columns: changed}
end
end
defmodule DeletedRecord do
defstruct [:relation, :old_record, :log_offset, :key, tags: []]
@type t() :: %__MODULE__{
relation: Changes.relation_name(),
old_record: Changes.record(),
log_offset: LogOffset.t(),
key: String.t() | nil,
tags: [Changes.tag()]
}
end
defmodule TruncatedRelation do
defstruct [:relation, :log_offset]
@type t() :: %__MODULE__{
relation: Changes.relation_name(),
log_offset: LogOffset.t()
}
end
defmodule Column do
@derive Jason.Encoder
defstruct [:name, :type_oid]
@type t() :: %__MODULE__{
name: Changes.db_identifier(),
type_oid: pos_integer()
}
end
defmodule Relation do
@derive Jason.Encoder
defstruct [:id, :schema, :table, :columns, affected_columns: []]
@type t() :: %__MODULE__{
id: Changes.relation_id(),
schema: Changes.db_identifier(),
table: Changes.db_identifier(),
columns: [Column.t()],
affected_columns: [Changes.db_identifier()]
}
end
@doc """
Build a unique key for a given record based on it's relation and PK.
Uses the `/` symbol as a PK separator, so any `/`s in the PK will
be escaped to avoid collisions.
## Examples
Build key respects PK column order:
iex> build_key({"hello", "world"}, %{"c" => "d", "a" => "b"}, ["a", "c"])
~S|"hello"."world"/"b"/"d"|
iex> build_key({"hello", "world"}, %{"a" => "b", "c" => "d"}, ["a", "c"])
~S|"hello"."world"/"b"/"d"|
Build key has `/` symbol in the PK escaped by repetition:
iex> build_key({"hello", "world"}, %{"a" => "test/test", "c" => "test"}, ["a", "c"])
~S|"hello"."world"/"test//test"/"test"|
iex> build_key({"hello", "world"}, %{"a" => "test", "c" => "test/test"}, ["a", "c"])
~S|"hello"."world"/"test"/"test//test"|
If a table has no PK, all columns are used, sorted by the column name:
iex> build_key({"hello", "world"}, %{"c" => "d", "a" => "b"}, [])
~S|"hello"."world"/"b"/"d"|
All pk sections are wrapped in quotes to allow for empty strings without generating a `//` pair.
iex> build_key({"hello", "world"}, %{"a" => "1", "b" => "", "c" => "2"}, [])
~S|"hello"."world"/"1"/""/"2"|
"""
def build_key(rel, record, pk_cols) when is_list(pk_cols) do
IO.iodata_to_binary([prefix_from_rel(rel), join_escape_pk(record, pk_cols)])
end
def fill_key(%TruncatedRelation{} = tr, _pk), do: tr
def fill_key(%UpdatedRecord{old_record: old_record, record: new_record} = change, pk) do
old_key = build_key(change.relation, old_record, pk)
new_key = build_key(change.relation, new_record, pk)
if old_key == new_key,
do: %{change | key: new_key},
else: %{change | old_key: old_key, key: new_key}
end
def fill_key(%NewRecord{relation: relation, record: record} = change, pk),
do: %{change | key: build_key(relation, record, pk)}
def fill_key(%DeletedRecord{relation: relation, old_record: old_record} = change, pk),
do: %{change | key: build_key(relation, old_record, pk)}
defp prefix_from_rel({schema, table}), do: [?", schema, ?", ?., ?", table, ?"]
defp join_escape_pk(record, []),
do:
record
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(fn {_, v} -> escape_pk_section(v) end)
defp join_escape_pk(record, pk_cols),
do: Enum.map(pk_cols, fn col -> escape_pk_section(Map.fetch!(record, col)) end)
defp escape_pk_section(v), do: [?/, ?", :binary.replace(v, "/", "//", [:global]), ?"]
@doc """
Convert an UpdatedRecord into the corresponding NewRecord or DeletedRecord
based on the provided `to` option.
## Examples
iex> convert_update(%UpdatedRecord{record: %{id: 1}}, to: :new_record)
%NewRecord{record: %{id: 1}}
iex> convert_update(%UpdatedRecord{record: %{id: 2}, old_record: %{id: 1}}, to: :deleted_record)
%DeletedRecord{old_record: %{id: 1}}
iex> convert_update(%UpdatedRecord{record: %{id: 1}}, to: :updated_record)
%UpdatedRecord{record: %{id: 1}}
"""
def convert_update(%UpdatedRecord{} = change, to: :new_record) do
%NewRecord{
relation: change.relation,
record: change.record,
key: change.key,
log_offset: change.log_offset
}
end
def convert_update(%UpdatedRecord{} = change, to: :deleted_record) do
%DeletedRecord{
relation: change.relation,
old_record: change.old_record,
key: change.old_key || change.key,
log_offset: change.log_offset,
tags: change.tags
}
end
def convert_update(%UpdatedRecord{} = change, to: :updated_record), do: change
@doc """
Filter the columns of a change to include only those provided in `columns_to_keep`.
## Examples
iex> filter_columns(%NewRecord{record: %{"a" => "b", "c" => "d"}}, ["a"])
%NewRecord{record: %{"a" => "b"}}
iex> filter_columns(UpdatedRecord.new(
...> record: %{"a" => "b", "c" => "d"},
...> old_record: %{"a" => "d", "c" => "f"}
...> ), ["a"])
UpdatedRecord.new(record: %{"a" => "b"}, old_record: %{"a" => "d"})
iex> filter_columns(%DeletedRecord{old_record: %{"a" => "b", "c" => "d"}}, ["c"])
%DeletedRecord{old_record: %{"c" => "d"}}
"""
@spec filter_columns(change(), [String.t()]) :: change()
def filter_columns(%NewRecord{} = change, columns_to_keep) do
%{change | record: change.record |> Map.take(columns_to_keep)}
end
def filter_columns(%UpdatedRecord{} = change, columns_to_keep) do
%{
change
| old_record: change.old_record |> Map.take(columns_to_keep),
record: change.record |> Map.take(columns_to_keep),
changed_columns:
change.changed_columns
|> MapSet.reject(fn col -> col not in columns_to_keep end)
}
end
def filter_columns(%DeletedRecord{} = change, columns_to_keep) do
%{change | old_record: change.old_record |> Map.take(columns_to_keep)}
end
def filter_columns(change, _), do: change
end