feat(gen): construct values in SQL when a driver cannot bind the type - #594
Merged
Conversation
Adds DataGenerator.valueExpression(), the SQL a generated value occupies
inside an INSERT VALUES tuple. It defaults to "?" -- so every existing
generator and every emitted statement is unchanged -- and a generator
overrides it when its value has to be built by the server rather than
bound.
This completes BigQuery's scalar coverage. 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
coerce a STRING or TIMESTAMP parameter into them, so they were rejected
outright. They are now generated as text and constructed server-side:
JSON PARSE_JSON(?) JsonbGenerator
GEOGRAPHY ST_GEOGFROMTEXT(?) WktPointGenerator (new)
INTERVAL CAST(? AS INTERVAL) IntervalGenerator
DATETIME CAST(? AS DATETIME) the inherited timestamp generator
Only GEOGRAPHY needed a new generator; the rest already emitted the right
text for a database that could accept it.
The seam lives on the generator rather than on DatabaseSupport because
the generator is what knows the shape of the text it produces, and a
generator supplied through GeneratorRegistry or a GeneratorPlugin then
carries its own wrapping with no support involvement. SqlExpressionGenerator
wraps an existing generator so a shared one (JsonbGenerator, used by
PostgreSQL too) gains BigQuery's PARSE_JSON without acquiring a
BigQuery-specific opinion.
Two things that had to be right:
- An expression must contain exactly one '?'. The engine binds one
parameter per column by position, so any other count shifts every later
column onto the wrong parameter -- silent corruption rather than an
error. Both Table.insertString and SqlExpressionGenerator reject it.
- SqlExpressionGenerator.of() returns a variant implementing
IndexedDataGenerator exactly when its delegate does, because TableFiller
tests for that interface to position a partitioned fill. Claiming it
without a seekable delegate skips repositioning; dropping it from one
replays draws instead of seeking. positionable() is likewise delegated.
TableFiller now builds its INSERT after resolving generators rather than
before, since the generators decide the statement's value slots.
Requires tbc-bq-jdbc 4.4.0, which collapses a batch whose VALUES tuple
wraps a placeholder; on 4.3.0 such tables fill correctly but at one query
job per row. A wrapped tuple also never takes the driver's NDJSON
load-job path, which writes bound values directly and would drop the
wrapping.
BIGNUMERIC is deliberately left clamped to NUMERIC's (38, 9) rather than
constructed: BigDecimalGenerator already caps every database at 25
significant digits on the grounds that enormous precision is not useful
test data, and generating true 76-digit values would contradict that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a generator-level “value slot” seam (DataGenerator.valueExpression()) so Bloviate can emit INSERT ... VALUES tuples that construct certain values server-side when a JDBC driver cannot bind the target type (notably for BigQuery’s JSON, GEOGRAPHY, INTERVAL, DATETIME). This extends BigQuerySupport to cover all scalar types while keeping the default behavior unchanged ("?" everywhere unless overridden/wrapped).
Changes:
- Introduce
DataGenerator.valueExpression()andSqlExpressionGeneratorfor expression-wrapped placeholders with strict single-?validation. - Update table insert SQL generation and
TableFillerto buildINSERTstatements after generator resolution, using per-column value expressions. - Extend BigQuery scalar support using server-constructed expressions, add
WktPointGenerator, and expand tests/docs/schema accordingly.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| pom.xml | Documents the BigQuery driver “floor” and why the pinned version trails it. |
| docs/DATABASE_SUPPORT.md | Updates BigQuery support docs to include server-constructed scalar types and explains performance implications. |
| bloviate-core/src/test/resources/create_tables.bigquery.sql | Expands BigQuery integration schema to include DATETIME/JSON/GEOGRAPHY/INTERVAL. |
| bloviate-core/src/test/java/io/bloviate/gen/SqlExpressionGeneratorTest.java | Adds unit coverage for expression wrapping, placeholder validation, and IndexedDataGenerator behavior. |
| bloviate-core/src/test/java/io/bloviate/ext/BigQuerySupportTest.java | Updates BigQuery type coverage expectations and validates new server-constructed expressions. |
| bloviate-core/src/test/java/io/bloviate/db/TableTest.java | Adds tests for expression substitution, placeholder-count validation, and filtered-column alignment. |
| bloviate-core/src/test/java/io/bloviate/db/BigQueryFillerTest.java | Verifies server-constructed types land as real BigQuery types (not strings) in the live test path. |
| bloviate-core/src/main/java/io/bloviate/gen/WktPointGenerator.java | Adds WKT point generator for GEOGRAPHY text generation. |
| bloviate-core/src/main/java/io/bloviate/gen/SqlExpressionGenerator.java | Adds generator wrapper that changes only SQL value expression while preserving delegate behavior (incl. IndexedDataGenerator when applicable). |
| bloviate-core/src/main/java/io/bloviate/gen/DataGenerator.java | Adds the valueExpression() extension point with documentation and constraints. |
| bloviate-core/src/main/java/io/bloviate/ext/BigQuerySupport.java | Implements server-constructed scalar handling via VALUE_EXPRESSIONS and generator wrapping. |
| bloviate-core/src/main/java/io/bloviate/db/TableFiller.java | Builds INSERT SQL after resolving generators to incorporate per-column value expressions. |
| bloviate-core/src/main/java/io/bloviate/db/Table.java | Adds insertString(identifierQuote, valueExpressions) with validation and keeps existing overload behavior. |
Suppressed comments (2)
docs/DATABASE_SUPPORT.md:90
- This paragraph frames 4.4.0 as a hard "floor", but the PR description notes older driver versions still fill correctly (just with the per-row job fallback). To avoid overstating the requirement, consider wording this as a performance recommendation rather than a correctness minimum.
This is why the driver floor is 4.4.0. 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
would silently fall back to **one query job per row** — correct, but slow enough to matter and
expensive on a large fill.
bloviate-core/src/main/java/io/bloviate/ext/BigQuerySupport.java:59
- This Javadoc sentence uses "This requires tbc-bq-jdbc 4.4.0 or later" even though (per the PR description) earlier versions still behave correctly but fall back to one job per row when the VALUES tuple isn't placeholders-only. Consider rephrasing this as a performance recommendation to avoid implying a hard runtime requirement.
* — see {@link #VALUE_EXPRESSIONS}. This requires tbc-bq-jdbc <strong>4.4.0 or
* later</strong>: earlier versions only collapse a batch whose {@code VALUES} tuple is
* placeholders-only, so a wrapped column silently degrades to one query job per row.</li>
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Review follow-up. Requiring "4.4.0 or later" was wrong twice over: 4.4.0 is merged but not released, so the docs pointed at a Maven artifact that does not exist, and nothing enforces the version because nothing needs to -- JSON, GEOGRAPHY, INTERVAL and DATETIME columns fill correctly on 4.3.0. What 4.4.0 buys is the batch collapse for a wrapped VALUES tuple, without which such tables fall back to one query job per row. So: 4.3.0 is the functional floor, 4.4.0 is strongly recommended once released, and the docs now say which is which and note that the pin deliberately trails. Also draw the interval value once in its test rather than twice, so a failure reports the value that actually failed instead of a fresh one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
timveil
pushed a commit
that referenced
this pull request
Aug 3, 2026
## [3.2.0](v3.1.0...v3.2.0) (2026-08-03) ### ✨ Features * **gen:** construct values in SQL when a driver cannot bind the type ([#594](#594)) ([60a1681](60a1681))
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds
DataGenerator.valueExpression()— the SQL a generated value occupies inside anINSERT ... VALUEStuple. It defaults to"?", so every existing generator and every emitted statement is unchanged, and a generator overrides it when its value has to be built by the server rather than bound.This completes BigQuery's scalar coverage (follow-up to #593). The driver reads
JSON,GEOGRAPHYandINTERVALback asVARCHARandDATETIMEasTIMESTAMP, but has no parameter binding that produces any of them, and BigQuery will not coerce aSTRINGorTIMESTAMPparameter into them — so v3.1.0 rejected all four outright. They now fill:JSONPARSE_JSON(?)JsonbGeneratorGEOGRAPHYST_GEOGFROMTEXT(?)WktPointGeneratorINTERVALCAST(? AS INTERVAL)IntervalGeneratorDATETIMECAST(? AS DATETIME)Only
GEOGRAPHYneeded a new generator —IntervalGeneratoralready emits exactly BigQuery'sY-M D H:M:Sliteral shape.Why the seam is on the generator
The generator is what knows the shape of the text it produces, so it is what should declare how that text reaches the column. It also means a generator supplied through
GeneratorRegistryor aGeneratorPlugincarries its own wrapping with noDatabaseSupportinvolvement — a custom PostGIS generator can use the same seam.SqlExpressionGeneratorwraps an existing generator, so a shared one (JsonbGenerator, used by PostgreSQL too) gains BigQuery'sPARSE_JSONwithout acquiring a BigQuery-specific opinion that would then apply everywhere.Two things that had to be right
An expression must contain exactly one
?. The engine binds one parameter per column by position, so any other count shifts every later column onto the wrong parameter — silent corruption rather than an error. BothTable.insertStringandSqlExpressionGeneratorreject it, with the column named.SqlExpressionGenerator.of()returns a variant implementingIndexedDataGeneratorexactly when its delegate does.TableFillertests for that interface to position a partitioned fill, so a single wrapper class would be wrong in one direction or the other: claiming the interface without a seekable delegate skips repositioning, and dropping it from a seekable one replays draws instead of seeking.positionable()is likewise delegated rather than answered for. Tests cover both directions plus a seek-matches-sequential-run check.TableFillernow builds itsINSERTafter resolving generators rather than before, since the generators decide the statement's value slots.Driver floor
Requires tbc-bq-jdbc 4.4.0 (Two-Bear-Capital/tbc-bq-jdbc#302), which collapses a batch whose
VALUEStuple wraps a placeholder. On 4.3.0 these tables still fill correctly, but at one query job per row — slow enough to matter and expensive on a large fill. A wrapped tuple also never takes the driver's NDJSON load-job path, which writes bound values directly and would drop the wrapping.The
tbc-bq-jdbc.versionpin deliberately trails at 4.3.0:release/4.4.0is not tagged yet and its POM still reads 4.3.0, so there is no 4.4.0 artifact to pin. The POM carries a note to bump it.Related Issues
Follows #593 / #569. Depends on Two-Bear-Capital/tbc-bq-jdbc#302 (merged, unreleased).
Type of Change
feat— new feature (minor)Affected Module(s)
bloviate-coreHow Has This Been Tested?
./mvnw verifygreen across the reactor: 316 core tests, up from 299.The default
"?"is what makes this safe:SeedGoldenDumpTestpins H2 output byte-for-byte and needed no regeneration, and every container-backed fill test passes unchanged.New coverage:
SqlExpressionGeneratorTest(bothIndexedDataGeneratordirections, seek-matches-sequential,positionable()delegation, reseed delegation, placeholder-count rejection),TableTest(expression substitution, null-expressions equivalence with the plain form, mismatched count, alignment against filtered columns), andBigQuerySupportTest(each expression, generated WKT within longitude/latitude bounds, parseable JSON, interval shape, and that nothing else got wrapped).Checklist
./mvnw verifypasses locally (tests included)Additional Notes
BIGNUMERICis deliberately left clamped toNUMERIC's (38, 9) rather than constructed viaCAST(? AS BIGNUMERIC). It has no parameter binding either, but unlike the four above it does not need one — aNUMERIC-range value is always valid in aBIGNUMERICcolumn, so it already fills correctly today. Reaching its true 76-digit range would mean a new generator, andBigDecimalGeneratoralready caps every database at 25 significant digits on the grounds that enormous precision is not useful test data (CockroachDB reports 131,089). Generating 76-digit values would contradict that. Documented as a deliberate limit with theColumnConfigurationescape hatch rather than left unexplained.🤖 Generated with Claude Code