Repository: ariga/atlas
Pull request #2825 (2024-05-31 20:36:29)

sql/migrate: control escaped strings per driver


Patch:
diff --git a/sql/migrate/lex.go b/sql/migrate/lex.go
index 775aea59..c8262199 100644
--- a/sql/migrate/lex.go
+++ b/sql/migrate/lex.go
@@ -106,6 +106,12 @@ type (
 		MatchBeginAtomic bool
 		// MatchDollarQuote enables the PostgreSQL dollar-quoted string syntax.
 		MatchDollarQuote bool
+		// BackslashEscapes enables backslash-escaped strings. By default, only MySQL/MariaDB uses backslash as
+		// an escape character.  https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_backslash_escapes
+		BackslashEscapes bool
+		// EscapedStringExt enables the supported for PG extension for escaped strings and adopted by its flavors.
+		// See: https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS-ESCAPE.
+		EscapedStringExt bool
 	}
 )
 
@@ -262,12 +268,15 @@ func (s *Scanner) addPos(p int) {
 }
 
 func (s *Scanner) skipQuote(quote rune) error {
-	pos := s.pos
+	var (
+		pos     = s.pos
+		escaped = s.BackslashEscapes || s.EscapedStringExt && s.pos > 0 && (s.input[s.pos-1] == 'E' || s.input[s.pos-1] == 'e')
+	)
 	for {
 		switch r := s.next(); {
 		case r == eos:
 			return s.error(pos, "unclosed quote %q", quote)
-		case r == '\\':
+		case r == '\\' && escaped:
 			s.next()
 		case r == quote:
 			return nil
diff --git a/sql/mysql/driver.go b/sql/mysql/driver.go
index d4ab12e8..bf437d16 100644
--- a/sql/mysql/driver.go
+++ b/sql/mysql/driver.go
@@ -269,7 +269,8 @@ func (*Driver) StmtBuilder(opts migrate.PlanOptions) *sqlx.Builder {
 func (*Driver) ScanStmts(input string) ([]*migrate.Stmt, error) {
 	return (&migrate.Scanner{
 		ScannerOptions: migrate.ScannerOptions{
-			MatchBegin: true,
+			MatchBegin:       true,
+			BackslashEscapes: true,
 			// The following are not support by MySQL/MariaDB.
 			MatchBeginAtomic: false,
 			MatchDollarQuote: false,
diff --git a/sql/postgres/driver.go b/sql/postgres/driver.go
index 5800b5c5..91dec5b0 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -347,6 +347,7 @@ func (*Driver) ScanStmts(input string) ([]*migrate.Stmt, error) {
 			MatchBegin:       true,
 			MatchBeginAtomic: true,
 			MatchDollarQuote: true,
+			EscapedStringExt: true,
 		},
 	}).Scan(input)
 }


Test patch:
diff --git a/sql/migrate/lex_test.go b/sql/migrate/lex_test.go
index 79e8ee43..efd21260 100644
--- a/sql/migrate/lex_test.go
+++ b/sql/migrate/lex_test.go
@@ -49,6 +49,42 @@ func TestScanner_StmtsGroup(t *testing.T) {
 	}
 }
 
+func TestScanner_EscapedStrings(t *testing.T) {
+	path := filepath.Join("testdata", "lexescaped")
+	dir, err := NewLocalDir(path)
+	require.NoError(t, err)
+	files, err := dir.Files()
+	require.NoError(t, err)
+	require.Len(t, files, 2, "tests should be updated")
+	scan := &Scanner{}
+	scan.BackslashEscapes = true
+	stmts, err := scan.Scan(string(files[0].Bytes()))
+	require.NoError(t, err)
+	buf, err := os.ReadFile(filepath.Join(path, files[0].Name()+".golden"))
+	require.NoError(t, err)
+	got := make([]string, len(stmts))
+	for i, s := range stmts {
+		got[i] = s.Text
+	}
+	require.Equalf(t, string(buf), strings.Join(got, "\n-- end --\n"), "mismatched statements in file %q", files[0].Name())
+	_, err = scan.Scan(string(files[1].Bytes()))
+	require.EqualError(t, err, `4:40: unclosed quote '\''`, "escaped strings conflicts with standard strings")
+
+	scan.BackslashEscapes = false
+	scan.EscapedStringExt = true
+	stmts, err = scan.Scan(string(files[0].Bytes()))
+	require.EqualError(t, err, `4:42: unclosed quote '\''`, "disabled escaped strings should fail parse of escaped strings without the extension")
+	stmts, err = scan.Scan(string(files[1].Bytes()))
+	require.NoError(t, err)
+	buf, err = os.ReadFile(filepath.Join(path, files[1].Name()+".golden"))
+	require.NoError(t, err)
+	got = make([]string, len(stmts))
+	for i, s := range stmts {
+		got[i] = s.Text
+	}
+	require.Equalf(t, string(buf), strings.Join(got, "\n-- end --\n"), "mismatched statements in file %q", files[1].Name())
+}
+
 func TestLocalFile_StmtDecls(t *testing.T) {
 	f := `cmd0;
 -- test
diff --git a/sql/migrate/testdata/lexescaped/1.my.sql b/sql/migrate/testdata/lexescaped/1.my.sql
new file mode 100644
index 00000000..06fcd44d
--- /dev/null
+++ b/sql/migrate/testdata/lexescaped/1.my.sql
@@ -0,0 +1,4 @@
+create table t (c text default '\\');
+create table t (c text default "\\");
+create table t (c text default "\"");
+create table t (c text default "\"" + '\'');
diff --git a/sql/migrate/testdata/lexescaped/1.my.sql.golden b/sql/migrate/testdata/lexescaped/1.my.sql.golden
new file mode 100644
index 00000000..0321e001
--- /dev/null
+++ b/sql/migrate/testdata/lexescaped/1.my.sql.golden
@@ -0,0 +1,7 @@
+create table t (c text default '\\');
+-- end --
+create table t (c text default "\\");
+-- end --
+create table t (c text default "\"");
+-- end --
+create table t (c text default "\"" + '\'');
\ No newline at end of file
diff --git a/sql/migrate/testdata/lexescaped/2.pg.sql b/sql/migrate/testdata/lexescaped/2.pg.sql
new file mode 100644
index 00000000..5670cbe6
--- /dev/null
+++ b/sql/migrate/testdata/lexescaped/2.pg.sql
@@ -0,0 +1,4 @@
+create table t1 (c text default '\');
+create table t1 (c text default E'\\');
+create table t1 (c text default '\A\');
+create table t1 (c text default e'\\A\\');
\ No newline at end of file
diff --git a/sql/migrate/testdata/lexescaped/2.pg.sql.golden b/sql/migrate/testdata/lexescaped/2.pg.sql.golden
new file mode 100644
index 00000000..2de61fa1
--- /dev/null
+++ b/sql/migrate/testdata/lexescaped/2.pg.sql.golden
@@ -0,0 +1,7 @@
+create table t1 (c text default '\');
+-- end --
+create table t1 (c text default E'\\');
+-- end --
+create table t1 (c text default '\A\');
+-- end --
+create table t1 (c text default e'\\A\\');
\ No newline at end of file