Packages
electric
1.0.13
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.
## 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
@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(binary(), boolean(), boolean()) :: {:ok, binary()} | {:error, term()}
def parse_unquoted_identifier(ident, truncate \\ false, single_byte_encoding \\ false)
def parse_unquoted_identifier("", _, _), do: parse_quoted_identifier("")
def 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/^[\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] ->
case parse(table) do
{:ok, parsed} -> {:ok, {"public", parsed}}
{:error, reason} -> {:error, reason}
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 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