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
70 changes: 68 additions & 2 deletions bloviate-core/src/main/java/io/bloviate/db/Table.java
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,82 @@ public String insertString() {
* @return a parameterized SQL INSERT statement string
*/
public String insertString(String identifierQuote) {
return insertString(identifierQuote, null);
}

/**
* Generates an SQL INSERT statement template whose values may be SQL expressions rather than
* bare placeholders.
*
* <p>Identical to {@link #insertString(String)} except that each value slot is taken from
* {@code valueExpressions} instead of being a {@code ?}. This exists for columns whose type the
* driver cannot bind and which must therefore be constructed in SQL &mdash; BigQuery's
* {@code PARSE_JSON(?)} and {@code ST_GEOGFROMTEXT(?)}, for example. Expressions come from
* {@link io.bloviate.gen.DataGenerator#valueExpression()}, so the generator resolved for a
* column decides its own binding.
*
* <p>Each expression must contain <strong>exactly one</strong> {@code ?}. The engine binds one
* parameter per column by position, so an expression with none or several would shift every
* later parameter onto the wrong column &mdash; a silent data-corruption failure rather than an
* error &mdash; and is rejected here instead.
*
* @param identifierQuote the identifier quote string, as in {@link #insertString(String)}
* @param valueExpressions one expression per {@link #filteredColumns() filtered column}, in
* order; {@code null} means a bare {@code ?} for every column
* @return a parameterized SQL INSERT statement string
* @throws IllegalArgumentException if the list size does not match the filtered column count
* @throws IllegalStateException if any expression does not contain exactly one {@code ?}
* @since 3.2.0
*/
public String insertString(String identifierQuote, List<String> valueExpressions) {
List<Column> columns = filteredColumns();

if (valueExpressions != null && valueExpressions.size() != columns.size()) {
throw new IllegalArgumentException(String.format(
"table [%s] has %d fillable columns but %d value expressions were supplied",
name, columns.size(), valueExpressions.size()));
}

StringJoiner nameJoiner = new StringJoiner(",");
StringJoiner valueJoiner = new StringJoiner(",");

for (Column column : filteredColumns()) {
for (int i = 0; i < columns.size(); i++) {
Column column = columns.get(i);
nameJoiner.add(quote(column.name(), identifierQuote));
valueJoiner.add("?");
valueJoiner.add(valueExpressions == null
? "?"
: validatedExpression(valueExpressions.get(i), column));
}

return String.format("insert into %s (%s) values (%s)", qualifiedName(identifierQuote), nameJoiner, valueJoiner);
}

/**
* Checks that a value expression binds exactly one parameter, so column {@code i}'s generator
* always writes to parameter {@code i + 1}.
*/
private static String validatedExpression(String expression, Column column) {
if (expression == null) {
throw new IllegalStateException(String.format(
"value expression for column [%s.%s] is null; it must contain exactly one '?'",
column.tableName(), column.name()));
}

int placeholders = 0;
for (int i = 0; i < expression.length(); i++) {
if (expression.charAt(i) == '?') {
placeholders++;
}
}

if (placeholders != 1) {
throw new IllegalStateException(String.format(
"value expression [%s] for column [%s.%s] contains %d '?' placeholders; it must contain "
+ "exactly one, because the engine binds one parameter per column by position",
expression, column.tableName(), column.name(), placeholders));
}

return expression;
}

private String qualifiedName(String identifierQuote) {
Expand Down
18 changes: 14 additions & 4 deletions bloviate-core/src/main/java/io/bloviate/db/TableFiller.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
Expand Down Expand Up @@ -102,10 +103,6 @@ public TableFiller(Connection connection, Database database, DatabaseConfigurati
@Override
public void fill() throws SQLException {

String sql = table.insertString(connection.getMetaData().getIdentifierQuoteString());

logger.trace("{}", sql);

// The fill loop is the hot path: it runs once per cell (rowCount * columnCount times).
// To keep it allocation- and lookup-free, everything is resolved up front into arrays
// indexed by column position, so the inner loop only does positional array reads instead
Expand Down Expand Up @@ -216,6 +213,19 @@ public void fill() throws SQLException {
}
}

// Built after generator resolution, not before: a generator decides how its value is bound,
// and a type the driver cannot bind directly must be constructed in SQL instead (BigQuery's
// PARSE_JSON(?) / ST_GEOGFROMTEXT(?)). Nearly always this is the same all-placeholder
// statement as before, since DataGenerator.valueExpression() defaults to "?".
List<String> valueExpressions = new ArrayList<>(columnCount);
for (DataGenerator<?> generator : generators) {
valueExpressions.add(generator.valueExpression());
}

String sql = table.insertString(connection.getMetaData().getIdentifierQuoteString(), valueExpressions);

logger.trace("{}", sql);

int batchSize = databaseConfiguration.batchSize();

long totalRowCount = databaseConfiguration.defaultRowCount();
Expand Down
94 changes: 67 additions & 27 deletions bloviate-core/src/main/java/io/bloviate/ext/BigQuerySupport.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@
import io.bloviate.db.Database;
import io.bloviate.gen.BigDecimalGenerator;
import io.bloviate.gen.ByteGenerator;
import io.bloviate.gen.DataGenerator;
import io.bloviate.gen.IntervalGenerator;
import io.bloviate.gen.JsonbGenerator;
import io.bloviate.gen.SimpleStringGenerator;
import io.bloviate.gen.SqlExpressionGenerator;
import io.bloviate.gen.WktPointGenerator;

import java.sql.Connection;
import java.sql.JDBCType;
Expand All @@ -30,7 +35,8 @@
/**
* Google BigQuery-specific {@link DatabaseSupport}, written against the
* <a href="https://github.com/Two-Bear-Capital/tbc-bq-jdbc">tbc-bq-jdbc</a> driver
* (<code>vc.tbc:tbc-bq-jdbc</code>, 4.3.0 or later).
* (<code>vc.tbc:tbc-bq-jdbc</code>, 4.3.0 or later; 4.4.0 or later is strongly recommended once
* released &mdash; see the value-expression note below).
*
* <p>BigQuery is an analytical engine, and it diverges from the OLTP databases Bloviate
* otherwise targets in three ways this class has to account for:
Expand All @@ -47,26 +53,30 @@
* {@code JSON}, {@code GEOGRAPHY} and {@code INTERVAL} back as {@link JDBCType#VARCHAR} and
* {@code DATETIME} as {@link JDBCType#TIMESTAMP}, but has no parameter binding that produces
* those types, and BigQuery will not implicitly coerce a {@code STRING}/{@code TIMESTAMP}
* parameter into them. Filling those columns needs SQL-side construction
* ({@code PARSE_JSON(?)}, {@code ST_GEOGFROMTEXT(?)}, {@code CAST(? AS DATETIME)}), which is
* not yet wired through the fill engine, so they are rejected here with an actionable
* message rather than generated and rejected by the server mid-batch.</li>
* parameter into them. Their values are therefore generated as text and constructed by the
* server through a {@link io.bloviate.gen.DataGenerator#valueExpression() value expression}
* &mdash; see {@link #VALUE_EXPRESSIONS}. This works on any supported driver version, but
* is much faster from 4.4.0: earlier versions only collapse a batch whose {@code VALUES}
* tuple is placeholders-only, so a table with one of these columns degrades to one query job
* per row &mdash; correct, but slow enough to matter and expensive on a large fill.</li>
* <li><strong>Keys are always {@code NOT ENFORCED}.</strong> BigQuery accepts declarative
* {@code PRIMARY KEY}/{@code FOREIGN KEY} constraints but never enforces them, and the driver
* surfaces them through {@code getPrimaryKeys}/{@code getImportedKeys}. Bloviate's FK-aware
* ordering therefore works, and {@link #supportsBulkLoad()} is safe to enable.</li>
* </ul>
*
* <p><strong>Supported types:</strong> {@code STRING}, {@code BYTES}, {@code INT64},
* {@code FLOAT64}, {@code NUMERIC}, {@code BIGNUMERIC} (within {@code NUMERIC} range),
* {@code BOOL}, {@code DATE}, {@code TIME}, {@code TIMESTAMP}.
* <p><strong>Supported types:</strong> every scalar type &mdash; {@code STRING}, {@code BYTES},
* {@code INT64}, {@code FLOAT64}, {@code NUMERIC}, {@code BIGNUMERIC} (within {@code NUMERIC}
* range), {@code BOOL}, {@code DATE}, {@code TIME}, {@code TIMESTAMP}, {@code DATETIME},
* {@code JSON}, {@code GEOGRAPHY} and {@code INTERVAL}.
*
* <p><strong>Unsupported types:</strong> {@code DATETIME}, {@code JSON}, {@code GEOGRAPHY},
* {@code INTERVAL}, {@code RANGE}, {@code ARRAY}, {@code STRUCT}. Supply a per-column generator
* through {@code ColumnConfiguration} or
* {@link GeneratorRegistry.Builder#registerColumnNamePattern} to fill them; the driver implements
* {@link Connection#createArrayOf} and {@link Connection#createStruct}, so a hand-written generator
* can write composites today.
* <p><strong>Unsupported types:</strong> the composite ones &mdash; {@code ARRAY}, {@code STRUCT}
* and {@code RANGE}. {@code ARRAY} and {@code STRUCT} need a shape Bloviate has no representation
* for; {@code RANGE} would need two parameters for one column, which the fill engine's
* one-parameter-per-column binding cannot express. Supply a per-column generator through
* {@code ColumnConfiguration} or {@link GeneratorRegistry.Builder#registerColumnNamePattern} to
* fill them; the driver implements {@link Connection#createArrayOf} and
* {@link Connection#createStruct}, so a hand-written generator can write composites today.
*
* <p><strong>Required driver settings:</strong> {@code includeStructFields=false} (the default) and
* {@code metadataLazyLoad=false} (the default). With struct fields spliced in, {@code getColumns}
Expand Down Expand Up @@ -112,24 +122,55 @@ public class BigQuerySupport extends AbstractDatabaseSupport {
*/
public static final int MAX_NUMERIC_SCALE = 9;

/**
* SQL that constructs a value for each type the driver cannot bind, keyed by BigQuery type name.
*
* <p>These are the types whose {@code getColumns} shape is indistinguishable from a bindable one
* &mdash; {@code JSON}, {@code GEOGRAPHY} and {@code INTERVAL} all read back as
* {@link JDBCType#VARCHAR}, {@code DATETIME} as {@link JDBCType#TIMESTAMP} &mdash; but which
* have no parameter type of their own, and which BigQuery will not implicitly coerce into. The
* value is generated as text (or, for {@code DATETIME}, as a timestamp) and turned into the
* column's type by the server.
*
* <p>Wrapping costs the batch collapse: tbc-bq-jdbc keeps a batch whose {@code VALUES} tuple is
* not placeholders-only off its NDJSON load-job path, since that path writes bound values
* directly and would drop the wrapping. The DML collapse itself still applies, but only from
* driver 4.4.0 &mdash; earlier versions require a placeholders-only tuple and fall back to one
* query job per row, which is correct but very slow.
*/
private static final Map<String, String> VALUE_EXPRESSIONS = Map.of(
"JSON", "PARSE_JSON(?)",
"GEOGRAPHY", "ST_GEOGFROMTEXT(?)",
"INTERVAL", "CAST(? AS INTERVAL)",
"DATETIME", "CAST(? AS DATETIME)");

/** Creates the BigQuery support with its default configuration. */
public BigQuerySupport() {
}

/**
* Wraps a generator so its text is turned into {@code typeName} by the server.
*
* @param delegate the generator producing the value's text
* @param typeName the BigQuery type name, which must have a {@link #VALUE_EXPRESSIONS} entry
* @return the wrapped generator
*/
private static DataGenerator<?> constructed(DataGenerator<?> delegate, String typeName) {
return SqlExpressionGenerator.of(delegate, VALUE_EXPRESSIONS.get(typeName));
}

@Override
protected void configure(Map<JDBCType, GeneratorFactory> registry) {

// STRING, and the three types the driver reads back as VARCHAR but cannot bind.
// STRING, plus the three types the driver reads back as VARCHAR but cannot bind: their
// values are generated as text and constructed server-side (see VALUE_EXPRESSIONS).
registry.put(JDBCType.VARCHAR, (column, random) -> switch (baseTypeName(column)) {
case "STRING" -> new SimpleStringGenerator.Builder(random)
.size(clamp(column.maxSize(), MAX_STRING_LENGTH))
.build();
case "JSON" -> throw unsupported(column,
"the driver binds strings as STRING and BigQuery will not coerce STRING to JSON");
case "GEOGRAPHY" -> throw unsupported(column,
"the driver binds strings as STRING and BigQuery will not coerce STRING to GEOGRAPHY");
case "INTERVAL" -> throw unsupported(column,
"the driver binds strings as STRING and BigQuery will not coerce STRING to INTERVAL");
case "JSON" -> constructed(new JsonbGenerator.Builder(random).build(), "JSON");
case "GEOGRAPHY" -> constructed(new WktPointGenerator.Builder(random).build(), "GEOGRAPHY");
case "INTERVAL" -> constructed(new IntervalGenerator.Builder(random).build(), "INTERVAL");
default -> throw unsupported(column, "no generator is registered for this type");
});

Expand Down Expand Up @@ -159,14 +200,13 @@ protected void configure(Map<JDBCType, GeneratorFactory> registry) {
registry.put(JDBCType.NUMERIC, bigDecimal);
registry.put(JDBCType.DECIMAL, bigDecimal);

// TIMESTAMP and DATETIME both arrive as JDBC TIMESTAMP; only TIMESTAMP can be bound.
// TIMESTAMP and DATETIME both arrive as JDBC TIMESTAMP. Only TIMESTAMP can be bound, so a
// DATETIME column takes the same generated instant and casts it server-side. The cast is
// interpreted in UTC, which is deterministic and therefore reproducible.
GeneratorFactory inheritedTimestamp = registry.get(JDBCType.TIMESTAMP);
registry.put(JDBCType.TIMESTAMP, (column, random) -> {
if ("DATETIME".equals(baseTypeName(column))) {
throw unsupported(column, "the driver has no DATETIME parameter type and BigQuery "
+ "will not coerce TIMESTAMP to DATETIME");
}
return inheritedTimestamp.create(column, random);
DataGenerator<?> timestamp = inheritedTimestamp.create(column, random);
return "DATETIME".equals(baseTypeName(column)) ? constructed(timestamp, "DATETIME") : timestamp;
});

// RANGE<...> is the only type the driver maps to OTHER.
Expand Down
31 changes: 31 additions & 0 deletions bloviate-core/src/main/java/io/bloviate/gen/DataGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,37 @@ default boolean positionable() {
return false;
}

/**
* The SQL this generator's value occupies inside an {@code INSERT ... VALUES} tuple.
*
* <p>The default {@code "?"} binds the generated value directly, which is what almost every
* generator wants. Override it when the value has to be <em>constructed</em> in SQL because the
* driver cannot bind the column's type &mdash; BigQuery's {@code JSON} and {@code GEOGRAPHY}
* have no parameter binding and the server will not coerce a {@code STRING} into them, so a
* generator emitting JSON text declares {@code "PARSE_JSON(?)"} and one emitting WKT declares
* {@code "ST_GEOGFROMTEXT(?)"}.
*
* <p>This lives on the generator rather than on {@link io.bloviate.ext.DatabaseSupport} because
* the generator is what knows the shape of the text it produces. It also means a generator
* supplied through {@link io.bloviate.ext.GeneratorRegistry} or a
* {@link io.bloviate.ext.GeneratorPlugin} carries its own wrapping with no support involvement.
*
* <p><strong>Must contain exactly one {@code ?}.</strong> The fill engine binds one parameter
* per column by position, so any other count would silently misalign every subsequent
* parameter; {@link io.bloviate.db.Table#insertString(String, java.util.List)} rejects it rather
* than emit such a statement. To wrap an existing generator without subclassing it, use
* {@link SqlExpressionGenerator}.
*
* <p>Note that a wrapped value may cost throughput: a driver that collapses a JDBC batch into a
* multi-row {@code INSERT} may decline to do so for a tuple that is not placeholders-only.
*
* @return the SQL expression for this value, containing exactly one {@code ?}
* @since 3.2.0
*/
default String valueExpression() {
return "?";
}

/**
* Resets this generator's random source to the given seed for reproducible foreign-key
* wraparound. Implementations must replace the underlying {@link java.util.random.RandomGenerator}
Expand Down
Loading
Loading