diff --git a/bloviate-core/src/main/java/io/bloviate/db/Table.java b/bloviate-core/src/main/java/io/bloviate/db/Table.java
index 48768e5..b09b111 100644
--- a/bloviate-core/src/main/java/io/bloviate/db/Table.java
+++ b/bloviate-core/src/main/java/io/bloviate/db/Table.java
@@ -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.
+ *
+ *
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 — 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.
+ *
+ *
Each expression must contain exactly one {@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 — a silent data-corruption failure rather than an
+ * error — 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 valueExpressions) {
+ List 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) {
diff --git a/bloviate-core/src/main/java/io/bloviate/db/TableFiller.java b/bloviate-core/src/main/java/io/bloviate/db/TableFiller.java
index 3ac69de..288d318 100644
--- a/bloviate-core/src/main/java/io/bloviate/db/TableFiller.java
+++ b/bloviate-core/src/main/java/io/bloviate/db/TableFiller.java
@@ -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;
@@ -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
@@ -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 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();
diff --git a/bloviate-core/src/main/java/io/bloviate/ext/BigQuerySupport.java b/bloviate-core/src/main/java/io/bloviate/ext/BigQuerySupport.java
index ee7a2e3..fd5bbf6 100644
--- a/bloviate-core/src/main/java/io/bloviate/ext/BigQuerySupport.java
+++ b/bloviate-core/src/main/java/io/bloviate/ext/BigQuerySupport.java
@@ -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;
@@ -30,7 +35,8 @@
/**
* Google BigQuery-specific {@link DatabaseSupport}, written against the
* tbc-bq-jdbc driver
- * (vc.tbc:tbc-bq-jdbc, 4.3.0 or later).
+ * (vc.tbc:tbc-bq-jdbc, 4.3.0 or later; 4.4.0 or later is strongly recommended once
+ * released — see the value-expression note below).
*
* BigQuery is an analytical engine, and it diverges from the OLTP databases Bloviate
* otherwise targets in three ways this class has to account for:
@@ -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.
+ * 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}
+ * — 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 — correct, but slow enough to matter and expensive on a large fill.
*
Keys are always {@code NOT ENFORCED}. 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.
*
*
- * Supported types: {@code STRING}, {@code BYTES}, {@code INT64},
- * {@code FLOAT64}, {@code NUMERIC}, {@code BIGNUMERIC} (within {@code NUMERIC} range),
- * {@code BOOL}, {@code DATE}, {@code TIME}, {@code TIMESTAMP}.
+ *
Supported types: every scalar type — {@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}.
*
- *
Unsupported types: {@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.
+ *
Unsupported types: the composite ones — {@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.
*
*
Required driver settings: {@code includeStructFields=false} (the default) and
* {@code metadataLazyLoad=false} (the default). With struct fields spliced in, {@code getColumns}
@@ -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.
+ *
+ *
These are the types whose {@code getColumns} shape is indistinguishable from a bindable one
+ * — {@code JSON}, {@code GEOGRAPHY} and {@code INTERVAL} all read back as
+ * {@link JDBCType#VARCHAR}, {@code DATETIME} as {@link JDBCType#TIMESTAMP} — 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.
+ *
+ *
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 — 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 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 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");
});
@@ -159,14 +200,13 @@ protected void configure(Map 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.
diff --git a/bloviate-core/src/main/java/io/bloviate/gen/DataGenerator.java b/bloviate-core/src/main/java/io/bloviate/gen/DataGenerator.java
index 168e2ff..5846103 100644
--- a/bloviate-core/src/main/java/io/bloviate/gen/DataGenerator.java
+++ b/bloviate-core/src/main/java/io/bloviate/gen/DataGenerator.java
@@ -84,6 +84,37 @@ default boolean positionable() {
return false;
}
+ /**
+ * The SQL this generator's value occupies inside an {@code INSERT ... VALUES} tuple.
+ *
+ * The default {@code "?"} binds the generated value directly, which is what almost every
+ * generator wants. Override it when the value has to be constructed in SQL because the
+ * driver cannot bind the column's type — 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(?)"}.
+ *
+ *
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.
+ *
+ *
Must contain exactly one {@code ?}. 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}.
+ *
+ *
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}
diff --git a/bloviate-core/src/main/java/io/bloviate/gen/SqlExpressionGenerator.java b/bloviate-core/src/main/java/io/bloviate/gen/SqlExpressionGenerator.java
new file mode 100644
index 0000000..cf9bb47
--- /dev/null
+++ b/bloviate-core/src/main/java/io/bloviate/gen/SqlExpressionGenerator.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (c) 2021 Tim Veil
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.bloviate.gen;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+/**
+ * Wraps another {@link DataGenerator} so its value is constructed by a SQL expression rather than
+ * bound as a bare placeholder, changing nothing else about it.
+ *
+ *
This is how a shared generator is reused for a type whose value the driver cannot bind
+ * directly. {@link JsonbGenerator} already produces exactly the JSON text a BigQuery {@code JSON}
+ * column needs, but the driver binds it as a {@code STRING} and BigQuery will not coerce that into
+ * {@code JSON}; wrapping supplies the missing {@code PARSE_JSON(?)} without giving
+ * {@code JsonbGenerator} a BigQuery-specific opinion that would then apply to PostgreSQL too:
+ *
+ *
{@code
+ * SqlExpressionGenerator.of(new JsonbGenerator.Builder(random).build(), "PARSE_JSON(?)")
+ * }
+ *
+ * Use {@link #of(DataGenerator, String)} rather than the constructor. Whether a generator
+ * implements {@link IndexedDataGenerator} is load-bearing — the fill engine tests for it to
+ * decide how to position a partitioned fill — so the factory returns a variant that
+ * implements it exactly when the delegate does. A single class cannot do that, and getting it wrong
+ * in either direction breaks partitioned fills: claiming the interface without a seekable delegate
+ * silently skips repositioning, and dropping it from a seekable delegate replays draws instead of
+ * seeking.
+ *
+ * @param the Java type of values produced by the wrapped generator
+ * @since 3.2.0
+ * @see DataGenerator#valueExpression()
+ */
+public class SqlExpressionGenerator implements DataGenerator {
+
+ private final DataGenerator delegate;
+ private final String valueExpression;
+
+ /**
+ * Wraps a generator so its value is written through the given SQL expression.
+ *
+ * @param delegate the generator producing the value; must not be null
+ * @param valueExpression the SQL expression, containing exactly one {@code ?}
+ * @param the generated value type
+ * @return a generator equivalent to {@code delegate} but reporting {@code valueExpression},
+ * implementing {@link IndexedDataGenerator} exactly when {@code delegate} does
+ * @throws IllegalArgumentException if the expression does not contain exactly one {@code ?}
+ */
+ public static DataGenerator of(DataGenerator delegate, String valueExpression) {
+ return delegate instanceof IndexedDataGenerator
+ ? new IndexedSqlExpressionGenerator<>(delegate, valueExpression)
+ : new SqlExpressionGenerator<>(delegate, valueExpression);
+ }
+
+ /**
+ * Prefer {@link #of(DataGenerator, String)}, which preserves a seekable delegate's
+ * {@link IndexedDataGenerator} contract.
+ *
+ * @param delegate the generator producing the value
+ * @param valueExpression the SQL expression, containing exactly one {@code ?}
+ * @throws IllegalArgumentException if the expression does not contain exactly one {@code ?}
+ */
+ protected SqlExpressionGenerator(DataGenerator delegate, String valueExpression) {
+ if (delegate == null) {
+ throw new IllegalArgumentException("delegate generator is required");
+ }
+ this.delegate = delegate;
+ this.valueExpression = validate(valueExpression);
+ }
+
+ private static String validate(String valueExpression) {
+ if (valueExpression == null) {
+ throw new IllegalArgumentException("value expression is required");
+ }
+ int placeholders = 0;
+ for (int i = 0; i < valueExpression.length(); i++) {
+ if (valueExpression.charAt(i) == '?') {
+ placeholders++;
+ }
+ }
+ if (placeholders != 1) {
+ throw new IllegalArgumentException(String.format(
+ "value expression [%s] contains %d '?' placeholders; it must contain exactly one, "
+ + "because the engine binds one parameter per column by position",
+ valueExpression, placeholders));
+ }
+ return valueExpression;
+ }
+
+ /** @return the wrapped generator */
+ protected final DataGenerator delegate() {
+ return delegate;
+ }
+
+ @Override
+ public final String valueExpression() {
+ return valueExpression;
+ }
+
+ @Override
+ public T generate() {
+ return delegate.generate();
+ }
+
+ @Override
+ public String generateAsString() {
+ return delegate.generateAsString();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Delegated rather than assumed: wrapping changes only how the value reaches the column, so
+ * the delegate's positioning contract carries over unchanged. Answering for it would either
+ * skip repositioning a positionable column or reposition one that cannot survive it.
+ */
+ @Override
+ public boolean positionable() {
+ return delegate.positionable();
+ }
+
+ @Override
+ public void reseed(long seed) {
+ delegate.reseed(seed);
+ }
+
+ @Override
+ public void generateAndSet(Connection connection, PreparedStatement statement, int parameterIndex)
+ throws SQLException {
+ delegate.generateAndSet(connection, statement, parameterIndex);
+ }
+
+ @Override
+ public void set(Connection connection, PreparedStatement statement, int parameterIndex, T value)
+ throws SQLException {
+ delegate.set(connection, statement, parameterIndex, value);
+ }
+
+ @Override
+ public T get(ResultSet resultSet, int columnIndex) throws SQLException {
+ return delegate.get(resultSet, columnIndex);
+ }
+
+ /**
+ * The variant returned by {@link #of} for a delegate that is itself an
+ * {@link IndexedDataGenerator}, so a partitioned fill still seeks the delegate's counter to the
+ * absolute row index instead of replaying draws.
+ *
+ * @param the generated value type
+ */
+ private static final class IndexedSqlExpressionGenerator extends SqlExpressionGenerator
+ implements IndexedDataGenerator {
+
+ private IndexedSqlExpressionGenerator(DataGenerator delegate, String valueExpression) {
+ super(delegate, valueExpression);
+ }
+
+ @Override
+ public void seek(long rowIndex) {
+ ((IndexedDataGenerator) delegate()).seek(rowIndex);
+ }
+ }
+}
diff --git a/bloviate-core/src/main/java/io/bloviate/gen/WktPointGenerator.java b/bloviate-core/src/main/java/io/bloviate/gen/WktPointGenerator.java
new file mode 100644
index 0000000..420e9fc
--- /dev/null
+++ b/bloviate-core/src/main/java/io/bloviate/gen/WktPointGenerator.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2021 Tim Veil
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.bloviate.gen;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.Locale;
+import java.util.random.RandomGenerator;
+
+/**
+ * Generator for geospatial point columns. Produces a
+ * Well-Known
+ * Text point literal — {@code POINT(longitude latitude)} — as a {@link String}.
+ *
+ * Coordinates are drawn uniformly over the whole globe: longitude in {@code [-180, 180]},
+ * latitude in {@code [-90, 90]}, rounded to a configurable number of decimal places. Six places is
+ * roughly 0.1 m at the equator, which is finer than any generated data needs to be and keeps the
+ * literal short.
+ *
+ *
WKT is the interchange form every geospatial engine accepts, but usually not as a directly
+ * bound parameter — BigQuery needs {@code ST_GEOGFROMTEXT(?)} and PostGIS
+ * {@code ST_GeomFromText(?)}. Pair this with {@link SqlExpressionGenerator} to supply that wrapping
+ * rather than binding the text straight into a geography column, which the server will reject.
+ *
+ *
Longitude precedes latitude, per WKT (and unlike the "lat, long" convention of mapping UIs).
+ *
+ * @since 3.2.0
+ */
+public class WktPointGenerator extends AbstractDataGenerator {
+
+ private static final int MAX_LONGITUDE = 180;
+ private static final int MAX_LATITUDE = 90;
+
+ private final int scale;
+
+ @Override
+ public String generate() {
+ // draw order (longitude, then latitude) is part of the reproducibility contract
+ String longitude = coordinate(MAX_LONGITUDE);
+ String latitude = coordinate(MAX_LATITUDE);
+
+ return "POINT(" + longitude + " " + latitude + ")";
+ }
+
+ private String coordinate(int bound) {
+ // drawn over [0, 2*bound] and shifted, because the shared random utility rejects a negative
+ // range; one draw either way, so the seeded sequence is unaffected
+ double value = randomUtils.nextDouble(0, 2.0 * bound) - bound;
+ return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).toPlainString();
+ }
+
+ @Override
+ public String get(ResultSet resultSet, int columnIndex) throws SQLException {
+ return resultSet.getString(columnIndex);
+ }
+
+ /** Fluent builder for {@link WktPointGenerator}. */
+ public static class Builder extends AbstractBuilder {
+
+ private int scale = 6;
+
+ /**
+ * Creates a builder backed by the given seeded random source.
+ *
+ * @param random the random source used to draw generated values
+ */
+ public Builder(RandomGenerator random) {
+ super(random);
+ }
+
+ /**
+ * Sets how many decimal places each coordinate carries. Defaults to {@code 6}, about 0.1 m
+ * of resolution at the equator.
+ *
+ * @param scale the number of decimal places; must not be negative
+ * @return this builder, for chaining
+ * @throws IllegalArgumentException if {@code scale} is negative
+ */
+ public Builder scale(int scale) {
+ if (scale < 0) {
+ throw new IllegalArgumentException(
+ String.format(Locale.ROOT, "scale cannot be negative: %d", scale));
+ }
+ this.scale = scale;
+ return this;
+ }
+
+ @Override
+ public WktPointGenerator build() {
+ return new WktPointGenerator(this);
+ }
+ }
+
+ private WktPointGenerator(Builder builder) {
+ super(builder.random);
+ this.scale = builder.scale;
+ }
+}
diff --git a/bloviate-core/src/test/java/io/bloviate/db/BigQueryFillerTest.java b/bloviate-core/src/test/java/io/bloviate/db/BigQueryFillerTest.java
index 472d2b2..cb16590 100644
--- a/bloviate-core/src/test/java/io/bloviate/db/BigQueryFillerTest.java
+++ b/bloviate-core/src/test/java/io/bloviate/db/BigQueryFillerTest.java
@@ -164,5 +164,22 @@ private static void verify(Connection connection, String dataset, String runId)
// BIGNUMERIC is bound as NUMERIC, so its values must sit inside NUMERIC's range
assertCount(connection, String.format(
"select count(*) from %s where abs(c_bignumeric) >= 1e29", standard), 0);
+
+ // The four server-constructed types actually landed as their declared type rather than as
+ // text. Each of these functions only accepts the real type, so a row that arrived as a
+ // STRING could not have been written at all -- but assert non-null so a silently skipped
+ // column cannot pass either.
+ assertCount(connection, String.format(
+ "select count(*) from %s where c_datetime is null or c_json is null "
+ + "or c_geography is null or c_interval is null", standard), 0);
+ assertCount(connection, String.format(
+ "select count(*) from %s where extract(year from c_datetime) is null", standard), 0);
+ assertCount(connection, String.format(
+ "select count(*) from %s where st_x(c_geography) not between -180 and 180", standard), 0);
+ assertCount(connection, String.format(
+ "select count(*) from %s where st_y(c_geography) not between -90 and 90", standard), 0);
+ // JSON_TYPE errors on a non-JSON argument, so reaching a count at all proves the column
+ assertCount(connection, String.format(
+ "select count(*) from %s where json_type(c_json) <> 'object'", standard), 0);
}
}
diff --git a/bloviate-core/src/test/java/io/bloviate/db/TableTest.java b/bloviate-core/src/test/java/io/bloviate/db/TableTest.java
index c8f532f..98330c2 100644
--- a/bloviate-core/src/test/java/io/bloviate/db/TableTest.java
+++ b/bloviate-core/src/test/java/io/bloviate/db/TableTest.java
@@ -19,9 +19,12 @@
import org.junit.jupiter.api.Test;
import java.sql.JDBCType;
+import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class TableTest {
@@ -46,6 +49,61 @@ void noArgInsertStringStaysUnqualifiedWhenSchemaPresent() {
assertEquals("insert into orders (id) values (?)", table.insertString());
}
+ @Test
+ void insertStringSubstitutesValueExpressions() {
+ Table table = new Table("events", null,
+ List.of(column("id", null, false), column("payload", null, false)), List.of());
+
+ assertEquals("insert into `events` (`id`,`payload`) values (?,PARSE_JSON(?))",
+ table.insertString("`", List.of("?", "PARSE_JSON(?)")));
+ }
+
+ @Test
+ void insertStringWithNullExpressionsMatchesThePlainForm() {
+ // the overload is what the engine always calls now, so its no-expression behavior must be
+ // byte-identical to the form it replaced
+ Table table = new Table("orders", null,
+ List.of(column("id", "public", false), column("qty", null, false)), List.of());
+
+ assertEquals(table.insertString("\""), table.insertString("\"", null));
+ }
+
+ @Test
+ void insertStringRejectsAnExpressionWithoutExactlyOnePlaceholder() {
+ // one parameter is bound per column by position, so any other count shifts every later
+ // column's value onto the wrong parameter -- silent corruption rather than an error
+ Table table = new Table("events", null,
+ List.of(column("id", null, false), column("payload", null, false)), List.of());
+
+ for (String bad : List.of("PARSE_JSON('x')", "RANGE(?, ?)")) {
+ IllegalStateException e = assertThrows(IllegalStateException.class,
+ () -> table.insertString("`", List.of("?", bad)));
+ assertTrue(e.getMessage().contains("payload"), e.getMessage());
+ }
+
+ assertThrows(IllegalStateException.class,
+ () -> table.insertString("`", Arrays.asList("?", null)));
+ }
+
+ @Test
+ void insertStringRejectsAMismatchedExpressionCount() {
+ Table table = new Table("events", null,
+ List.of(column("id", null, false), column("payload", null, false)), List.of());
+
+ assertThrows(IllegalArgumentException.class, () -> table.insertString("`", List.of("?")));
+ }
+
+ @Test
+ void insertStringCountsExpressionsAgainstFilteredColumns() {
+ // auto-increment columns are excluded from the INSERT, so expressions align with the
+ // filtered list rather than every column
+ Table table = new Table("orders", null,
+ List.of(column("id", null, true), column("qty", null, false)), List.of());
+
+ assertEquals("insert into `orders` (`qty`) values (CAST(? AS INT64))",
+ table.insertString("`", List.of("CAST(? AS INT64)")));
+ }
+
@Test
void insertStringQuotesAndSchemaQualifiesIdentifiers() {
Table table = new Table("order", null,
diff --git a/bloviate-core/src/test/java/io/bloviate/ext/BigQuerySupportTest.java b/bloviate-core/src/test/java/io/bloviate/ext/BigQuerySupportTest.java
index 2a59e70..0bb2112 100644
--- a/bloviate-core/src/test/java/io/bloviate/ext/BigQuerySupportTest.java
+++ b/bloviate-core/src/test/java/io/bloviate/ext/BigQuerySupportTest.java
@@ -16,6 +16,7 @@
package io.bloviate.ext;
+import com.fasterxml.jackson.databind.ObjectMapper;
import io.bloviate.db.Column;
import io.bloviate.gen.BigDecimalGenerator;
import io.bloviate.gen.BooleanGenerator;
@@ -31,10 +32,12 @@
import java.math.BigDecimal;
import java.sql.JDBCType;
+import java.sql.Timestamp;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Random;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
@@ -189,14 +192,6 @@ void toleratesUnreportedNumericPrecisionAndScale() {
// ------------------------------------------------------------------------ rejected types
- @Test
- void rejectsDatetimeWhichIsIndistinguishableFromTimestampByJdbcTypeAlone() {
- UnsupportedOperationException e = rejectionFor(JDBCType.TIMESTAMP, "DATETIME");
-
- assertTrue(e.getMessage().contains("DATETIME"));
- assertTrue(e.getMessage().contains("ColumnConfiguration"));
- }
-
@Test
void rejectsTypesWithNoParameterBinding() {
Map rejected = new LinkedHashMap<>();
@@ -204,12 +199,75 @@ void rejectsTypesWithNoParameterBinding() {
rejected.put(JDBCType.ARRAY, "ARRAY");
rejected.put(JDBCType.STRUCT, "STRUCT");
- for (String typeName : new String[]{"JSON", "GEOGRAPHY", "INTERVAL"}) {
- assertRejected(JDBCType.VARCHAR, typeName);
- }
rejected.forEach(this::assertRejected);
}
+ // ------------------------------------------------------- server-constructed (Phase 2) types
+
+ @Test
+ void constructsJsonServerSide() {
+ // the driver binds text as STRING and BigQuery will not coerce that into JSON, so the
+ // value has to be built by the server
+ DataGenerator> generator = generatorFor(JDBCType.VARCHAR, null, "JSON");
+
+ assertEquals("PARSE_JSON(?)", generator.valueExpression());
+ assertDoesNotThrow(() -> new ObjectMapper().readTree((String) generator.generate()));
+ }
+
+ @Test
+ void constructsGeographyFromWellKnownText() {
+ DataGenerator> generator = generatorFor(JDBCType.VARCHAR, null, "GEOGRAPHY");
+
+ assertEquals("ST_GEOGFROMTEXT(?)", generator.valueExpression());
+
+ for (int i = 0; i < DRAWS; i++) {
+ String wkt = (String) generator.generate();
+ assertTrue(wkt.startsWith("POINT(") && wkt.endsWith(")"), wkt);
+
+ // WKT orders coordinates longitude-first, so the wider bound comes first
+ String[] coordinates = wkt.substring(6, wkt.length() - 1).split(" ");
+ assertEquals(2, coordinates.length, wkt);
+ assertTrue(Math.abs(Double.parseDouble(coordinates[0])) <= 180, wkt);
+ assertTrue(Math.abs(Double.parseDouble(coordinates[1])) <= 90, wkt);
+ }
+ }
+
+ @Test
+ void constructsIntervalServerSide() {
+ DataGenerator> generator = generatorFor(JDBCType.VARCHAR, null, "INTERVAL");
+
+ assertEquals("CAST(? AS INTERVAL)", generator.valueExpression());
+
+ // BigQuery's interval literal is Y-M D H:M:S, which IntervalGenerator already emits.
+ // Drawn once and reused, so a failure reports the value that actually failed rather than
+ // a fresh (possibly valid) one.
+ String interval = (String) generator.generate();
+ assertTrue(interval.matches("-?\\d+-\\d+ -?\\d+ \\d+:\\d+:\\d+"), interval);
+ }
+
+ @Test
+ void constructsDatetimeFromTheTimestampGenerator() {
+ // DATETIME and TIMESTAMP are indistinguishable by JDBC type, so the type name decides;
+ // both draw the same instant and only DATETIME is cast server-side
+ DataGenerator> datetime = generatorFor(JDBCType.TIMESTAMP, null, "DATETIME");
+ DataGenerator> timestamp = generatorFor(JDBCType.TIMESTAMP, null, "TIMESTAMP");
+
+ assertEquals("CAST(? AS DATETIME)", datetime.valueExpression());
+ assertEquals("?", timestamp.valueExpression());
+ assertInstanceOf(Timestamp.class, datetime.generate());
+ }
+
+ @Test
+ void leavesEveryOtherTypeBoundDirectly() {
+ // a wrapped tuple costs the driver's load-job path, so nothing should be wrapped that does
+ // not have to be
+ assertEquals("?", generatorFor(JDBCType.VARCHAR, 20, "STRING(20)").valueExpression());
+ assertEquals("?", generatorFor(JDBCType.VARBINARY, null, "BYTES").valueExpression());
+ assertEquals("?", generatorFor(JDBCType.NUMERIC, 38, 9, "NUMERIC").valueExpression());
+ assertEquals("?", generatorFor(JDBCType.BIGINT, null, "INT64").valueExpression());
+ assertEquals("?", generatorFor(JDBCType.DATE, null, "DATE").valueExpression());
+ }
+
private void assertRejected(JDBCType jdbcType, String typeName) {
UnsupportedOperationException e = rejectionFor(jdbcType, typeName);
diff --git a/bloviate-core/src/test/java/io/bloviate/gen/SqlExpressionGeneratorTest.java b/bloviate-core/src/test/java/io/bloviate/gen/SqlExpressionGeneratorTest.java
new file mode 100644
index 0000000..92ec78a
--- /dev/null
+++ b/bloviate-core/src/test/java/io/bloviate/gen/SqlExpressionGeneratorTest.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (c) 2021 Tim Veil
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.bloviate.gen;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SqlExpressionGeneratorTest {
+
+ private static DataGenerator wrapped(String expression) {
+ return SqlExpressionGenerator.of(new JsonbGenerator.Builder(new Random(1)).build(), expression);
+ }
+
+ @Test
+ void reportsTheExpressionAndDelegatesTheValue() {
+ DataGenerator wrapped = wrapped("PARSE_JSON(?)");
+
+ assertEquals("PARSE_JSON(?)", wrapped.valueExpression());
+ assertTrue(wrapped.generate().startsWith("{"));
+ }
+
+ @Test
+ void wrappingDoesNotChangeGeneratedValues() {
+ // same seed, one wrapped and one not: wrapping affects how the value reaches the column,
+ // never the value itself, so seed reproducibility must survive it
+ DataGenerator plain = new JsonbGenerator.Builder(new Random(42)).build();
+ DataGenerator wrapped = SqlExpressionGenerator.of(
+ new JsonbGenerator.Builder(new Random(42)).build(), "PARSE_JSON(?)");
+
+ for (int i = 0; i < 20; i++) {
+ assertEquals(plain.generate(), wrapped.generate());
+ }
+ }
+
+ @Test
+ void rejectsAnExpressionThatWouldMisalignParameters() {
+ // the engine binds one parameter per column by position, so anything other than exactly one
+ // placeholder shifts every later column's value onto the wrong parameter
+ assertThrows(IllegalArgumentException.class, () -> wrapped("PARSE_JSON('x')"));
+ assertThrows(IllegalArgumentException.class, () -> wrapped("RANGE(?, ?)"));
+ assertThrows(IllegalArgumentException.class, () -> wrapped(null));
+ assertThrows(IllegalArgumentException.class,
+ () -> SqlExpressionGenerator.of(null, "PARSE_JSON(?)"));
+ }
+
+ @Test
+ void delegatesPositionable() {
+ // answering for the delegate would either skip repositioning a positionable column or
+ // reposition one that cannot survive it
+ assertTrue(wrapped("PARSE_JSON(?)").positionable());
+ assertFalse(SqlExpressionGenerator.of(new NotPositionable(), "CAST(? AS DATETIME)").positionable());
+ }
+
+ @Test
+ void delegatesReseed() {
+ DataGenerator wrapped = wrapped("PARSE_JSON(?)");
+ String first = wrapped.generate();
+
+ wrapped.reseed(99);
+ String afterReseed = wrapped.generate();
+ wrapped.reseed(99);
+
+ assertNotEquals(first, afterReseed);
+ assertEquals(afterReseed, wrapped.generate());
+ }
+
+ // ------------------------------------------------------------------ IndexedDataGenerator
+
+ @Test
+ void keepsAnIndexedDelegateSeekable() {
+ // TableFiller tests `instanceof IndexedDataGenerator` to decide how to position a
+ // partitioned fill, so dropping the interface here would silently replay draws instead
+ DataGenerator wrapped = SqlExpressionGenerator.of(new Counter(), "CAST(? AS INT64)");
+
+ assertInstanceOf(IndexedDataGenerator.class, wrapped);
+
+ ((IndexedDataGenerator) wrapped).seek(7);
+ assertEquals(7, wrapped.generate());
+ assertEquals(8, wrapped.generate());
+ }
+
+ @Test
+ void doesNotClaimSeekabilityForAPlainDelegate() {
+ // the mirror-image failure: claiming the interface without a seekable delegate makes the
+ // engine skip the repositioning that column actually needs
+ assertFalse(SqlExpressionGenerator.of(new NotPositionable(), "CAST(? AS DATETIME)")
+ instanceof IndexedDataGenerator);
+ }
+
+ @Test
+ void aSeekedIndexedDelegateMatchesTheSequentialRun() {
+ // the property partitioned fills depend on: seeking to row N produces exactly what the
+ // sequential run produces at row N
+ DataGenerator sequential = SqlExpressionGenerator.of(new Counter(), "CAST(? AS INT64)");
+ List expected = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ expected.add(sequential.generate());
+ }
+
+ DataGenerator partitioned = SqlExpressionGenerator.of(new Counter(), "CAST(? AS INT64)");
+ ((IndexedDataGenerator) partitioned).seek(5);
+
+ for (int i = 5; i < 10; i++) {
+ assertEquals(expected.get(i), partitioned.generate());
+ }
+ }
+
+ /** A minimal indexed generator: its value is its absolute row index. */
+ private static final class Counter extends AbstractDataGenerator implements IndexedDataGenerator {
+
+ private long row;
+
+ private Counter() {
+ super(new Random(1));
+ }
+
+ @Override
+ public Integer generate() {
+ return (int) row++;
+ }
+
+ @Override
+ public void seek(long rowIndex) {
+ this.row = rowIndex;
+ }
+
+ @Override
+ public Integer get(java.sql.ResultSet resultSet, int columnIndex) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ /** A generator that opts out of positioning, as the datafaker integration does. */
+ private static final class NotPositionable extends AbstractDataGenerator {
+
+ private NotPositionable() {
+ super(new Random(1));
+ }
+
+ @Override
+ public boolean positionable() {
+ return false;
+ }
+
+ @Override
+ public String generate() {
+ return "x";
+ }
+
+ @Override
+ public String get(java.sql.ResultSet resultSet, int columnIndex) {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/bloviate-core/src/test/resources/create_tables.bigquery.sql b/bloviate-core/src/test/resources/create_tables.bigquery.sql
index e22d8b9..c177fd1 100644
--- a/bloviate-core/src/test/resources/create_tables.bigquery.sql
+++ b/bloviate-core/src/test/resources/create_tables.bigquery.sql
@@ -4,9 +4,11 @@
-- concurrent runs never collide). Every table carries a 2-hour expiration so a failed run cannot
-- leave billable tables behind.
--
--- Only natively bindable types appear here. DatabaseFiller fills every table in the dataset, so a
--- single DATETIME/JSON/GEOGRAPHY/ARRAY/STRUCT column would fail the whole fill; rejection of those
--- is covered by BigQuerySupportTest instead.
+-- Every scalar type appears here, including the four the driver cannot bind directly
+-- (DATETIME, JSON, GEOGRAPHY, INTERVAL), which are constructed server-side from generated text.
+-- The composite types (ARRAY, STRUCT, RANGE) are deliberately absent: DatabaseFiller fills every
+-- table in the dataset, so one unsupported column would fail the whole fill. Their rejection is
+-- covered by BigQuerySupportTest instead.
--
-- Keys are declared NOT ENFORCED, which is the only form BigQuery accepts. They are never enforced
-- at write time, but the driver surfaces them through getPrimaryKeys/getImportedKeys, which is what
@@ -43,7 +45,13 @@ CREATE TABLE `${dataset}.standard_types_${suffix}` (
c_bytes_sized BYTES(16),
c_date DATE,
c_time TIME,
- c_timestamp TIMESTAMP
+ c_timestamp TIMESTAMP,
+ -- constructed server-side: none of these has a parameter binding, and BigQuery will not
+ -- coerce a STRING or TIMESTAMP parameter into them
+ c_datetime DATETIME,
+ c_json JSON,
+ c_geography GEOGRAPHY,
+ c_interval INTERVAL
) OPTIONS(expiration_timestamp = TIMESTAMP_ADD(CURRENT_TIMESTAMP(), INTERVAL 2 HOUR));
-- Any real BigQuery table is partitioned and clustered; this proves the batch/DML path works
diff --git a/docs/DATABASE_SUPPORT.md b/docs/DATABASE_SUPPORT.md
index 9f6e74b..48a8617 100644
--- a/docs/DATABASE_SUPPORT.md
+++ b/docs/DATABASE_SUPPORT.md
@@ -12,7 +12,7 @@ explicitly, or let Bloviate detect it from the connection.
| MariaDB | `MariaDBSupport` | Extends `MySQLSupport` (MariaDB speaks the MySQL wire protocol) |
| H2 | `H2Support` | Standard JDBC types **plus** `UUID` and `JSON` (embedded; no Docker) |
| SQLite | `SQLiteSupport` | Standard JDBC types via type affinity (embedded; no Docker) |
-| BigQuery | `BigQuerySupport` | Scalar types only — see [BigQuery](#bigquery) for what is excluded |
+| BigQuery | `BigQuerySupport` | All scalar types **incl.** `JSON`, `GEOGRAPHY`, `INTERVAL`, `DATETIME`; no composites |
| Generic JDBC | `DefaultSupport` | Standard JDBC types only |
All of them resolve the cross-database defaults for the common JDBC types (integers, decimals,
@@ -49,28 +49,63 @@ graph regardless.
Requires the [tbc-bq-jdbc](https://github.com/Two-Bear-Capital/tbc-bq-jdbc) driver
(`vc.tbc:tbc-bq-jdbc`, 4.3.0 or later), which is not yet on Maven Central — install it locally with
-`./mvnw clean install` in that repo, or use its GitHub Releases jar.
+`./mvnw clean install` in that repo, or use its GitHub Releases jar. Version 4.4.0 is strongly
+recommended once it is released, for the reason given under [server-constructed
+values](#server-constructed-values).
```java
String url = "jdbc:bigquery:my-project/my_dataset?authType=ADC";
```
-**Supported:** `STRING`, `BYTES`, `INT64`, `FLOAT64`, `NUMERIC`, `BIGNUMERIC`, `BOOL`, `DATE`,
-`TIME`, `TIMESTAMP`.
+**Supported:** every scalar type — `STRING`, `BYTES`, `INT64`, `FLOAT64`, `NUMERIC`, `BIGNUMERIC`,
+`BOOL`, `DATE`, `TIME`, `TIMESTAMP`, `DATETIME`, `JSON`, `GEOGRAPHY`, `INTERVAL`.
-**Not supported:** `DATETIME`, `JSON`, `GEOGRAPHY`, `INTERVAL`, `RANGE`, `ARRAY`, `STRUCT`. These
-fail fast with a message naming the column, before any rows are written.
-
-The reason is that BigQuery's *write* surface is narrower than its read surface. The driver reads
-`JSON`/`GEOGRAPHY`/`INTERVAL` back as `VARCHAR` and `DATETIME` as `TIMESTAMP`, but has no parameter
-binding that produces those types, and BigQuery will not implicitly coerce a `STRING` or `TIMESTAMP`
-parameter into them. Filling them requires SQL-side construction (`PARSE_JSON(?)`,
-`ST_GEOGFROMTEXT(?)`, `CAST(? AS DATETIME)`), which the fill engine does not yet emit.
+**Not supported:** the composite types — `ARRAY`, `STRUCT`, `RANGE`. These fail fast with a message
+naming the column, before any rows are written. `ARRAY` and `STRUCT` need a shape Bloviate has no
+representation for, and `RANGE` would need two parameters for one column, which the engine's
+one-parameter-per-column binding cannot express.
To fill one anyway, supply a per-column generator through `ColumnConfiguration` or
`GeneratorRegistry.registerColumnNamePattern`. The driver does implement `Connection.createArrayOf`
and `Connection.createStruct`, so a hand-written generator can write composites today.
+### Server-constructed values
+
+BigQuery's *write* surface is narrower than its read surface. The driver reads `JSON`, `GEOGRAPHY`
+and `INTERVAL` back as `VARCHAR` and `DATETIME` as `TIMESTAMP`, but has no parameter binding that
+produces any of them, and BigQuery will not implicitly coerce a `STRING` or `TIMESTAMP` parameter
+into them.
+
+Those four are therefore generated as text and turned into the column's type by the server:
+
+| Type | Generated as | Written as |
+|------|--------------|------------|
+| `JSON` | a JSON object literal | `PARSE_JSON(?)` |
+| `GEOGRAPHY` | a WKT point, `POINT(lon lat)` | `ST_GEOGFROMTEXT(?)` |
+| `INTERVAL` | `Y-M D H:M:S` | `CAST(? AS INTERVAL)` |
+| `DATETIME` | a timestamp | `CAST(? AS DATETIME)` (interpreted as UTC) |
+
+This is why 4.4.0 matters. It is not required — these columns fill correctly on 4.3.0 — but earlier
+versions collapse a JDBC batch into a multi-row `INSERT` only when the `VALUES` tuple is
+placeholders-only, so a table with any of these columns falls back to **one query job per row**.
+That is correct, but slow enough to matter and expensive on a large fill.
+
+At the time of writing 4.4.0 is merged but unreleased, so `tbc-bq-jdbc.version` still pins 4.3.0.
+
+Two further consequences:
+
+- A table containing one of these columns never takes the driver's NDJSON load-job path, even above
+ `batchLoadThreshold`. That path writes bound values directly and never sees the SQL, so it would
+ drop the wrapping and store the wrong thing; the driver keeps such batches on DML deliberately.
+- The mechanism is general, not BigQuery-specific: any generator can declare its own
+ `DataGenerator.valueExpression()`, and `SqlExpressionGenerator` wraps an existing generator
+ without subclassing it. A custom PostGIS generator can use the same seam.
+
+`BIGNUMERIC` is *not* in that table. It has no parameter binding either, but unlike the four above
+it does not need one: the driver binds every `BigDecimal` as `NUMERIC`, and a `NUMERIC`-range value
+is always valid in a `BIGNUMERIC` column. Values are therefore clamped rather than constructed —
+see below.
+
> `GeneratorRegistry.registerTypeName` is a poor fit here: it matches type names exactly, and
> BigQuery reports the raw `INFORMATION_SCHEMA` text (`string(20)`, `numeric(10, 2)`,
> `array`), so only unparameterized names ever match.
@@ -97,6 +132,11 @@ binds every `BigDecimal` as `NUMERIC`, so generated values are clamped to `NUMER
binding is what forces this, not the destination column — a `NUMERIC`-range value is always valid in
a `BIGNUMERIC` column.
+That clamp is a deliberate limit rather than a gap to close. `BigDecimalGenerator` already caps
+itself at 25 significant digits on every database, on the grounds that enormous precision is not
+useful test data (CockroachDB reports 131,089), so generating true 76-digit `BIGNUMERIC` values
+would contradict that. If you need them, supply a per-column generator.
+
### Required driver settings
Both are the driver's defaults; overriding either breaks the fill.
@@ -132,6 +172,11 @@ auto-commit to stay on, which is why `BigQuerySupport` opts out of engine-manage
`CommitStrategy` at its default** — each `executeBatch` is already one atomic job, so an explicit
strategy buys nothing and costs the load path. Bloviate logs a warning if you set one anyway.
+The load path is also unavailable to any table holding a `JSON`, `GEOGRAPHY`, `INTERVAL` or
+`DATETIME` column, for the reason given under [server-constructed
+values](#server-constructed-values). Such tables still collapse into multi-row `INSERT` statements
+on driver 4.4.0 and later; they simply stay on DML.
+
> Filling a BigQuery dataset writes real data to real storage and runs real jobs. Both cost money.
> **PostgreSQL connection requirement:** the vendor types above are bound as their text
diff --git a/pom.xml b/pom.xml
index fd22d21..2767707 100644
--- a/pom.xml
+++ b/pom.xml
@@ -92,6 +92,12 @@
green without it. To run the BigQuery integration test, either `./mvnw clean install` the
driver repo or drop its GitHub Releases jar into ~/.m2. Dependabot and versions:display
cannot resolve this coordinate; bump it by hand.
+
+ 4.3.0 is the functional floor. 4.4.0 collapses a batch whose VALUES tuple wraps a
+ placeholder (PARSE_JSON(?), CAST(? AS DATETIME)), which BigQuerySupport emits for JSON,
+ GEOGRAPHY, INTERVAL and DATETIME columns; without it those tables still fill correctly,
+ just one query job per row. 4.4.0 is merged but unreleased, so this stays at 4.3.0;
+ bump it once 4.4.0 ships.
-->
4.3.0