Skip to content

Commit e45d390

Browse files
authored
fix: disallow generated columns in shapes (#2507)
Closes #2448 Generated columns cannot be included in a publication by PostgreSQL, so we disallow them in shapes. When doing `CREATE PUBLICATION p FOR TABLE t` without a column list, generated columns are omitted. When doing an explicit `FOR TABLE t (id)` (if `id` is generated), then it says `ERROR: cannot use generated column "id" in publication column list`. We could, later, allow generated columns based on the subset of where clause functions we support by filling the stored values on our side too, but that's a way off.
1 parent 5deeccd commit e45d390

File tree

13 files changed

+131
-16
lines changed

13 files changed

+131
-16
lines changed

.changeset/forty-buttons-accept.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@core/sync-service": patch
3+
---
4+
5+
fix: disallow generated columns in shapes

packages/sync-service/lib/electric/postgres/inspector/direct_inspector.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ defmodule Electric.Postgres.Inspector.DirectInspector do
8888
attndims as array_dimensions,
8989
atttypmod as type_mod,
9090
attnotnull as not_null,
91+
attgenerated != '' as is_generated,
9192
pg_type.typname as type,
9293
pg_type.typtype as type_kind, -- e.g. an enum is kind 'e'
9394
elem_pg_type.typname as array_type, -- type of the element inside the array or nil if it's not an array

packages/sync-service/lib/electric/shapes/shape.ex

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,19 @@ defmodule Electric.Shapes.Shape do
174174
[String.t(), ...] | nil
175175
) ::
176176
{:ok, [String.t(), ...] | nil} | {:error, {:columns, [String.t()]}}
177-
defp validate_selected_columns(column_info, _pk_cols, nil) do
178-
{:ok, Enum.map(column_info, & &1.name)}
177+
defp validate_selected_columns(column_info, pk_cols, nil) do
178+
validate_selected_columns(column_info, pk_cols, Enum.map(column_info, & &1.name))
179179
end
180180

181181
defp validate_selected_columns(column_info, pk_cols, columns_to_select) do
182182
missing_pk_cols = pk_cols -- columns_to_select
183183
invalid_cols = columns_to_select -- Enum.map(column_info, & &1.name)
184184

185+
generated_cols =
186+
column_info
187+
|> Enum.filter(&(&1.is_generated and &1.name in columns_to_select))
188+
|> Enum.map(& &1.name)
189+
185190
cond do
186191
missing_pk_cols != [] ->
187192
{:error,
@@ -197,6 +202,13 @@ defmodule Electric.Shapes.Shape do
197202
"The following columns could not be found: #{invalid_cols |> Enum.join(", ")}"
198203
]}}
199204

205+
generated_cols != [] ->
206+
{:error,
207+
{:columns,
208+
[
209+
"The following columns are generated and cannot be included in replication: #{generated_cols |> Enum.join(", ")}"
210+
]}}
211+
200212
true ->
201213
{:ok, Enum.sort(columns_to_select)}
202214
end

packages/sync-service/test/electric/plug/delete_shape_plug_test.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ defmodule Electric.Plug.DeleteShapePlugTest do
2626
@test_pg_id "12345"
2727

2828
def load_column_info({"public", "users"}, _),
29-
do: {:ok, [%{name: "id", type: "int8", pk_position: 0, type_id: {20, -1}}]}
29+
do:
30+
{:ok, [%{name: "id", type: "int8", pk_position: 0, type_id: {20, -1}, is_generated: false}]}
3031

3132
def load_relation(tbl, _),
3233
do: Support.StubInspector.load_relation(tbl, nil)

packages/sync-service/test/electric/plug/router_test.exs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,56 @@ defmodule Electric.Plug.RouterTest do
621621
] = Jason.decode!(conn.resp_body)
622622
end
623623

