Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions lib/migration_generator/operation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ defmodule AshPostgres.MigrationGenerator.Operation do
def in_quotes(nil), do: nil
def in_quotes(value), do: "\"#{value}\""

# Escapes raw SQL for the body of a `"""` heredoc in a generated migration,
# so that the string Elixir reads back is the SQL as written.
def escape_heredoc(sql) do
sql
|> String.replace("\\", "\\\\")
|> String.replace("\#{", "\\\#{")
|> String.replace(~s("""), ~S(\"""))
end

def as_atom(value) when is_atom(value), do: Macro.inspect_atom(:remote_call, value)
# sobelow_skip ["DOS.StringToAtom"]
def as_atom(value), do: Macro.inspect_atom(:remote_call, String.to_atom(value))
Expand Down Expand Up @@ -1123,7 +1132,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do
base_filter ->
base_filter = "(#{base_filter})"

"create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], where: \"#{base_filter}\", #{join(["name: \"#{index_name}\"", option("prefix", schema), option("nulls_distinct", nils_distinct?), concurrently_option])})"
"create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], where: #{inspect(base_filter, printable_limit: :infinity)}, #{join(["name: \"#{index_name}\"", option("prefix", schema), option("nulls_distinct", nils_distinct?), concurrently_option])})"

where ->
where = "(#{where})"
Expand Down Expand Up @@ -1241,10 +1250,12 @@ defmodule AshPostgres.MigrationGenerator.Operation do
@moduledoc false
defstruct [:statement, :table, :schema, no_phase: true]

import Helper, only: [escape_heredoc: 1]

def up(%{statement: %{up: up, code?: false}}) do
"""
execute(\"\"\"
#{String.trim(up)}
#{escape_heredoc(String.trim(up))}
\"\"\")
"""
end
Expand All @@ -1256,7 +1267,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do
def down(%{statement: %{down: down, code?: false}}) do
"""
execute(\"\"\"
#{String.trim(down)}
#{escape_heredoc(String.trim(down))}
\"\"\")
"""
end
Expand Down Expand Up @@ -1734,13 +1745,13 @@ defmodule AshPostgres.MigrationGenerator.Operation do
if base_filter do
~s'''
create constraint(:#{as_atom(table)}, :#{as_atom(name)}, check: """
(#{check}) OR NOT (#{base_filter})
#{escape_heredoc("(#{check}) OR NOT (#{base_filter})")}
"""#{prefix})
'''
else
~s'''
create constraint(:#{as_atom(table)}, :#{as_atom(name)}, check: """
#{check}
#{escape_heredoc(check)}
"""#{prefix})
'''
end
Expand Down Expand Up @@ -1779,13 +1790,13 @@ defmodule AshPostgres.MigrationGenerator.Operation do
if base_filter do
~s'''
create constraint(:#{as_atom(table)}, :#{as_atom(name)}, check: """
(#{check}) OR NOT (#{base_filter})
#{escape_heredoc("(#{check}) OR NOT (#{base_filter})")}
"""#{prefix})
'''
else
~s'''
create constraint(:#{as_atom(table)}, :#{as_atom(name)}, check: """
#{check}
#{escape_heredoc(check)}
"""#{prefix})
'''
end
Expand Down
201 changes: 195 additions & 6 deletions test/migration_generator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,59 @@ defmodule AshPostgres.MigrationGeneratorTest do
end
end

# The strings Elixir reads from a generated migration, which is what reaches
# the database, as opposed to the migration's source text. `direction` picks
# the body of `def up` or `def down`.
defp migration_values(file, direction, fun) do
{_, [body]} =
file
|> Code.string_to_quoted!()
|> Macro.prewalk([], fn
{:def, _, [{^direction, _, _}, [do: body]]} = node, acc -> {node, [body | acc]}
node, acc -> {node, acc}
end)

body
|> Macro.prewalk([], fn node, acc ->
case fun.(node) do
nil -> {node, acc}
value when is_binary(value) -> {node, [String.trim(value) | acc]}
value -> {node, [value | acc]}
end
end)
|> elem(1)
|> Enum.reverse()
end

defp check_sql(file, name, direction \\ :up) do
migration_values(file, direction, fn
{:constraint, _, [_table, ^name, opts]} -> Keyword.fetch!(opts, :check)
_ -> nil
end)
end

defp execute_sql(file, direction) do
migration_values(file, direction, fn
{:execute, _, [sql]} -> sql
_ -> nil
end)
end

defp unique_index_where(file) do
migration_values(file, :up, fn
{:unique_index, _, [_table, _columns, opts]} -> Keyword.get(opts, :where)
_ -> nil
end)
end

defp migration_files(migration_path) do
"#{migration_path}/**/*_migrate_resources*.exs"
|> Path.wildcard()
|> Enum.reject(&String.contains?(&1, "extensions"))
|> Enum.sort()
|> Enum.map(&File.read!/1)
end

defp generate_post_migration(domain, snapshot_path, migration_path) do
AshPostgres.MigrationGenerator.generate(domain,
snapshot_path: snapshot_path,
Expand Down Expand Up @@ -4535,12 +4588,9 @@ defmodule AshPostgres.MigrationGeneratorTest do
""")
'''

assert file =~
~S'''
create constraint(:posts, :title_must_conform_to_format, check: """
title ~= '("\"\\"\\\"\\\\"\\\\\")'
""")
'''
assert check_sql(file, :title_must_conform_to_format) == [
~S[title ~= '("\"\\"\\\"\\\\"\\\\\")']
]

defposts do
attributes do
Expand Down Expand Up @@ -5044,6 +5094,145 @@ defmodule AshPostgres.MigrationGeneratorTest do
end
end

describe "raw SQL in generated migrations" do
# Each ~S string below is SQL as written: `\d` is a regex class, `#{x}` is
# literal text, and `"archived"` is a quoted identifier.

test "check constraint SQL reaches the migration as written", %{
snapshot_path: snapshot_path,
migration_path: migration_path
} do
defposts do
attributes do
uuid_primary_key(:id)
attribute(:code, :string, public?: true)
end

postgres do
check_constraints do
check_constraint(:code, "code_is_four_digits", check: ~S[code ~ '^\d{4}$'])
check_constraint(:code, "code_is_not_a_template", check: ~S[code <> '#{x}'])
check_constraint(:code, "code_has_no_triple_quote", check: ~S[code NOT LIKE '%"""%'])
end
end
end

defdomain([Post])
generate_post_migration(Domain, snapshot_path, migration_path)

[file] = migration_files(migration_path)

assert check_sql(file, :code_is_four_digits) == [~S[code ~ '^\d{4}$']]
assert check_sql(file, :code_is_not_a_template) == [~S[code <> '#{x}']]
assert check_sql(file, :code_has_no_triple_quote) == [~S[code NOT LIKE '%"""%']]

defposts do
attributes do
uuid_primary_key(:id)
attribute(:code, :string, public?: true)
end
end

defdomain([Post])
generate_post_migration(Domain, snapshot_path, migration_path)

[_, file] = migration_files(migration_path)

assert check_sql(file, :code_is_four_digits, :down) == [~S[code ~ '^\d{4}$']]
assert check_sql(file, :code_is_not_a_template, :down) == [~S[code <> '#{x}']]
end

test "check constraint SQL combined with a base filter reaches the migration as written",
%{snapshot_path: snapshot_path, migration_path: migration_path} do
defposts do
attributes do
uuid_primary_key(:id)
attribute(:code, :string, public?: true)
attribute(:kind, :string, public?: true)
end

resource do
base_filter(expr(kind == "digits"))
end

postgres do
base_filter_sql ~S["kind" ~ '^\w+$']

check_constraints do
check_constraint(:code, "code_is_four_digits", check: ~S[code ~ '^\d{4}$'])
end
end
end

defdomain([Post])
generate_post_migration(Domain, snapshot_path, migration_path)

[file] = migration_files(migration_path)

assert check_sql(file, :code_is_four_digits) == [
~S[(code ~ '^\d{4}$') OR NOT ("kind" ~ '^\w+$')]
]
end

test "custom statement SQL reaches the migration as written", %{
snapshot_path: snapshot_path,
migration_path: migration_path
} do
defposts do
attributes do
uuid_primary_key(:id)
end

postgres do
custom_statements do
statement :comment do
up(~S[COMMENT ON TABLE posts IS 'a\db #{x}'])
down(~S[COMMENT ON TABLE posts IS '\N'])
end
end
end
end

defdomain([Post])
generate_post_migration(Domain, snapshot_path, migration_path)

[file] = migration_files(migration_path)

assert execute_sql(file, :up) == [~S[COMMENT ON TABLE posts IS 'a\db #{x}']]
assert execute_sql(file, :down) == [~S[COMMENT ON TABLE posts IS '\N']]
end

test "an identity under a base_filter_sql with quotes and backslashes generates a valid where",
%{snapshot_path: snapshot_path, migration_path: migration_path} do
defposts do
attributes do
uuid_primary_key(:id)
attribute(:title, :string, public?: true)
attribute(:archived, :boolean, public?: true, default: false)
end

identities do
identity(:unique_title, [:title])
end

resource do
base_filter(expr(archived == false))
end

postgres do
base_filter_sql ~S["archived" = false AND "title" !~ '\s']
end
end

defdomain([Post])
generate_post_migration(Domain, snapshot_path, migration_path)

[file] = migration_files(migration_path)

assert ~S[("archived" = false AND "title" !~ '\s')] in unique_index_where(file)
end
end

describe "polymorphic resources" do
setup %{snapshot_path: snapshot_path, migration_path: migration_path} do
:ok
Expand Down
Loading