diff --git a/lib/migration_generator/operation.ex b/lib/migration_generator/operation.ex index 19761331..da3b0957 100644 --- a/lib/migration_generator/operation.ex +++ b/lib/migration_generator/operation.ex @@ -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)) @@ -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})" @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/test/migration_generator_test.exs b/test/migration_generator_test.exs index 5cecd74b..7080d49d 100644 --- a/test/migration_generator_test.exs +++ b/test/migration_generator_test.exs @@ -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, @@ -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 @@ -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