Skip to content

Commit 032e631

Browse files
ryannedolanCopilot
andcommitted
Add validate (dry-run) mode to JDBC connection string
Introduce `mode=validate`, a dry-run flavor alongside `mode=apply`. In validate mode 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, and no specs are emitted. With respect to OR REPLACE, validate behaves like apply: an already-existing resource is treated as an in-place update rather than an error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2f9d82d commit 032e631

7 files changed

Lines changed: 356 additions & 43 deletions

File tree

docs/user-guide/ddl-reference.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,28 @@ 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+
Unlike the YAML preview produced by the SPECIFY path, `validate` emits no
71+
specs — it is purely a pass/fail validation. `validate`, `apply`, and `create`
72+
are mutually exclusive; pick one per connection.
73+
74+
5375
## CREATE VIEW
5476

5577
```

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

Lines changed: 69 additions & 32 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+
// Only mutate the in-memory schema when we are actually deploying. In dry-run
134+
// (VALIDATE) we leave the existing definition untouched.
135+
if (mode.mutable()) {
136+
pair.left.removeFunction(pair.right);
137+
}
134138
}
135139
}
136140

@@ -152,16 +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);
155-
if (mode == HoptimatorDdlUtils.DdlMode.UPDATE) {
156-
logger.info("Deploying update view {}", viewName);
157-
DeploymentService.update(deployers);
159+
if (mode == HoptimatorDdlUtils.DdlMode.VALIDATE) {
160+
logger.info("Validated view (dry-run) {}; skipping deployment", viewName);
158161
} else {
159-
logger.info("Deploying create view {}", viewName);
160-
DeploymentService.create(deployers);
162+
if (mode == HoptimatorDdlUtils.DdlMode.UPDATE) {
163+
logger.info("Deploying update view {}", viewName);
164+
DeploymentService.update(deployers);
165+
} else {
166+
logger.info("Deploying create view {}", viewName);
167+
DeploymentService.create(deployers);
168+
}
169+
logger.info("Deployed view {}", viewName);
170+
schemaPlus.add(viewName, viewTable);
171+
logger.info("Added view {} to schema {}", viewName, schemaPlus.getName());
161172
}
162-
logger.info("Deployed view {}", viewName);
163-
schemaPlus.add(viewName, viewTable);
164-
logger.info("Added view {} to schema {}", viewName, schemaPlus.getName());
165173
} catch (SQLException | RuntimeException e) {
166174
logger.info("Failed to deploy view {}", viewName);
167175
if (deployers != null) {
@@ -241,14 +249,18 @@ public void execute(SqlCreateTrigger create, CalcitePrepare.Context context) {
241249
ValidationService.validateOrThrow(deployers, connection);
242250
logger.info("Validated trigger {}", name);
243251
HoptimatorDdlUtils.DdlMode mode = HoptimatorDdlUtils.effectiveMode(create.getReplace(), connection);
244-
if (mode == HoptimatorDdlUtils.DdlMode.UPDATE) {
245-
logger.info("Updating trigger {}", name);
246-
DeploymentService.update(deployers);
252+
if (mode == HoptimatorDdlUtils.DdlMode.VALIDATE) {
253+
logger.info("Validated trigger (dry-run) {}; skipping deployment", name);
247254
} else {
248-
logger.info("Creating trigger {}", name);
249-
DeploymentService.create(deployers);
255+
if (mode == HoptimatorDdlUtils.DdlMode.UPDATE) {
256+
logger.info("Updating trigger {}", name);
257+
DeploymentService.update(deployers);
258+
} else {
259+
logger.info("Creating trigger {}", name);
260+
DeploymentService.create(deployers);
261+
}
262+
logger.info("Deployed trigger {}", name);
250263
}
251-
logger.info("Deployed trigger {}", name);
252264
logger.info("CREATE TRIGGER {} completed", name);
253265
} catch (Exception e) {
254266
if (deployers != null) {
@@ -336,7 +348,11 @@ public void execute(SqlFireTrigger fire, CalcitePrepare.Context context) {
336348
try {
337349
logger.info("Firing trigger {} with {} option(s)", name, options.size() - 1);
338350
deployers = DeploymentService.deployers(trigger, connection);
339-
DeploymentService.update(deployers);
351+
if (HoptimatorDdlUtils.isValidateMode(connection)) {
352+
logger.info("Validated FIRE TRIGGER (dry-run) {}; skipping deployment", name);
353+
} else {
354+
DeploymentService.update(deployers);
355+
}
340356
logger.info("FIRE TRIGGER {} completed", name);
341357
} catch (Exception e) {
342358
if (deployers != null) {
@@ -366,8 +382,12 @@ public void execute(SqlDropTrigger drop, CalcitePrepare.Context context) {
366382
try {
367383
logger.info("Deleting trigger {}", name);
368384
deployers = DeploymentService.deployers(trigger, connection);
369-
DeploymentService.delete(deployers);
370-
logger.info("Deleted trigger {}", name);
385+
if (HoptimatorDdlUtils.isValidateMode(connection)) {
386+
logger.info("Validated DROP TRIGGER (dry-run) {}; skipping deletion", name);
387+
} else {
388+
DeploymentService.delete(deployers);
389+
logger.info("Deleted trigger {}", name);
390+
}
371391
logger.info("DROP TRIGGER {} completed", name);
372392
} catch (Exception e) {
373393
if (deployers != null) {
@@ -403,7 +423,11 @@ private void updateTriggerPausedState(SqlNode sqlNode, SqlIdentifier triggerName
403423
try {
404424
logger.info("Updating trigger {} with paused state: {}", name, paused);
405425
deployers = DeploymentService.deployers(trigger, connection);
406-
DeploymentService.update(deployers);
426+
if (HoptimatorDdlUtils.isValidateMode(connection)) {
427+
logger.info("Validated paused-state update (dry-run) for trigger {}; skipping deployment", name);
428+
} else {
429+
DeploymentService.update(deployers);
430+
}
407431
logger.info("Successfully updated trigger {} with paused state: {}", name, paused);
408432
} catch (Exception e) {
409433
if (deployers != null) {
@@ -450,6 +474,7 @@ public void execute(SqlDropObject drop, CalcitePrepare.Context context) {
450474
tablePath.add(tableName);
451475

452476
Collection<Deployer> deployers = null;
477+
final boolean dryRun = HoptimatorDdlUtils.isValidateMode(connection);
453478
try {
454479
if (table instanceof MaterializedViewTable) {
455480
if (!(drop instanceof SqlDropMaterializedView)) {
@@ -459,10 +484,14 @@ public void execute(SqlDropObject drop, CalcitePrepare.Context context) {
459484
MaterializedViewTable materializedViewTable = (MaterializedViewTable) table;
460485
View view = new View(tablePath, materializedViewTable.viewSql());
461486
deployers = DeploymentService.deployers(view, connection);
462-
logger.info("Deleting materialized view {}", tableName);
463-
DeploymentService.delete(deployers);
464-
schemaPlus.removeTable(tableName);
465-
logger.info("Removed materialized table {} from schema {}", tableName, schemaPlus.getName());
487+
if (dryRun) {
488+
logger.info("Validated DROP (dry-run) for materialized view {}; skipping deletion", tableName);
489+
} else {
490+
logger.info("Deleting materialized view {}", tableName);
491+
DeploymentService.delete(deployers);
492+
schemaPlus.removeTable(tableName);
493+
logger.info("Removed materialized table {} from schema {}", tableName, schemaPlus.getName());
494+
}
466495
} else if (table instanceof ViewTable) {
467496
if (!(drop instanceof SqlDropView)) {
468497
throw new DdlException(drop,
@@ -471,10 +500,14 @@ public void execute(SqlDropObject drop, CalcitePrepare.Context context) {
471500
ViewTable viewTable = (ViewTable) table;
472501
View view = new View(tablePath, viewTable.getViewSql());
473502
deployers = DeploymentService.deployers(view, connection);
474-
logger.info("Deleting view {}", tableName);
475-
DeploymentService.delete(deployers);
476-
schemaPlus.removeTable(tableName);
477-
logger.info("Removed view {} from schema {}", tableName, schemaPlus.getName());
503+
if (dryRun) {
504+
logger.info("Validated DROP (dry-run) for view {}; skipping deletion", tableName);
505+
} else {
506+
logger.info("Deleting view {}", tableName);
507+
DeploymentService.delete(deployers);
508+
schemaPlus.removeTable(tableName);
509+
logger.info("Removed view {} from schema {}", tableName, schemaPlus.getName());
510+
}
478511
} else if (table instanceof HoptimatorJdbcTable || table instanceof TemporaryTable) {
479512
if (!(drop instanceof SqlDropTable)) {
480513
throw new DdlException(drop,
@@ -491,13 +524,17 @@ public void execute(SqlDropObject drop, CalcitePrepare.Context context) {
491524
}
492525
// Pre-delete dependency guard. PendingDelete is the explicit "delete intent" signal
493526
// — only validators that key off it (the K8s dep checker) fire here. The check throws
494-
// before any deployer-level state change.
527+
// before any deployer-level state change. This validation runs in dry-run too.
495528
ValidationService.validateOrThrow(new PendingDelete<>(source), connection);
496529
deployers = DeploymentService.deployers(source, connection);
497-
logger.info("Deleting table {}", tableName);
498-
DeploymentService.delete(deployers);
499-
schemaPlus.removeTable(tableName);
500-
logger.info("Removed table {} from schema {}", tableName, schemaPlus.getName());
530+
if (dryRun) {
531+
logger.info("Validated DROP (dry-run) for table {}; skipping deletion", tableName);
532+
} else {
533+
logger.info("Deleting table {}", tableName);
534+
DeploymentService.delete(deployers);
535+
schemaPlus.removeTable(tableName);
536+
logger.info("Removed table {} from schema {}", tableName, schemaPlus.getName());
537+
}
501538
} else {
502539
throw new SQLException(String.format("Unsupported drop type %s.", table.getClass().getSimpleName()));
503540
}

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

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ private HoptimatorDdlUtils() {
9898
* <li>{@code apply} — declarative, K8s-style reconciliation. Both {@code CREATE} and
9999
* {@code CREATE OR REPLACE} converge the resource to the declared definition, so
100100
* running the same script twice is a no-op. Idempotent by design.</li>
101+
* <li>{@code validate} — dry-run. Every statement is fully validated (and its deployers
102+
* resolved and validated) but no real object is ever created, updated, or deleted.
103+
* With respect to {@code OR REPLACE}, {@code validate} behaves like {@code apply}:
104+
* an already-existing resource is treated as an in-place update, not an error.</li>
101105
* </ul>
102106
*
103107
* <p>Set per connection on the JDBC URL, e.g. {@code jdbc:hoptimator://...;mode=apply}
@@ -111,15 +115,23 @@ private HoptimatorDdlUtils() {
111115
/** Apply-mode value of {@link #MODE_PROPERTY}. */
112116
public static final String MODE_APPLY = "apply";
113117

118+
/** Validate-only (dry-run) value of {@link #MODE_PROPERTY}. */
119+
public static final String MODE_VALIDATE = "validate";
120+
114121
/**
115122
* Resolves the effective {@link DdlMode} for a {@code CREATE} statement, combining the
116123
* statement's {@code OR REPLACE} flag with the connection's {@link #MODE_PROPERTY}.
117124
*
118125
* <p>In {@code create} mode (the default): {@code CREATE} → {@link DdlMode#CREATE} and
119126
* {@code CREATE OR REPLACE} → {@link DdlMode#UPDATE}. In {@code apply} mode: both forms
120-
* resolve to {@link DdlMode#UPDATE}, making CREATE idempotent.
127+
* resolve to {@link DdlMode#UPDATE}, making CREATE idempotent. In {@code validate} mode:
128+
* both forms resolve to {@link DdlMode#VALIDATE}, a dry-run that validates but never
129+
* mutates (and, like apply, never errors on an already-existing resource).
121130
*/
122131
static DdlMode effectiveMode(boolean orReplace, HoptimatorConnection conn) {
132+
if (isValidateMode(conn)) {
133+
return DdlMode.VALIDATE;
134+
}
123135
if (isApplyMode(conn)) {
124136
return DdlMode.UPDATE;
125137
}
@@ -128,12 +140,21 @@ static DdlMode effectiveMode(boolean orReplace, HoptimatorConnection conn) {
128140

129141
/** Whether the connection is configured for apply-mode DDL. */
130142
static boolean isApplyMode(HoptimatorConnection conn) {
143+
return modeIs(conn, MODE_APPLY);
144+
}
145+
146+
/** Whether the connection is configured for validate-only (dry-run) DDL. */
147+
static boolean isValidateMode(HoptimatorConnection conn) {
148+
return modeIs(conn, MODE_VALIDATE);
149+
}
150+
151+
private static boolean modeIs(HoptimatorConnection conn, String expected) {
131152
Properties props = conn.connectionProperties();
132153
if (props == null) {
133154
return false;
134155
}
135156
String mode = props.getProperty(MODE_PROPERTY, MODE_CREATE);
136-
return MODE_APPLY.equalsIgnoreCase(mode);
157+
return expected.equalsIgnoreCase(mode);
137158
}
138159

139160
/**
@@ -198,6 +219,19 @@ List<String> executeDeployers(Collection<Deployer> deployers, Connection conn) t
198219
return specs;
199220
}
200221

222+
@Override
223+
boolean mutable() {
224+
return false;
225+
}
226+
},
227+
VALIDATE {
228+
@Override
229+
List<String> executeDeployers(Collection<Deployer> deployers, Connection conn) {
230+
// Dry-run: validation has already happened by the time we reach here. Perform no
231+
// create/update/delete, and emit no specs — unlike SPECIFY, validate produces no output.
232+
return Collections.emptyList();
233+
}
234+
201235
@Override
202236
boolean mutable() {
203237
return false;
@@ -368,11 +402,12 @@ static SpecifyResult processCreateMaterializedView(CalcitePrepare.Context ctx,
368402
"Cannot overwrite physical table " + pair.right + " with a view.");
369403
}
370404
// A view already exists. The executor pre-resolved apply-mode into DdlMode.UPDATE,
371-
// so UPDATE always means "converge to this definition". Strict CREATE is the only
372-
// path that errors. SPECIFY (dry-run) keeps the original syntax-driven preview so
373-
// it accurately reflects what a real run would do.
405+
// so UPDATE always means "converge to this definition". VALIDATE (dry-run) follows the
406+
// same convergence semantics as apply, so it never errors on an existing resource.
407+
// Strict CREATE is the only path that errors. SPECIFY (dry-run) keeps the original
408+
// syntax-driven preview so it accurately reflects what a real run would do.
374409
boolean replaceExisting;
375-
if (mode == DdlMode.UPDATE) {
410+
if (mode == DdlMode.UPDATE || mode == DdlMode.VALIDATE) {
376411
replaceExisting = true;
377412
} else if (mode == DdlMode.CREATE) {
378413
if (!create.ifNotExists) {
@@ -447,14 +482,17 @@ static SpecifyResult processCreateMaterializedView(CalcitePrepare.Context ctx,
447482
logger.info("Deploying update materialized view {}", viewName);
448483
} else if (mode == DdlMode.CREATE) {
449484
logger.info("Deploying create materialized view {}", viewName);
485+
} else if (mode == DdlMode.VALIDATE) {
486+
logger.info("Validating (dry-run) materialized view {}", viewName);
450487
} else {
451488
logger.info("Specifying materialized view {}", viewName);
452489
}
453490
List<String> specs = mode.executeDeployers(deployers, conn);
454491
if (mode.mutable()) {
455492
logger.info("Deployed materialized view {}", viewName);
456493
} else {
457-
// SPECIFY (dry-run): roll back any side effects made by deployers during specify().
494+
// Non-mutating mode (SPECIFY/VALIDATE): roll back any side effects deployers may have
495+
// made (none for VALIDATE). Harmless when nothing was changed.
458496
DeploymentService.restore(deployers);
459497
}
460498
success = true;
@@ -542,10 +580,10 @@ static SpecifyResult processCreateTable(CalcitePrepare.Context ctx, HoptimatorCo
542580

543581
if (!isNewSchema && schemaPlus.tables().get(tableName) != null) {
544582
// Strict CREATE without IF NOT EXISTS is the only path that errors. UPDATE
545-
// (apply mode or explicit OR REPLACE) targets the existing table; SPECIFY
546-
// (dry-run) preserves its syntax-driven semantics.
583+
// (apply mode or explicit OR REPLACE) and VALIDATE (dry-run, apply-like) target
584+
// the existing table; SPECIFY (dry-run) preserves its syntax-driven semantics.
547585
boolean wouldFail;
548-
if (mode == DdlMode.UPDATE) {
586+
if (mode == DdlMode.UPDATE || mode == DdlMode.VALIDATE) {
549587
wouldFail = false;
550588
} else if (mode == DdlMode.CREATE) {
551589
wouldFail = !create.ifNotExists;
@@ -654,14 +692,17 @@ public RexNode newColumnDefaultValue(RelOptTable table, int iColumn,
654692
logger.info("Deploying update table {}", source);
655693
} else if (mode == DdlMode.CREATE) {
656694
logger.info("Deploying create table {}", source);
695+
} else if (mode == DdlMode.VALIDATE) {
696+
logger.info("Validating (dry-run) table {}", source);
657697
} else {
658698
logger.info("Specifying table {}", source);
659699
}
660700
List<String> specs = mode.executeDeployers(deployers, conn);
661701
if (mode.mutable()) {
662702
logger.info("Deployed table {}", source);
663703
} else {
664-
// SPECIFY (dry-run): roll back any side effects made by deployers during specify()
704+
// Non-mutating mode (SPECIFY/VALIDATE): roll back any side effects deployers may have
705+
// made (none for VALIDATE). Harmless when nothing was changed.
665706
DeploymentService.restore(deployers);
666707
}
667708
success = true;

0 commit comments

Comments
 (0)