Skip to content

The migration generator writes raw SQL into Elixir string literals without escaping it #876

Closed

Description

@grempe

Versions: ash_postgres 2.13.1, ash_sql 0.7.6, ash 3.33.11, ecto_sql 3.14.0, PostgreSQL 18.6, Elixir 1.20.1 on OTP 29. Present on the default branch at 63cf7ca (checked 2026-09-30, with ash f3aa1b8 and ash_sql d4fad6a).

Found by an AI assistant working with a human while generating migrations for their own application; reproduced in a minimal project with default settings.

Request

A resource with a check constraint whose SQL contains a backslash, then mix ash.codegen and mix ecto.migrate:

check_constraints do
  # The SQL is: escaped ~ '^\d{4}$'
  check_constraint :escaped, "escaped_four_digits", check: "escaped ~ '^\\d{4}$'"
end

Observed

The generated migration holds the SQL verbatim inside a heredoc:

create constraint(:escape_codes, :escaped_four_digits,
         check: """
           escaped ~ '^\d{4}$'
         """
       )

Elixir reads \d in that heredoc as the escape for DEL (U+007F), so Postgres stores a different constraint. pg_get_constraintdef returns CHECK ((escaped ~ '^<U+007F>{4}$'::text)), and Ash.create(Code, %{escaped: "1234"}) fails with InvalidAttribute (constraint: "escaped_four_digits"). mix ash.codegen --check reports nothing pending, because the snapshot holds the declared SQL. The same happens to every escape Elixir recognises (\s becomes a space, \b a backspace; \A, \Z and \w lose the backslash), and #{...} in the SQL is interpolated: label <> '#{1 + 1}' is stored as label <> '2'.

Two other sites share the cause:

  • custom_statements (without code?: true): up and down go into an execute("""...""") heredoc. COMMENT ON TABLE escape_codes IS 'a\db' stores the comment with U+007F.
  • An identity on a resource with base_filter_sql generates where: "(<base_filter_sql>)" between plain double quotes. With base_filter_sql ~S|"archived" = false|, mix ash.codegen raises SyntaxError: syntax error before: archived while formatting the generated file.

custom_indexes where: is written with inspect/1 and reaches Postgres intact (WHERE (escaped ~ '\d'::text)).

Expected

Postgres receives the SQL the resource declares, at every site.

Failure point

lib/migration_generator/operation.ex at v2.13.1 interpolates the SQL with #{...} into the generated source:

  • AddCheckConstraint.up/1, L1581 and L1587, and RemoveCheckConstraint.down/1, L1626 and L1632: check: """ heredocs.
  • AddCustomStatement.up/1 and down/1, L1156 and L1168: execute(""" heredocs.
  • AddUniqueIndex.up/1, L1123: where: \"#{base_filter}\".

On 63cf7ca the same code is at L1737, L1743, L1782, L1788, L1247, L1259 and L1126.

Fix direction

Escape the SQL before it is placed in the generated source. Tested against 63cf7ca: a helper that escapes \, #{ and """ for the heredoc sites keeps their layout, and inspect(base_filter, printable_limit: :infinity) for the unique index; all four failing tests then pass. inspect/1 alone truncates strings longer than 4096 bytes. Migrations generated before a fix keep the altered SQL, and --check does not detect them. A pull request with this change and regression tests follows this issue.

Reproduction

Activity

  1. zachdaniel commented on Oct 4, 2026

    @zachdaniel
    Contributor

    You should use ~S() or similar to create a string that is handled literally for scenarios like that. Thanks!

  2. grempe commented on Oct 4, 2026

    @grempe
    ContributorAuthor

    Thanks for taking a look. One detail for anyone who finds this later: ~S(code ~ '^\d{4}$') is the same string as the one in the report, and on 2.14.2 it still stores U+007F, because the generator places the string inside a """ heredoc in the migration. What works is doubling the backslash inside the sigil, ~S(code ~ '^\\d{4}$'), that is, writing the check as it should read in the generated Elixir source. The same goes for custom_statements, and for a " in base_filter_sql, which needs \".

    If that is the intended contract, would a short note in the docs for check_constraint's check, custom_statements and base_filter_sql be welcome? I'm happy to send one.

  3. zachdaniel commented on Oct 4, 2026

    @zachdaniel
    Contributor

    Ah, okay sorry I misunderstood.

  4. zachdaniel commented on Oct 4, 2026

    @zachdaniel
    Contributor

    I think you're right that we should figure something out here to make it cleaner

  5. zachdaniel commented on Oct 4, 2026

    @zachdaniel
    Contributor

    Okay so anything we do here is a breaking change. If I fix it, I break anyone who used the escaping workaround. So lets do two things:

    1. lets add a comment that contains 3.0 somewhere in the codebase like 3.0: escape this content in migrations (where we would make the migrations themselves use ~S""" for example).
    2. update the docs to help others out as you pointed out 😄

    Thank you!

  6. grempe commented on Oct 4, 2026

    @grempe
    ContributorAuthor

    @zachdaniel Done in #880: 3.0: comments at each site and the docs updates, as you suggested. One thing worth a look while checking the sites: the new temporal identities write their SQL into the migration the same way, unlike the non-temporal ones, so I've noted it in the PR in case you'd rather address that before 3.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions