Skip to content

Commit 2171ca1

Browse files
aayush-kclaudealco
authored
fix(sync-service): allow DELETE /v1/shape to resolve a shape by its definition (#4754)
DeleteShapePlug narrowed the incoming params to ["table", "handle"] before handing them to Api.validate_for_delete/2, dropping where/columns/params/ replica/log. The shape passed to Shapes.fetch_handle_by_shape/2 was therefore always the bare-table shape, so any shape defined with a where clause or a column selection could only ever be deleted by handle. Widen the take list to every param that Api.Params.define_shape/2 reads and add plug- and router-level regression tests; the existing api_test.exs coverage calls validate_for_delete/2 directly and so bypasses the plug. <!-- codesmith:footer --> --- <a href="https://app.blacksmith.sh/electric-sql/codesmith/electric/pr/4754"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img alt="View with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a> <a href="https://backend.blacksmith.sh/track/enable-autofix?expires=1788920734&installation_model_id=8736&pr_number=4754&repository=electric-sql%2Felectric&return_to=https%3A%2F%2Fgithub.com%2Felectric-sql%2Felectric%2Fpull%2F4754&signature=16dba5ae9a134733732fa7467c22ca46a79ca591c0f47db17b7f97464143979f"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img alt="Autofix with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a> <sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you need. Autofix is disabled.</sup> <!-- codesmith:autofix:disabled --> <!-- /codesmith:footer --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Oleksii Sholik <oleksii@sholik.dev>
1 parent 5c5ba9d commit 2171ca1

4 files changed

Lines changed: 115 additions & 5 deletions

File tree

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 `DELETE /v1/shape` so a shape can be deleted by its definition. The delete plug discarded every shape parameter other than `table` and `handle`, so any shape with a `where`, `columns`, `params`, `replica` or `log` could only ever be deleted by handle.

packages/sync-service/lib/electric/plug/delete_shape_plug.ex

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ defmodule Electric.Plug.DeleteShapePlug do
1212
defp validate_request(%Plug.Conn{assigns: %{config: config}} = conn, _) do
1313
api = Access.fetch!(config, :api)
1414

15-
all_params =
16-
Map.merge(conn.query_params, conn.path_params)
17-
|> Map.take(["table", "handle"])
18-
|> Map.put("offset", "-1")
15+
# No filtering here: `Api.Params` casting drops unknown parameters and
16+
# `validate_for_delete/2` ignores request-only ones such as `offset` and
17+
# `live`, so the shape definition is built from the same parameters a GET
18+
# request would use.
19+
all_params = Map.merge(conn.query_params, conn.path_params)
1920

2021
case Api.validate_for_delete(api, all_params) do
2122
{:ok, request} ->

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

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ defmodule Electric.Plug.DeleteShapePlugTest do
2121
flags: %{selects_all_columns: true}
2222
}
2323
@test_pg_id "12345"
24+
@inspector {__MODULE__, []}
2425

2526
def load_column_info(@users_oid, _),
2627
do:
@@ -38,6 +39,8 @@ defmodule Electric.Plug.DeleteShapePlugTest do
3839
Plug.Test.conn(method, "/" <> query_string)
3940
end
4041

42+
def query_string(params), do: "?" <> URI.encode_query(params)
43+
4144
def call_delete_shape_plug(conn, ctx, allow \\ true) do
4245
config =
4346
Electric.Shapes.Api.plug_opts(
@@ -47,7 +50,7 @@ defmodule Electric.Plug.DeleteShapePlugTest do
4750
pg_id: @test_pg_id,
4851
shape_cache: {Electric.ShapeCache, []},
4952
storage: {Mock.Storage, []},
50-
inspector: {__MODULE__, []},
53+
inspector: @inspector,
5154
registry: @registry,
5255
long_poll_timeout: Access.get(ctx, :long_poll_timeout, 20_000),
5356
max_age: Access.get(ctx, :max_age, 60),
@@ -135,6 +138,24 @@ defmodule Electric.Plug.DeleteShapePlugTest do
135138
}
136139
end
137140

141+
test "returns 400 for a malformed request-only param", ctx do
142+
# Request-only params such as `live` are ignored by delete validation,
143+
# but they still go through schema casting, so malformed values are
144+
# rejected rather than silently dropped.
145+
conn =
146+
ctx
147+
|> conn("DELETE", query_string(table: "public.users", live: "banana"))
148+
|> call_delete_shape_plug(ctx)
149+
150+
assert conn.status == 400
151+
assert Plug.Conn.get_resp_header(conn, "cache-control") == ["no-cache"]
152+
153+
assert Jason.decode!(conn.resp_body) == %{
154+
"message" => "Invalid request",
155+
"errors" => %{"live" => ["is invalid"]}
156+
}
157+
end
158+
138159
test "should clean shape based on shape definition", ctx do
139160
%{stack_id: stack_id} = ctx
140161

@@ -151,6 +172,63 @@ defmodule Electric.Plug.DeleteShapePlugTest do
151172
assert Plug.Conn.get_resp_header(conn, "cache-control") == ["no-cache"]
152173
end
153174

175+
test "should clean shape based on shape definition with a where clause", ctx do
176+
%{stack_id: stack_id} = ctx
177+
178+
shape = Shape.new!("public.users", where: "id = 1", inspector: @inspector)
179+
{:ok, shape_handle} = Electric.ShapeCache.ShapeStatus.add_shape(stack_id, shape)
180+
181+
expect_shape_cache(clean_shape: fn ^shape_handle, ^stack_id -> :ok end)
182+
183+
conn =
184+
ctx
185+
|> conn(:delete, query_string(table: "public.users", where: "id = 1"))
186+
|> call_delete_shape_plug(ctx)
187+
188+
assert conn.status == 202
189+
end
190+
191+
test "should clean shape based on shape definition with a parameterised where clause", ctx do
192+
%{stack_id: stack_id} = ctx
193+
194+
shape =
195+
Shape.new!("public.users",
196+
where: "id = $1",
197+
params: %{"1" => "1"},
198+
inspector: @inspector
199+
)
200+
201+
{:ok, shape_handle} = Electric.ShapeCache.ShapeStatus.add_shape(stack_id, shape)
202+
203+
expect_shape_cache(clean_shape: fn ^shape_handle, ^stack_id -> :ok end)
204+
205+
conn =
206+
ctx
207+
|> conn(
208+
:delete,
209+
query_string([{"table", "public.users"}, {"where", "id = $1"}, {"params[1]", "1"}])
210+
)
211+
|> call_delete_shape_plug(ctx)
212+
213+
assert conn.status == 202
214+
end
215+
216+
test "should clean shape based on shape definition with a column selection", ctx do
217+
%{stack_id: stack_id} = ctx
218+
219+
shape = Shape.new!("public.users", columns: ["id"], inspector: @inspector)
220+
{:ok, shape_handle} = Electric.ShapeCache.ShapeStatus.add_shape(stack_id, shape)
221+
222+
expect_shape_cache(clean_shape: fn ^shape_handle, ^stack_id -> :ok end)
223+
224+
conn =
225+
ctx
226+
|> conn(:delete, query_string(table: "public.users", columns: "id"))
227+
|> call_delete_shape_plug(ctx)
228+
229+
assert conn.status == 202
230+
end
231+
154232
test "should clean shape based only on shape_handle", ctx do
155233
%{stack_id: stack_id} = ctx
156234

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,32 @@ defmodule Electric.Plug.RouterTest do
292292
] = response
293293
end
294294

295+
@tag with_sql: [
296+
"INSERT INTO items VALUES (gen_random_uuid(), 'test value 1')"
297+
]
298+
test "DELETE deletes a shape that was defined with a where clause", %{opts: opts} do
299+
where = "value = 'test value 1'"
300+
301+
conn =
302+
conn("GET", "/v1/shape?table=items&offset=-1", %{where: where})
303+
|> Router.call(opts)
304+
305+
assert %{status: 200} = conn
306+
shape1_handle = get_resp_shape_handle(conn)
307+
308+
# The shape must be resolvable by its definition, not just by its handle.
309+
assert %{status: 202} =
310+
conn("DELETE", "/v1/shape?" <> URI.encode_query(table: "items", where: where))
311+
|> Router.call(opts)
312+
313+
conn =
314+
conn("GET", "/v1/shape?table=items&offset=-1", %{where: where})
315+
|> Router.call(opts)
316+
317+
assert %{status: 200} = conn
318+
assert get_resp_shape_handle(conn) != shape1_handle
319+
end
320+
295321
@tag with_sql: ["INSERT INTO items VALUES (gen_random_uuid(), 'test value 1')"]
296322
test "follows a table and returns last-seen lsn", %{
297323
opts: opts,

0 commit comments

Comments
 (0)