diff --git a/src/main/java/net/datafaker/transformations/sql/SqlDialect.java b/src/main/java/net/datafaker/transformations/sql/SqlDialect.java index a36c5cf3e..29b813fee 100644 --- a/src/main/java/net/datafaker/transformations/sql/SqlDialect.java +++ b/src/main/java/net/datafaker/transformations/sql/SqlDialect.java @@ -7,6 +7,7 @@ import static net.datafaker.transformations.Transformer.LINE_SEPARATOR; import static net.datafaker.transformations.sql.Casing.DEFAULT_CASING; +import static net.datafaker.transformations.sql.SqlDialect.QuoteEscape.BACKSLASH; import static net.datafaker.transformations.sql.SqlDialect.AuxiliaryConstants.DEFAULT_FIRST_ROW; import static net.datafaker.transformations.sql.SqlDialect.AuxiliaryConstants.DEFAULT_OTHER_ROWS; import static net.datafaker.transformations.sql.SqlTransformer.SQLKeyWords.INSERT_ALL; @@ -19,17 +20,17 @@ public enum SqlDialect { ANSI("`"), - BIGQUERY("`", Casing.UNCHANGED), + BIGQUERY("`", Casing.UNCHANGED, BACKSLASH), CALCITE("\""), - CLICKHOUSE("`"), + CLICKHOUSE("`", BACKSLASH), EXASOL("\""), FIREBOLT("\""), H2("\""), - INFOBRIGHT("`"), + INFOBRIGHT("`", BACKSLASH), LUCIDDB("\""), - MARIADB("`", Casing.TO_LOWER), + MARIADB("`", Casing.TO_LOWER, BACKSLASH), MSSQL("[]"), - MYSQL("`", Casing.UNCHANGED), + MYSQL("`", Casing.UNCHANGED, BACKSLASH), NETEZZA("\""), ORACLE("\"", Casing.TO_UPPER, (columns, values, caze) -> { @@ -47,8 +48,8 @@ public enum SqlDialect { POSTGRES("\""), PRESTO("\"", Casing.UNCHANGED), REDSHIFT("\"", Casing.TO_LOWER), - SNOWFLAKE("\""), - SPARKSQL("`") { + SNOWFLAKE("\"", BACKSLASH), + SPARKSQL("`", BACKSLASH) { @Override public String getCompositePrefix(SqlTransformer.Case caze) { @@ -77,30 +78,46 @@ public String getFieldPrefix(String fieldName) { private final TriFunction, Supplier, SqlTransformer.Case, String> batchFirstRow; private final TriFunction, Supplier, SqlTransformer.Case, String> batchOtherRows; private final Function lastBatchRow; + private final QuoteEscape quoteEscape; private static final String DEFAULT_BEFORE_EACH_BATCH_PREFIX = " "; - SqlDialect(String sqlQuoteIdentifier, Casing casing, TriFunction, Supplier, SqlTransformer.Case, String> batchFirstRow, + SqlDialect(String sqlQuoteIdentifier, Casing casing, QuoteEscape quoteEscape, + TriFunction, Supplier, SqlTransformer.Case, String> batchFirstRow, TriFunction, Supplier, SqlTransformer.Case, String> batchOtherRows, Function lastBatchRow) { this.sqlQuoteIdentifier = sqlQuoteIdentifier; this.unquotedCasing = casing; + this.quoteEscape = quoteEscape; this.batchFirstRow = batchFirstRow; this.batchOtherRows = batchOtherRows; this.lastBatchRow = lastBatchRow; } + SqlDialect(String sqlQuoteIdentifier, Casing casing, TriFunction, Supplier, SqlTransformer.Case, String> batchFirstRow, + TriFunction, Supplier, SqlTransformer.Case, String> batchOtherRows, Function lastBatchRow) { + this(sqlQuoteIdentifier, casing, QuoteEscape.DOUBLE, batchFirstRow, batchOtherRows, lastBatchRow); + } + SqlDialect(String sqlQuoteIdentifier, TriFunction, Supplier, SqlTransformer.Case, String> batchFirstRow, TriFunction, Supplier, SqlTransformer.Case, String> batchOtherRows, Function lastBatchRow) { this(sqlQuoteIdentifier, DEFAULT_CASING, batchFirstRow, batchOtherRows, lastBatchRow); } - SqlDialect(String sqlQuoteIdentifier, Casing casing) { - this(sqlQuoteIdentifier, casing, + SqlDialect(String sqlQuoteIdentifier, Casing casing, QuoteEscape quoteEscape) { + this(sqlQuoteIdentifier, casing, quoteEscape, DEFAULT_FIRST_ROW, DEFAULT_OTHER_ROWS, s -> ""); } + SqlDialect(String sqlQuoteIdentifier, Casing casing) { + this(sqlQuoteIdentifier, casing, QuoteEscape.DOUBLE); + } + + SqlDialect(String sqlQuoteIdentifier, QuoteEscape quoteEscape) { + this(sqlQuoteIdentifier, DEFAULT_CASING, quoteEscape); + } + SqlDialect(String sqlQuoteIdentifier) { this(sqlQuoteIdentifier, DEFAULT_CASING); } @@ -148,14 +165,42 @@ public String getArrayEnd() { } /** - * Whether a quote inside a string literal is escaped with a backslash ({@code 'It\'s'}). - * Other dialects double it ({@code 'It''s'}) as standard SQL requires. + * Escapes a value placed inside a string literal delimited by {@code quote}. + * Most dialects double the quote ({@code 'It''s'}) as standard SQL requires. Dialects declared with + * {@code QuoteEscape.BACKSLASH} put a backslash before the quote and before a literal backslash + * ({@code 'It\'s'}, {@code 'C:\\temp'}). + *

+ * This assumes the server's default behaviour: MySQL and MariaDB running with the + * {@code NO_BACKSLASH_ESCAPES} SQL mode treat backslashes literally and need the standard doubling instead. */ - public boolean escapesQuoteWithBackslash() { - return switch (this) { - case BIGQUERY, CLICKHOUSE, INFOBRIGHT, MARIADB, MYSQL, SPARKSQL -> true; - default -> false; - }; + public String escapeStringLiteral(String value, char quote) { + return escape(value, quote, quoteEscape == QuoteEscape.BACKSLASH ? '\\' : quote); + } + + /** + * Puts {@code escape} before every {@code quote} and every {@code escape} in the value. + * When both are the same character, this doubles it. + */ + static String escape(String value, char quote, char escape) { + StringBuilder result = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == quote || c == escape) { + result.append(escape); + } + result.append(c); + } + return result.toString(); + } + + /** + * How a dialect escapes the quote character inside a string literal. + */ + enum QuoteEscape { + /** {@code 'It''s'} */ + DOUBLE, + /** {@code 'It\'s'}, a literal backslash is escaped too */ + BACKSLASH } static class AuxiliaryConstants { diff --git a/src/main/java/net/datafaker/transformations/sql/SqlTransformer.java b/src/main/java/net/datafaker/transformations/sql/SqlTransformer.java index 888f9c65d..0926bb47f 100644 --- a/src/main/java/net/datafaker/transformations/sql/SqlTransformer.java +++ b/src/main/java/net/datafaker/transformations/sql/SqlTransformer.java @@ -32,7 +32,6 @@ public class SqlTransformer implements Transformer { private final Casing casing; private final char quote; - private final char quoteEscape; private final char openSqlIdentifier; private final char closeSqlIdentifier; private final String tableName; @@ -53,7 +52,6 @@ private SqlTransformer(String schemaName, String tableName, char quote, @Nullabl Casing casing, boolean withBatchMode, int batchSize, Case keywordCase, boolean forceSqlQuoteIdentifierUsage) { this.schemaName = schemaName; this.quote = quote; - this.quoteEscape = dialect == null || dialect.escapesQuoteWithBackslash() ? '\\' : quote; this.dialect = dialect; this.openSqlIdentifier = sqlIdentifier.charAt(0); this.closeSqlIdentifier = sqlIdentifier.length() == 1 ? sqlIdentifier.charAt(0) : sqlIdentifier.charAt(1); @@ -117,7 +115,7 @@ private String addValues(@Nullable IN input, Field[] fields, Boolean isRoo dialect.getFieldPrefix(fields[i].getName()); if (!fieldPrefix.isEmpty() && !isRoot) { - result.add(quote + dialect.getFieldPrefix(fields[i].getName()) + quote); + result.add(quote + escapeStringLiteral(fieldPrefix) + quote); } } @@ -214,25 +212,21 @@ private String handleObject(@Nullable Object value) { + "[" + handleObjectInCollection(collection) + "]"; } else { String strValue = value.toString(); - final int length = strValue.length(); final boolean quoteRequired = !(value instanceof Number) && !(value instanceof Boolean); - String res = handledObjectToString(length, strValue); + String res = escapeStringLiteral(strValue); return quoteRequired ? quote + res + quote : res; } } } - private String handledObjectToString(int length, String strValue) { - StringBuilder builder = new StringBuilder(length); - - for (int k = 0; k < length; k++) { - if (strValue.charAt(k) == quote) { - builder.append(quoteEscape).append(quote); - }else { - builder.append(strValue.charAt(k)); - } + /** + * Without a dialect, the quote is prefixed with a backslash and backslashes are left alone. + */ + private String escapeStringLiteral(String value) { + if (dialect != null) { + return dialect.escapeStringLiteral(value, quote); } - return builder.toString(); + return value.replace(String.valueOf(quote), "\\" + quote); } private String handlePrimitivesInArray(Class componentType, Object value) { diff --git a/src/test/java/net/datafaker/formats/SqlTest.java b/src/test/java/net/datafaker/formats/SqlTest.java index 7a9f58c1d..3d8b9bae0 100644 --- a/src/test/java/net/datafaker/formats/SqlTest.java +++ b/src/test/java/net/datafaker/formats/SqlTest.java @@ -617,7 +617,58 @@ private static Stream quoteEscapeDialects() { of(SqlDialect.MYSQL, "'O\\'Brien'"), of(SqlDialect.MARIADB, "'O\\'Brien'"), of(SqlDialect.BIGQUERY, "'O\\'Brien'"), + of(SqlDialect.CLICKHOUSE, "'O\\'Brien'"), + of(SqlDialect.INFOBRIGHT, "'O\\'Brien'"), + of(SqlDialect.SNOWFLAKE, "'O\\'Brien'"), of(SqlDialect.SPARKSQL, "'O\\'Brien'") ); } + + @ParameterizedTest + @MethodSource("backslashEscapeDialects") + void escapesBackslashInStringValues(SqlDialect dialect, String expectedValue) { + Schema schema = Schema.of(field("path", () -> "C:\\temp\\")); + SqlTransformer transformer = SqlTransformer.builder().dialect(dialect).build(); + + assertThat(transformer.generate(schema, 1)).endsWith("VALUES (" + expectedValue + ");"); + } + + private static Stream backslashEscapeDialects() { + return Stream.of( + of(SqlDialect.POSTGRES, "'C:\\temp\\'"), + of(SqlDialect.MSSQL, "'C:\\temp\\'"), + of(SqlDialect.MYSQL, "'C:\\\\temp\\\\'"), + of(SqlDialect.MARIADB, "'C:\\\\temp\\\\'"), + of(SqlDialect.BIGQUERY, "'C:\\\\temp\\\\'"), + of(SqlDialect.CLICKHOUSE, "'C:\\\\temp\\\\'"), + of(SqlDialect.INFOBRIGHT, "'C:\\\\temp\\\\'"), + of(SqlDialect.SNOWFLAKE, "'C:\\\\temp\\\\'"), + of(SqlDialect.SPARKSQL, "'C:\\\\temp\\\\'") + ); + } + + @Test + void escapesBackslashBeforeQuote() { + Schema schema = Schema.of(field("text", () -> "a\\'b")); + SqlTransformer transformer = SqlTransformer.builder().dialect(SqlDialect.MYSQL).build(); + + assertThat(transformer.generate(schema, 1)).endsWith("VALUES ('a\\\\\\'b');"); + } + + @Test + void withoutDialectOnlyTheQuoteIsEscaped() { + Schema schema = Schema.of(field("text", () -> "C:\\temp\\O'Brien")); + SqlTransformer transformer = SqlTransformer.builder().build(); + + assertThat(transformer.generate(schema, 1)).endsWith("VALUES ('C:\\temp\\O\\'Brien');"); + } + + @Test + void escapesSparkSqlFieldPrefix() { + Schema schema = Schema.of( + compositeField("outer", new Field[]{field("it's", () -> "x"), field("a\\b", () -> "y")})); + SqlTransformer transformer = SqlTransformer.builder().dialect(SqlDialect.SPARKSQL).build(); + + assertThat(transformer.generate(schema, 1)).contains("'it\\'s', 'x'", "'a\\\\b', 'y'"); + } }