Skip to content
Merged
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
79 changes: 62 additions & 17 deletions src/main/java/net/datafaker/transformations/sql/SqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand 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) -> {
Expand All @@ -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) {
Expand Down Expand Up @@ -77,30 +78,46 @@ public String getFieldPrefix(String fieldName) {
private final TriFunction<Supplier<String>, Supplier<String>, SqlTransformer.Case, String> batchFirstRow;
private final TriFunction<Supplier<String>, Supplier<String>, SqlTransformer.Case, String> batchOtherRows;
private final Function<SqlTransformer.Case, String> lastBatchRow;
private final QuoteEscape quoteEscape;

private static final String DEFAULT_BEFORE_EACH_BATCH_PREFIX = " ";

SqlDialect(String sqlQuoteIdentifier, Casing casing, TriFunction<Supplier<String>, Supplier<String>, SqlTransformer.Case, String> batchFirstRow,
SqlDialect(String sqlQuoteIdentifier, Casing casing, QuoteEscape quoteEscape,
TriFunction<Supplier<String>, Supplier<String>, SqlTransformer.Case, String> batchFirstRow,
TriFunction<Supplier<String>, Supplier<String>, SqlTransformer.Case, String> batchOtherRows, Function<SqlTransformer.Case, String> 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<String>, Supplier<String>, SqlTransformer.Case, String> batchFirstRow,
TriFunction<Supplier<String>, Supplier<String>, SqlTransformer.Case, String> batchOtherRows, Function<SqlTransformer.Case, String> lastBatchRow) {
this(sqlQuoteIdentifier, casing, QuoteEscape.DOUBLE, batchFirstRow, batchOtherRows, lastBatchRow);
}

SqlDialect(String sqlQuoteIdentifier, TriFunction<Supplier<String>, Supplier<String>, SqlTransformer.Case, String> batchFirstRow,
TriFunction<Supplier<String>, Supplier<String>, SqlTransformer.Case, String> batchOtherRows, Function<SqlTransformer.Case, String> 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);
}
Comment thread
asolntsev marked this conversation as resolved.

SqlDialect(String sqlQuoteIdentifier) {
this(sqlQuoteIdentifier, DEFAULT_CASING);
}
Expand Down Expand Up @@ -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'}).
* <p>
* 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) {
Comment thread
asolntsev marked this conversation as resolved.
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ public class SqlTransformer<IN> implements Transformer<IN, CharSequence> {

private final Casing casing;
private final char quote;
private final char quoteEscape;
private final char openSqlIdentifier;
private final char closeSqlIdentifier;
private final String tableName;
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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) {
Expand Down
51 changes: 51 additions & 0 deletions src/test/java/net/datafaker/formats/SqlTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,58 @@ private static Stream<Arguments> 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<String, String> schema = Schema.of(field("path", () -> "C:\\temp\\"));
SqlTransformer<String> transformer = SqlTransformer.<String>builder().dialect(dialect).build();

assertThat(transformer.generate(schema, 1)).endsWith("VALUES (" + expectedValue + ");");
}

Comment thread
asolntsev marked this conversation as resolved.
private static Stream<Arguments> 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<String, String> schema = Schema.of(field("text", () -> "a\\'b"));
SqlTransformer<String> transformer = SqlTransformer.<String>builder().dialect(SqlDialect.MYSQL).build();

assertThat(transformer.generate(schema, 1)).endsWith("VALUES ('a\\\\\\'b');");
}

@Test
void withoutDialectOnlyTheQuoteIsEscaped() {
Schema<String, String> schema = Schema.of(field("text", () -> "C:\\temp\\O'Brien"));
SqlTransformer<String> transformer = SqlTransformer.<String>builder().build();

assertThat(transformer.generate(schema, 1)).endsWith("VALUES ('C:\\temp\\O\\'Brien');");
}

@Test
void escapesSparkSqlFieldPrefix() {
Schema<String, String> schema = Schema.of(
compositeField("outer", new Field[]{field("it's", () -> "x"), field("a\\b", () -> "y")}));
SqlTransformer<String> transformer = SqlTransformer.<String>builder().dialect(SqlDialect.SPARKSQL).build();

assertThat(transformer.generate(schema, 1)).contains("'it\\'s', 'x'", "'a\\\\b', 'y'");
}
}
Loading