624+
@generated_pk_table "generated_pk_table"
625+
@tag with_sql: [
626+
"CREATE TABLE #{@generated_pk_table} (val JSONB NOT NULL, id uuid PRIMARY KEY GENERATED ALWAYS AS ((val->>'id')::uuid) STORED)"
627+
]
628+
test "returns an error when trying to select a generated column", %{opts: opts} do
629+
# When selecting all columns
630+
conn = conn("GET", "/v1/shape?table=#{@generated_pk_table}&offset=-1") |> Router.call(opts)
631+
assert %{status: 400} = conn
632+
633+
assert Jason.decode!(conn.resp_body) ==
634+
%{
635+
"errors" => %{
636+
"columns" => [
637+
"The following columns are generated and cannot be included in replication: id"
638+
]
639+
},
640+
"message" => "Invalid request"
641+
}
642+
643+
# When selecting a single column but PK is generated
644+
conn =
645+
conn("GET", "/v1/shape?table=#{@generated_pk_table}&offset=-1&columns=val")
646+
|> Router.call(opts)
647+
648+
assert %{status: 400} = conn
649+
650+
assert Jason.decode!(conn.resp_body) ==
651+
%{
652+
"errors" => %{"columns" => ["Must include all primary key columns, missing: id"]},
653+
"message" => "Invalid request"
654+
}
655+
656+
# When selecting a generated column explicitly
657+
conn =
658+
conn("GET", "/v1/shape?table=#{@generated_pk_table}&offset=-1&columns=id,val")
659+
|> Router.call(opts)
660+
661+
assert %{status: 400} = conn
662+
663+
assert Jason.decode!(conn.resp_body) ==
664+
%{
665+
"errors" => %{
666+
"columns" => [
667+
"The following columns are generated and cannot be included in replication: id"
668+
]
669+
},
670+
"message" => "Invalid request"
671+
}
672+
end
673+
624674
@tag with_sql: [
625675
"CREATE TABLE wide_table (id BIGINT PRIMARY KEY, value1 TEXT NOT NULL, value2 TEXT NOT NULL, value3 TEXT NOT NULL)",
626676
"INSERT INTO wide_table VALUES (1, 'test value 1', 'test value 1', 'test value 1')"

packages/sync-service/test/electric/plug/serve_shape_plug_test.exs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,22 @@ defmodule Electric.Plug.ServeShapePlugTest do
4343
do:
4444
{:ok,
4545
[
46-
%{name: "id", type: "int8", type_id: {20, 1}, pk_position: 0, array_dimensions: 0},
47-
%{name: "value", type: "text", type_id: {28, 1}, pk_position: nil, array_dimensions: 0}
46+
%{
47+
name: "id",
48+
type: "int8",
49+
type_id: {20, 1},
50+
pk_position: 0,
51+
array_dimensions: 0,
52+
is_generated: false
53+
},
54+
%{
55+
name: "value",
56+
type: "text",
57+
type_id: {28, 1},
58+
pk_position: nil,
59+
array_dimensions: 0,
60+
is_generated: false
61+
}
4862
]}
4963

5064
def load_column_info(_, _),

packages/sync-service/test/electric/replication/shape_log_collector_test.exs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ defmodule Electric.Replication.ShapeLogCollectorTest do
117117
{:ok, %{id: 1234, schema: "public", name: "test_table", parent: nil, children: nil}}
118118
end)
119119
|> stub(:load_column_info, fn {"public", "test_table"}, _ ->
120-
{:ok, [%{pk_position: 0, name: "id"}]}
120+
{:ok, [%{pk_position: 0, name: "id", is_generated: false}]}
121121
end)
122122
|> allow(self(), ctx.server)
123123

@@ -158,7 +158,7 @@ defmodule Electric.Replication.ShapeLogCollectorTest do
158158
{:ok, %{id: 1234, schema: "public", name: "test_table", parent: nil, children: nil}}
159159
end)
160160
|> stub(:load_column_info, fn {"public", "test_table"}, _ ->
161-
{:ok, [%{pk_position: 0, name: "id"}]}
161+
{:ok, [%{pk_position: 0, name: "id", is_generated: false}]}
162162
end)
163163
|> allow(self(), ctx.server)
164164

@@ -295,7 +295,7 @@ defmodule Electric.Replication.ShapeLogCollectorTest do
295295
{:ok, %{id: 1234, schema: "public", name: "test_table", parent: nil, children: nil}}
296296
end)
297297
|> stub(:load_column_info, fn {"public", "test_table"}, _ ->
298-
{:ok, [%{pk_position: 0, name: "id"}]}
298+
{:ok, [%{pk_position: 0, name: "id", is_generated: false}]}
299299
end)
300300
|> allow(self(), pid)
301301

packages/sync-service/test/electric/shape_cache/shape_status_test.exs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,12 +205,12 @@ defmodule Electric.ShapeCache.ShapeStatusTest do
205205
do:
206206
{:ok,
207207
[
208-
%{name: "id", type: :int8, type_id: {1, 1}, pk_position: 0},
209-
%{name: "value", type: :text, type_id: {2, 2}, pk_position: nil}
208+
%{name: "id", type: :int8, type_id: {1, 1}, pk_position: 0, is_generated: false},
209+
%{name: "value", type: :text, type_id: {2, 2}, pk_position: nil, is_generated: false}
210210
]}
211211

212212
def load_column_info({"public", "table"}, _),
213-
do: {:ok, [%{name: "id", type: :int8, pk_position: 0}]}
213+
do: {:ok, [%{name: "id", type: :int8, type_id: {1, 1}, pk_position: 0, is_generated: false}]}
214214

215215
def load_relation(tbl, _),
216216
do: StubInspector.load_relation(tbl, nil)

packages/sync-service/test/electric/shape_cache_test.exs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,14 @@ defmodule Electric.ShapeCacheTest do
4444
@zero_offset LogOffset.last_before_real_offsets()
4545

4646
@stub_inspector StubInspector.new([
47-
%{name: "id", type: "int8", type_id: {20, 1}, pk_position: 0},
48-
%{name: "value", type: "text", type_id: {25, 1}}
47+
%{
48+
name: "id",
49+
type: "int8",
50+
type_id: {20, 1},
51+
pk_position: 0,
52+
is_generated: false
53+
},
54+
%{name: "value", type: "text", type_id: {25, 1}, is_generated: false}
4955
])
5056

5157
# {xmin, xmax, xip_list}

packages/sync-service/test/electric/shapes/api_test.exs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,22 @@ defmodule Electric.Shapes.ApiTest do
3535
def load_column_info({"public", "users"}, _) do
3636
{:ok,
3737
[
38-
%{name: "id", type: "int8", type_id: {20, 1}, pk_position: 0, array_dimensions: 0},
39-
%{name: "value", type: "text", type_id: {28, 1}, pk_position: nil, array_dimensions: 0}
38+
%{
39+
name: "id",
40+
type: "int8",
41+
type_id: {20, 1},
42+
pk_position: 0,
43+
array_dimensions: 0,
44+
is_generated: false
45+
},
46+
%{
47+
name: "value",
48+
type: "text",
49+
type_id: {28, 1},
50+
pk_position: nil,
51+
array_dimensions: 0,
52+
is_generated: false
53+
}
4054
]}
4155
end
4256

0 commit comments

Comments
 (0)