Skip to content

Commit 4a9a703

Browse files
ryannedolanCopilot
andcommitted
Add validate (dry-run) mode to JDBC connection string
Introduce `mode=validate`, a dry-run flavor alongside `mode=apply`. Every DDL statement (CREATE, DROP, FIRE/PAUSE/RESUME) is fully parsed, planned, and validated — including deployer-level validation — but no real object is created, updated, or deleted. A dry-run still evolves the in-memory Calcite schema so a series of statements validates against each other: e.g. a dry-run DROP VIEW followed by a query against that view fails validation, even though no real View was deleted. To make this explicit, DdlMode's old `mutable()` is split into two axes: `executeDeployers` (the real side effect) and `persistsSchema` (whether in-memory changes are kept). VALIDATE persists schema but deploys nothing; SPECIFY (the `!specify` renderer) restores. With respect to OR REPLACE, validate is apply-like and never errors on an existing object. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2f9d82d commit 4a9a703

7 files changed

Lines changed: 441 additions & 78 deletions

File tree

docs/user-guide/ddl-reference.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,34 @@ remains imperative and explicit. Detection of *incompatible* metadata changes
5050
`CREATE` and `CREATE OR REPLACE` apply the new definition regardless. Use
5151
caution when changing schemas of in-flight pipelines.
5252

53+
## Dry-run: validate mode
54+
55+
`mode=validate` is a dry-run. Every statement — `CREATE`, `DROP`, and the
56+
trigger verbs (`FIRE`/`PAUSE`/`RESUME`) — is fully parsed, planned, and
57+
validated (including deployer-level validation), but **no real object is ever
58+
created, updated, or deleted.** A statement that would succeed reports
59+
`(0 rows modified)`; a statement that would fail validation raises the same
60+
error a real run would. Use it to check a script against a live environment
61+
without touching anything:
62+
63+
```
64+
jdbc:hoptimator://...;mode=validate
65+
```
66+
67+
With respect to `OR REPLACE`, `validate` behaves like `apply`: an
68+
already-existing resource is treated as an in-place update, so a plain
69+
`CREATE` over an existing object validates cleanly instead of erroring.
70+
71+
Crucially, a dry-run still evolves the **in-memory** schema as it goes, so a
72+
series of statements is validated against each other: a dry-run `DROP VIEW`
73+
followed by a query against that view fails validation, even though no real
74+
`View` object was deleted. Only the deployment is skipped, never the
75+
in-memory bookkeeping. (This is what distinguishes `validate` from the
76+
one-shot `!specify` preview, which renders a single statement's YAML and then
77+
restores the schema.) `validate`, `apply`, and `create` are mutually
78+
exclusive; pick one per connection.
79+
80+
5381
## CREATE VIEW
5482

