Packages
electric
1.4.12
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/postgres/identifiers.ex
defmodule Electric.Postgres.Identifiers do
@namedatalen 63
@ascii_downcase ?a - ?A
defmodule StringSplitter do
@moduledoc """
Utility module for splitting strings on a schema delimiter
"""
@doc """
Split a string on a schema delimiter, only if the delimiter is not
inside quotes, returning a list of strings
"""
@spec split_outside_quotes(binary()) :: [binary(), ...]
def split_outside_quotes(string) do
split_outside_quotes(string, "", [], false)
end
# Return the accumulated parts
defp split_outside_quotes("", current, acc, _in_quotes), do: acc ++ [current]
# If we hit a period and we're not in quotes, split here
defp split_outside_quotes(<<".", rest::binary>>, current, acc, false),
do: split_outside_quotes(rest, "", acc ++ [current], false)
# Toggle the in_quotes flag when encountering a quote
defp split_outside_quotes(<<"\"", rest::binary>>, current, acc, in_quotes),
do: split_outside_quotes(rest, current <> "\"", acc, !in_quotes)
# Continue processing, accumulating characters
defp split_outside_quotes(<<char, rest::binary>>, current, acc, in_quotes),
do: split_outside_quotes(rest, current <> <<char>>, acc, in_quotes)
end
@doc """
Parse a PostgreSQL identifier, removing quotes if present and escaping internal ones
and downcasing the identifier otherwise.
Postgres identifiers are limited to 63 characters - Postgres will truncate them,
but we'll fail if the identifier is too long to avoid api injection issues.
## 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(for(_ <- 1..64, into: "", do: "a"))
{:error, "Identifier is too long (max length is #{@namedatalen})"}
iex> Electric.Postgres.Identifiers.parse(~S|" "|)
{:ok, " "}
iex> Electric.Postgres.Identifiers.parse(~S|"Foo""Bar"|)
{:ok, ~S|Foo"Bar|}
"""
@spec parse(binary(), boolean()) :: {:ok, binary()} | {:error, term()}
def parse(ident, single_byte_encoding \\ false) when is_binary(ident) do
with {:ok, parsed} <- do_parse(ident, single_byte_encoding) do
if String.length(parsed) > @namedatalen do
{:error, "Identifier is too long (max length is #{@namedatalen})"}
else
{:ok, parsed}
end
end
end
defp do_parse(ident, single_byte_encoding) 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, 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
@doc """
Parse an unquoted PostgreSQL identifier, downcasing characters and failing if any
special characters are present
## Examples
iex> Electric.Postgres.Identifiers.parse_unquoted_identifier("FooBar")
{:ok, "foobar"}
iex> Electric.Postgres.Identifiers.parse_unquoted_identifier("foob@r")
{:error, ~S|Invalid unquoted identifier contains special characters: foob@r|}
"""
@spec parse_unquoted_identifier(binary(), boolean()) :: {:ok, binary()} | {:error, term()}
def parse_unquoted_identifier(ident, single_byte_encoding \\ false)
def parse_unquoted_identifier("", _), do: parse_quoted_identifier("")
def parse_unquoted_identifier(ident, single_byte_encoding) do
if valid_unquoted_identifier?(ident),
do: {:ok, downcase(ident, single_byte_encoding)},
else: {:error, "Invalid unquoted identifier contains special characters: #{ident}"}
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/^[\pL_][\pL\pM_0-9$]*$]*$/u, identifier)
end
@doc """
Parse a PostgreSQL relation identifier
## Examples
iex> Electric.Postgres.Identifiers.parse_relation("foo")
{:ok, {"public", "foo"}}
iex> Electric.Postgres.Identifiers.parse_relation("foo.bar")
{:ok, {"foo", "bar"}}
iex> Electric.Postgres.Identifiers.parse_relation(~S|"foo"."bar"|)
{:ok, {"foo", "bar"}}
iex> Electric.Postgres.Identifiers.parse_relation(~S|"foo.woah"."bar"|)
{:ok, {"foo.woah", "bar"}}
iex> Electric.Postgres.Identifiers.parse_relation(~S|"foo".bar|)
{:ok, {"foo", "bar"}}
iex> Electric.Postgres.Identifiers.parse_relation(~S|"foo"."bar|)
{:error, ~S|Invalid unquoted identifier contains special characters: "bar|}
iex> Electric.Postgres.Identifiers.parse_relation("foo.bar.baz")
{:error, "Invalid relation identifier, too many delimiters: foo.bar.baz"}
"""
@spec parse_relation(binary()) :: {:ok, Electric.relation()} | {:error, term()}
def parse_relation(ident) do
case StringSplitter.split_outside_quotes(ident) do
[table] ->
with {:ok, parsed} <- parse(table) do
{:ok, {"public", parsed}}
end
[schema, table] ->
with {:ok, schema} <- parse(schema),
{:ok, table} <- parse(table) do
{:ok, {schema, table}}
end
_ ->
{:error, "Invalid relation identifier, too many delimiters: #{ident}"}
end
end
@doc """
Downcase the identifier using PostgreSQL's algorithm for downcasing.
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"
"""
def downcase(ident, single_byte_encoding \\ false)
def downcase(ident, single_byte_encoding) do
downcased_ident =
ident
|> String.to_charlist()
|> Enum.map(&downcase_char(&1, single_byte_encoding))
|> List.to_string()
downcased_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