-
Notifications
You must be signed in to change notification settings - Fork 175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Add columns
API query parameter to filter table columns
#1829
Changes from all commits
bd56927
4e83c69
2c8ae36
ea385c6
abbdc15
0c9f7f2
237a4df
642cb88
8a97ba9
7ee1dbe
1c7087b
ba432a6
59f3d71
79b221f
8483d46
f553e81
81dbb6a
490669c
adad2d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
--- | ||
"@electric-sql/client": patch | ||
"@core/sync-service": patch | ||
--- | ||
|
||
Implement `columns` query parameter for `GET v1/shapes` API to allow filtering rows for a subset of table columns. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
defmodule Electric.Plug.Utils do | ||
@moduledoc """ | ||
Utility functions for Electric endpoints, e.g. for parsing and validating | ||
path and query parameters. | ||
""" | ||
|
||
@doc """ | ||
Parse columns parameter from a string consisting of a comma separated list | ||
of potentially quoted column names into a sorted list of strings. | ||
|
||
## Examples | ||
iex> Electric.Plug.Utils.parse_columns_param("") | ||
{:error, "Invalid zero-length delimited identifier"} | ||
iex> Electric.Plug.Utils.parse_columns_param("foo,") | ||
{:error, "Invalid zero-length delimited identifier"} | ||
iex> Electric.Plug.Utils.parse_columns_param("id") | ||
{:ok, ["id"]} | ||
iex> Electric.Plug.Utils.parse_columns_param("id,name") | ||
{:ok, ["id", "name"]} | ||
iex> Electric.Plug.Utils.parse_columns_param(~S|"PoT@To",PoTaTo|) | ||
{:ok, ["PoT@To", "potato"]} | ||
iex> Electric.Plug.Utils.parse_columns_param(~S|"PoTaTo,sunday",foo|) | ||
{:ok, ["PoTaTo,sunday", "foo"]} | ||
iex> Electric.Plug.Utils.parse_columns_param(~S|"fo""o",bar|) | ||
{:ok, [~S|fo"o|, "bar"]} | ||
iex> Electric.Plug.Utils.parse_columns_param(~S|"id,"name"|) | ||
{:error, ~S|Invalid unquoted identifier contains special characters: "id|} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this saying "unquoted identifier" ? The sigil does contain quotes. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The provided column was If the strategy to split the comas was different it would provide |
||
""" | ||
@spec parse_columns_param(binary()) :: {:ok, [String.t(), ...]} | {:error, term()} | ||
|
||
def parse_columns_param(columns) when is_binary(columns) do | ||
columns | ||
# Split by commas that are not inside quotes | ||
|> String.split(~r/,(?=(?:[^"]*"[^"]*")*[^"]*$)/) | ||
|> Enum.reduce_while([], fn column, acc -> | ||
case Electric.Postgres.Identifiers.parse(column) do | ||
{:ok, casted_column} -> {:cont, [casted_column | acc]} | ||
{:error, reason} -> {:halt, {:error, reason}} | ||
end | ||
end) | ||
|> then(fn result -> | ||
case result do | ||
# TODO: convert output to MapSet? | ||
parsed_cols when is_list(parsed_cols) -> {:ok, Enum.reverse(parsed_cols)} | ||
{:error, reason} -> {:error, reason} | ||
end | ||
end) | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
defmodule Electric.Postgres.Identifiers do | ||
@namedatalen 63 | ||
@ascii_downcase ?a - ?A | ||
|
||
@doc """ | ||
Parse a PostgreSQL identifier, removing quotes if present and escaping internal ones | ||
and downcasing the identifier otherwise. | ||
|
||
## Examples | ||
|
||
iex> Electric.Postgres.Identifiers.parse("FooBar") | ||
{:ok, "foobar"} | ||
iex> Electric.Postgres.Identifiers.parse(~S|"FooBar"|) | ||
{:ok, "FooBar"} | ||
iex> Electric.Postgres.Identifiers.parse(~S|Foo"Bar"|) | ||
{:error, ~S|Invalid unquoted identifier contains special characters: Foo"Bar"|} | ||
iex> Electric.Postgres.Identifiers.parse(~S| |) | ||
{:error, ~S|Invalid unquoted identifier contains special characters: |} | ||
iex> Electric.Postgres.Identifiers.parse("foob@r") | ||
{:error, ~S|Invalid unquoted identifier contains special characters: foob@r|} | ||
iex> Electric.Postgres.Identifiers.parse(~S|"Foo"Bar"|) | ||
{:error, ~S|Invalid identifier with unescaped quote: Foo"Bar|} | ||
iex> Electric.Postgres.Identifiers.parse(~S|""|) | ||
{:error, "Invalid zero-length delimited identifier"} | ||
iex> Electric.Postgres.Identifiers.parse("") | ||
{:error, "Invalid zero-length delimited identifier"} | ||
iex> Electric.Postgres.Identifiers.parse(~S|" "|) | ||
{:ok, " "} | ||
iex> Electric.Postgres.Identifiers.parse(~S|"Foo""Bar"|) | ||
{:ok, ~S|Foo"Bar|} | ||
""" | ||
@spec parse(binary(), boolean(), boolean()) :: {:ok, binary()} | {:error, term()} | ||
def parse(ident, truncate \\ false, single_byte_encoding \\ false) when is_binary(ident) do | ||
if String.starts_with?(ident, ~S|"|) and String.ends_with?(ident, ~S|"|) do | ||
ident_unquoted = String.slice(ident, 1..-2//1) | ||
parse_quoted_identifier(ident_unquoted) | ||
else | ||
parse_unquoted_identifier(ident, truncate, single_byte_encoding) | ||
end | ||
end | ||
|
||
defp parse_quoted_identifier(""), do: {:error, "Invalid zero-length delimited identifier"} | ||
|
||
defp parse_quoted_identifier(ident) do | ||
if contains_unescaped_quote?(ident), | ||
do: {:error, "Invalid identifier with unescaped quote: #{ident}"}, | ||
else: {:ok, unescape_quotes(ident)} | ||
end | ||
|
||
defp parse_unquoted_identifier("", _, _), do: parse_quoted_identifier("") | ||
|
||
defp parse_unquoted_identifier(ident, truncate, single_byte_encoding) do | ||
unless valid_unquoted_identifier?(ident), | ||
do: {:error, "Invalid unquoted identifier contains special characters: #{ident}"}, | ||
else: {:ok, downcase(ident, truncate, single_byte_encoding)} | ||
end | ||
|
||
defp contains_unescaped_quote?(string) do | ||
Regex.match?(~r/(?<!")"(?!")/, string) | ||
end | ||
|
||
defp unescape_quotes(string) do | ||
string | ||
|> String.replace(~r/""/, ~S|"|) | ||
end | ||
|
||
defp valid_unquoted_identifier?(identifier) do | ||
Regex.match?(~r/^[a-zA-Z_][a-zA-Z0-9_]*$/, identifier) | ||
end | ||
|
||
@doc """ | ||
Downcase the identifier and truncate if necessary, using | ||
PostgreSQL's algorithm for downcasing. | ||
|
||
Setting `truncate` to `true` will truncate the identifier to 63 characters | ||
|
||
Setting `single_byte_encoding` to `true` will downcase the identifier | ||
using single byte encoding | ||
|
||
See: | ||
https://github.com/postgres/postgres/blob/259a0a99fe3d45dcf624788c1724d9989f3382dc/src/backend/parser/scansup.c#L46-L80 | ||
|
||
## Examples | ||
|
||
iex> Electric.Postgres.Identifiers.downcase("FooBar") | ||
"foobar" | ||
iex> Electric.Postgres.Identifiers.downcase(String.duplicate("a", 100), true) | ||
String.duplicate("a", 63) | ||
""" | ||
def downcase(ident, truncate \\ false, single_byte_encoding \\ false) | ||
|
||
def downcase(ident, false, single_byte_encoding) do | ||
downcased_ident = | ||
ident | ||
|> String.to_charlist() | ||
|> Enum.map(&downcase_char(&1, single_byte_encoding)) | ||
|> List.to_string() | ||
|
||
downcased_ident | ||
end | ||
|
||
def downcase(ident, true, single_byte_encoding) do | ||
downcased_ident = downcase(ident, false, single_byte_encoding) | ||
|
||
truncated_ident = | ||
if String.length(ident) >= @namedatalen do | ||
String.slice(downcased_ident, 0, @namedatalen) | ||
else | ||
downcased_ident | ||
end | ||
|
||
truncated_ident | ||
end | ||
|
||
# Helper function to downcase a character | ||
defp downcase_char(ch, _) when ch in ?A..?Z, do: ch + @ascii_downcase | ||
|
||
defp downcase_char(ch, true) when ch > 127, | ||
do: | ||
if(ch == Enum.at(:unicode_util.uppercase(ch), 0), | ||
do: Enum.at(:unicode_util.lowercase(ch), 0), | ||
else: ch | ||
) | ||
|
||
defp downcase_char(ch, _), do: ch | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's perhaps not introduce several utility files but keep everything in the outer
utils.ex
file?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like this is not a general utility like the other ones and is more meant to be part of the Plug code - we already have a very long serve shae plug module which defines submodules, the idea is that parsing logic could move to this.