5583
```

hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlExecutor.java

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,11 @@ public void execute(SqlCreateView create, CalcitePrepare.Context context) {
130130
throw new DdlException(create,
131131
"View " + pair.right + " already exists. Use CREATE OR REPLACE to update.");
132132
}
133-
pair.left.removeFunction(pair.right);
133+
// Apply the in-memory schema change whenever the mode persists it (CREATE/UPDATE deploy
134+
// for real; VALIDATE keeps it in-memory only). SPECIFY render is not reached here.
135+
if (mode.persistsSchema()) {
136+
pair.left.removeFunction(pair.right);
137+
}
134138
}
135139
}
136140

@@ -152,14 +156,20 @@ public void execute(SqlCreateView create, CalcitePrepare.Context context) {
152156
deployers = DeploymentService.deployers(view, connection);
153157
ValidationService.validateOrThrow(deployers, connection);
154158
logger.info("Validated view {}", viewName);
159+
// CREATE→create, UPDATE→update, VALIDATE (dry-run)→deploy nothing. In every case the
160+
// in-memory view is registered (VALIDATE persists it without deploying), so a later
161+
// statement in the same script sees it.
155162
if (mode == HoptimatorDdlUtils.DdlMode.UPDATE) {
156163
logger.info("Deploying update view {}", viewName);
157-
DeploymentService.update(deployers);
158-
} else {
164+
} else if (mode == HoptimatorDdlUtils.DdlMode.CREATE) {
159165
logger.info("Deploying create view {}", viewName);
160-
DeploymentService.create(deployers);
166+
} else {
167+
logger.info("Validating (dry-run) view {}; skipping deployment", viewName);
168+
}
169+
mode.executeDeployers(deployers, connection);
170+
if (mode != HoptimatorDdlUtils.DdlMode.VALIDATE) {
171+
logger.info("Deployed view {}", viewName);
161172
}
162-
logger.info("Deployed view {}", viewName);
163173
schemaPlus.add(viewName, viewTable);
164174
logger.info("Added view {} to schema {}", viewName, schemaPlus.getName());
165175
} catch (SQLException | RuntimeException e) {
@@ -243,12 +253,17 @@ public void execute(SqlCreateTrigger create, CalcitePrepare.Context context) {
243253
HoptimatorDdlUtils.DdlMode mode = HoptimatorDdlUtils.effectiveMode(create.getReplace(), connection);
244254
if (mode == HoptimatorDdlUtils.DdlMode.UPDATE) {
245255
logger.info("Updating trigger {}", name);
246-
DeploymentService.update(deployers);
247-
} else {
256+
} else if (mode == HoptimatorDdlUtils.DdlMode.CREATE) {
248257
logger.info("Creating trigger {}", name);
249-
DeploymentService.create(deployers);
258+
} else {
259+
logger.info("Validating (dry-run) trigger {}; skipping deployment", name);
260+
}
261+
// CREATE→create, UPDATE→update, VALIDATE (dry-run)→no-op. Triggers are not part of the
262+
// Calcite schema, so there is no in-memory state to persist.
263+
mode.executeDeployers(deployers, connection);
264+
if (mode != HoptimatorDdlUtils.DdlMode.VALIDATE) {
265+
logger.info("Deployed trigger {}", name);
250266
}
251-
logger.info("Deployed trigger {}", name);
252267
logger.info("CREATE TRIGGER {} completed", name);
253268
} catch (Exception e) {
254269
if (deployers != null) {
@@ -336,7 +351,11 @@ public void execute(SqlFireTrigger fire, CalcitePrepare.Context context) {
336351
try {
337352
logger.info("Firing trigger {} with {} option(s)", name, options.size() - 1);
338353
deployers = DeploymentService.deployers(trigger, connection);
339-
DeploymentService.update(deployers);
354+
if (HoptimatorDdlUtils.isValidateMode(connection)) {
355+
logger.info("Validated FIRE TRIGGER (dry-run) {}; skipping deployment", name);
356+
} else {
357+
DeploymentService.update(deployers);
358+
}
340359
logger.info("FIRE TRIGGER {} completed", name);
341360
} catch (Exception e) {
342361
if (deployers != null) {
@@ -366,8 +385,12 @@ public void execute(SqlDropTrigger drop, CalcitePrepare.Context context) {
366385
try {
367386
logger.info("Deleting trigger {}", name);
368387
deployers = DeploymentService.deployers(trigger, connection);
369-
DeploymentService.delete(deployers);
370-
logger.info("Deleted trigger {}", name);
388+
if (HoptimatorDdlUtils.isValidateMode(connection)) {
389+
logger.info("Validated DROP TRIGGER (dry-run) {}; skipping deletion", name);
390+
} else {
391+
DeploymentService.delete(deployers);
392+
logger.info("Deleted trigger {}", name);
393+
}
371394
logger.info("DROP TRIGGER {} completed", name);
372395
} catch (Exception e) {
373396
if (deployers != null) {
@@ -403,7 +426,11 @@ private void updateTriggerPausedState(SqlNode sqlNode, SqlIdentifier triggerName
403426
try {
404427
logger.info("Updating trigger {} with paused state: {}", name, paused);
405428
deployers = DeploymentService.deployers(trigger, connection);
406-
DeploymentService.update(deployers);
429+
if (HoptimatorDdlUtils.isValidateMode(connection)) {
430+
logger.info("Validated paused-state update (dry-run) for trigger {}; skipping deployment", name);
431+
} else {
432+
DeploymentService.update(deployers);
433+
}
407434
logger.info("Successfully updated trigger {} with paused state: {}", name, paused);
408435
} catch (Exception e) {
409436
if (deployers != null) {
@@ -450,6 +477,7 @@ public void execute(SqlDropObject drop, CalcitePrepare.Context context) {
450477
tablePath.add(tableName);
451478

452479
Collection<Deployer> deployers = null;
480+
final boolean dryRun = HoptimatorDdlUtils.isValidateMode(connection);
453481
try {
454482
if (table instanceof MaterializedViewTable) {
455483
if (!(drop instanceof SqlDropMaterializedView)) {
@@ -459,8 +487,14 @@ public void execute(SqlDropObject drop, CalcitePrepare.Context context) {
459487
MaterializedViewTable materializedViewTable = (MaterializedViewTable) table;
460488
View view = new View(tablePath, materializedViewTable.viewSql());
461489
deployers = DeploymentService.deployers(view, connection);
462-
logger.info("Deleting materialized view {}", tableName);
463-
DeploymentService.delete(deployers);
490+
if (dryRun) {
491+
logger.info("Validated DROP (dry-run) for materialized view {}; skipping deletion", tableName);
492+
} else {
493+
logger.info("Deleting materialized view {}", tableName);
494+
DeploymentService.delete(deployers);
495+
}
496+
// Always evolve the in-memory schema — even in a dry-run, dropping the view must make a
497+
// subsequent reference to it fail validation, regardless of whether a real object existed.
464498
schemaPlus.removeTable(tableName);
465499
logger.info("Removed materialized table {} from schema {}", tableName, schemaPlus.getName());
466500
} else if (table instanceof ViewTable) {
@@ -471,8 +505,12 @@ public void execute(SqlDropObject drop, CalcitePrepare.Context context) {
471505
ViewTable viewTable = (ViewTable) table;
472506
View view = new View(tablePath, viewTable.getViewSql());
473507
deployers = DeploymentService.deployers(view, connection);
474-
logger.info("Deleting view {}", tableName);
475-
DeploymentService.delete(deployers);
508+
if (dryRun) {
509+
logger.info("Validated DROP (dry-run) for view {}; skipping deletion", tableName);
510+
} else {
511+
logger.info("Deleting view {}", tableName);
512+
DeploymentService.delete(deployers);
513+
}
476514
schemaPlus.removeTable(tableName);
477515
logger.info("Removed view {} from schema {}", tableName, schemaPlus.getName());
478516
} else if (table instanceof HoptimatorJdbcTable || table instanceof TemporaryTable) {
@@ -491,11 +529,15 @@ public void execute(SqlDropObject drop, CalcitePrepare.Context context) {
491529
}
492530
// Pre-delete dependency guard. PendingDelete is the explicit "delete intent" signal
493531
// — only validators that key off it (the K8s dep checker) fire here. The check throws
494-
// before any deployer-level state change.
532+
// before any deployer-level state change. This validation runs in dry-run too.
495533
ValidationService.validateOrThrow(new PendingDelete<>(source), connection);
496534
deployers = DeploymentService.deployers(source, connection);
497-
logger.info("Deleting table {}", tableName);
498-
DeploymentService.delete(deployers);
535+
if (dryRun) {
536+
logger.info("Validated DROP (dry-run) for table {}; skipping deletion", tableName);
537+
} else {
538+
logger.info("Deleting table {}", tableName);
539+
DeploymentService.delete(deployers);
540+
}
499541
schemaPlus.removeTable(tableName);
500542
logger.info("Removed table {} from schema {}", tableName, schemaPlus.getName());
501543
} else {

0 commit comments

Comments
 (0)