Skip to content

feat(gen): construct values in SQL when a driver cannot bind the type - #594

Merged
timveil merged 2 commits into
mainfrom
feat/569-bigquery-value-expressions
Aug 3, 2026
Merged

feat(gen): construct values in SQL when a driver cannot bind the type#594
timveil merged 2 commits into
mainfrom
feat/569-bigquery-value-expressions

Conversation

@timveil

@timveil timveil commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Description

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 (follow-up to #593). 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 v3.1.0 rejected all four outright. They now fill:

Type Written as Generator
JSON PARSE_JSON(?) existing JsonbGenerator
GEOGRAPHY ST_GEOGFROMTEXT(?) new WktPointGenerator
INTERVAL CAST(? AS INTERVAL) existing IntervalGenerator
DATETIME CAST(? AS DATETIME) the inherited timestamp generator

Only GEOGRAPHY needed a new generator — IntervalGenerator already emits exactly BigQuery's Y-M D H:M:S literal 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 GeneratorRegistry or a GeneratorPlugin carries its own wrapping with no DatabaseSupport involvement — a custom PostGIS generator can use the same seam. 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 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. Both Table.insertString and SqlExpressionGenerator reject it, with the column named.

SqlExpressionGenerator.of() returns a variant implementing IndexedDataGenerator exactly when its delegate does. TableFiller tests 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.

TableFiller now builds its INSERT after 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 VALUES tuple 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.version pin deliberately trails at 4.3.0: release/4.4.0 is 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-core
  • Build / CI / tooling

How Has This Been Tested?

./mvnw verify green across the reactor: 316 core tests, up from 299.

The default "?" is what makes this safe: SeedGoldenDumpTest pins H2 output byte-for-byte and needed no regeneration, and every container-backed fill test passes unchanged.

New coverage: SqlExpressionGeneratorTest (both IndexedDataGenerator directions, 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), and BigQuerySupportTest (each expression, generated WKT within longitude/latitude bounds, parseable JSON, interval shape, and that nothing else got wrapped).

⚠️ Still never run against live BigQuery. I extended create_tables.bigquery.sql and the verifier to cover all four types (ST_X/ST_Y bounds, JSON_TYPE, EXTRACT from the datetime), but that assertion code has never executed — same gap as #593.

Checklist

  • My PR title follows the Conventional Commits format
  • ./mvnw verify passes locally (tests included)
  • I have added or updated tests covering my changes
  • I have updated documentation as needed
  • My changes follow the existing code style and conventions

Additional Notes

BIGNUMERIC is deliberately left clamped to NUMERIC's (38, 9) rather than constructed via CAST(? AS BIGNUMERIC). It has no parameter binding either, but unlike the four above it does not need one — a NUMERIC-range value is always valid in a BIGNUMERIC column, so it already fills correctly today. Reaching its true 76-digit range would mean a new generator, and BigDecimalGenerator already 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 the ColumnConfiguration escape hatch rather than left unexplained.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings August 3, 2026 20:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() and SqlExpressionGenerator for expression-wrapped placeholders with strict single-? validation.
  • Update table insert SQL generation and TableFiller to build INSERT statements 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.
 *       &mdash; 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.

Comment thread docs/DATABASE_SUPPORT.md Outdated
Comment thread bloviate-core/src/main/java/io/bloviate/ext/BigQuerySupport.java Outdated
Comment thread bloviate-core/src/test/java/io/bloviate/ext/BigQuerySupportTest.java Outdated
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
timveil merged commit 60a1681 into main Aug 3, 2026
10 checks passed
@timveil
timveil deleted the feat/569-bigquery-value-expressions branch August 3, 2026 22:02
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))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants