Skip to content

Commit 18e3189

Browse files
authored
Implement schemaValidation mode handling in firebase dataconnect:sql:diff (#7633)
* Compat mode support. * Support handling schema validation options in `diff`. * Fix build. * Format. * Don't output second set of diffs if they're equal to the first. * Address review comments. * Tweak output logs.
1 parent 4cdfad2 commit 18e3189

File tree

3 files changed

+138
-35
lines changed

3 files changed

+138
-35
lines changed

src/commands/dataconnect-sql-diff.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export const command = new Command("dataconnect:sql:diff [serviceId]")
2222
await ensureApis(projectId);
2323
const serviceInfo = await pickService(projectId, options.config, serviceId);
2424

25-
const diffs = await diffSchema(serviceInfo.schema);
25+
const diffs = await diffSchema(
26+
serviceInfo.schema,
27+
serviceInfo.dataConnectYaml.schema.datasource.postgresql?.schemaValidation,
28+
);
2629
return { projectId, serviceId, diffs };
2730
});

src/dataconnect/schemaMigration.ts

Lines changed: 129 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as clc from "colorette";
22
import { format } from "sql-formatter";
33

4-
import { IncompatibleSqlSchemaError, Diff, SCHEMA_ID } from "./types";
4+
import { IncompatibleSqlSchemaError, Diff, SCHEMA_ID, SchemaValidation } from "./types";
55
import { getSchema, upsertSchema, deleteConnector } from "./client";
66
import {
77
setupIAMUsers,
@@ -23,21 +23,42 @@ import { logLabeledBullet, logLabeledWarning, logLabeledSuccess } from "../utils
2323
import * as experiments from "../experiments";
2424
import * as errors from "./errors";
2525

26-
export async function diffSchema(schema: Schema): Promise<Diff[]> {
26+
export async function diffSchema(
27+
schema: Schema,
28+
schemaValidation?: SchemaValidation,
29+
): Promise<Diff[]> {
2730
const { serviceName, instanceName, databaseId } = getIdentifiers(schema);
2831
await ensureServiceIsConnectedToCloudSql(
2932
serviceName,
3033
instanceName,
3134
databaseId,
3235
/* linkIfNotConnected=*/ false,
3336
);
37+
let diffs: Diff[] = [];
38+
39+
let validationMode: SchemaValidation = "STRICT";
40+
if (experiments.isEnabled("fdccompatiblemode")) {
41+
if (!schemaValidation) {
42+
// If the schema validation mode is unset, we surface both STRICT and COMPATIBLE mode diffs, starting with COMPATIBLE.
43+
validationMode = "COMPATIBLE";
44+
} else {
45+
validationMode = schemaValidation;
46+
}
47+
}
48+
setSchemaValidationMode(schema, validationMode);
3449

35-
setCompatibleMode(schema, databaseId, instanceName);
3650
try {
51+
if (!schemaValidation && experiments.isEnabled("fdccompatiblemode")) {
52+
logLabeledBullet("dataconnect", `generating required schema changes...`);
53+
}
3754
await upsertSchema(schema, /** validateOnly=*/ true);
38-
logLabeledSuccess("dataconnect", `Database schema is up to date.`);
55+
if (validationMode === "STRICT") {
56+
logLabeledSuccess("dataconnect", `Database schema is up to date.`);
57+
} else {
58+
logLabeledSuccess("dataconnect", `Database schema is compatible.`);
59+
}
3960
} catch (err: any) {
40-
if (err.status !== 400) {
61+
if (err?.status !== 400) {
4162
throw err;
4263
}
4364
const invalidConnectors = errors.getInvalidConnectors(err);
@@ -52,11 +73,47 @@ export async function diffSchema(schema: Schema): Promise<Diff[]> {
5273
displayInvalidConnectors(invalidConnectors);
5374
}
5475
if (incompatible) {
55-
displaySchemaChanges(incompatible);
56-
return incompatible.diffs;
76+
displaySchemaChanges(incompatible, validationMode, instanceName, databaseId);
77+
diffs = incompatible.diffs;
5778
}
5879
}
59-
return [];
80+
81+
if (experiments.isEnabled("fdccompatiblemode")) {
82+
// If the validation mode is unset, then we also surface any additional optional STRICT diffs.
83+
if (!schemaValidation) {
84+
validationMode = "STRICT";
85+
setSchemaValidationMode(schema, validationMode);
86+
try {
87+
logLabeledBullet("dataconnect", `generating schema changes, including optional changes...`);
88+
await upsertSchema(schema, /** validateOnly=*/ true);
89+
logLabeledSuccess("dataconnect", `no additional optional changes`);
90+
} catch (err: any) {
91+
if (err?.status !== 400) {
92+
throw err;
93+
}
94+
const incompatible = errors.getIncompatibleSchemaError(err);
95+
if (incompatible) {
96+
if (!diffsEqual(diffs, incompatible.diffs)) {
97+
if (diffs.length === 0) {
98+
displaySchemaChanges(
99+
incompatible,
100+
"STRICT_AFTER_COMPATIBLE",
101+
instanceName,
102+
databaseId,
103+
);
104+
} else {
105+
displaySchemaChanges(incompatible, validationMode, instanceName, databaseId);
106+
}
107+
// Return STRICT diffs if the --json flag is passed and schemaValidation is unset.
108+
diffs = incompatible.diffs;
109+
} else {
110+
logLabeledSuccess("dataconnect", `no additional optional changes`);
111+
}
112+
}
113+
}
114+
}
115+
}
116+
return diffs;
60117
}
61118

62119
export async function migrateSchema(args: {
@@ -75,13 +132,14 @@ export async function migrateSchema(args: {
75132
/* linkIfNotConnected=*/ true,
76133
);
77134

78-
setCompatibleMode(schema, databaseId, instanceName);
135+
const validationMode = experiments.isEnabled("fdccompatiblemode") ? "COMPATIBLE" : "STRICT";
136+
setSchemaValidationMode(schema, validationMode);
79137

80138
try {
81139
await upsertSchema(schema, validateOnly);
82140
logger.debug(`Database schema was up to date for ${instanceId}:${databaseId}`);
83141
} catch (err: any) {
84-
if (err.status !== 400) {
142+
if (err?.status !== 400) {
85143
throw err;
86144
}
87145
// Parse and handle failed precondition errors, then retry.
@@ -94,9 +152,11 @@ export async function migrateSchema(args: {
94152

95153
const migrationMode = await promptForSchemaMigration(
96154
options,
155+
instanceName,
97156
databaseId,
98157
incompatible,
99158
validateOnly,
159+
validationMode,
100160
);
101161

102162
const shouldDeleteInvalidConnectors = await promptForInvalidConnectorError(
@@ -129,22 +189,26 @@ export async function migrateSchema(args: {
129189
return [];
130190
}
131191

132-
function setCompatibleMode(schema: Schema, databaseId: string, instanceName: string) {
133-
if (experiments.isEnabled("fdccompatiblemode")) {
134-
if (schema.primaryDatasource.postgresql?.schemaValidation) {
135-
schema.primaryDatasource.postgresql.schemaValidation = "COMPATIBLE";
136-
} else {
137-
schema.primaryDatasource = {
138-
postgresql: {
139-
database: databaseId,
140-
cloudSql: {
141-
instance: instanceName,
142-
},
143-
schemaValidation: "COMPATIBLE",
144-
},
145-
};
192+
function diffsEqual(x: Diff[], y: Diff[]): boolean {
193+
if (x.length !== y.length) {
194+
return false;
195+
}
196+
for (let i = 0; i < x.length; i++) {
197+
if (
198+
x[i].description !== y[i].description ||
199+
x[i].destructive !== y[i].destructive ||
200+
x[i].sql !== y[i].sql
201+
) {
202+
return false;
146203
}
147204
}
205+
return true;
206+
}
207+
208+
function setSchemaValidationMode(schema: Schema, schemaValidation: SchemaValidation) {
209+
if (experiments.isEnabled("fdccompatiblemode") && schema.primaryDatasource.postgresql) {
210+
schema.primaryDatasource.postgresql.schemaValidation = schemaValidation;
211+
}
148212
}
149213

150214
function getIdentifiers(schema: Schema): {
@@ -274,14 +338,16 @@ async function handleIncompatibleSchemaError(args: {
274338

275339
async function promptForSchemaMigration(
276340
options: Options,
277-
databaseName: string,
341+
instanceName: string,
342+
databaseId: string,
278343
err: IncompatibleSqlSchemaError | undefined,
279344
validateOnly: boolean,
345+
schemaValidation: SchemaValidation,
280346
): Promise<"none" | "all"> {
281347
if (!err) {
282348
return "none";
283349
}
284-
displaySchemaChanges(err);
350+
displaySchemaChanges(err, schemaValidation, instanceName, databaseId);
285351
if (!options.nonInteractive) {
286352
if (validateOnly && options.force) {
287353
// `firebase dataconnect:sql:migrate --force` performs all migrations
@@ -299,7 +365,7 @@ async function promptForSchemaMigration(
299365
{ name: "Abort changes", value: "none" },
300366
];
301367
return await promptOnce({
302-
message: `Would you like to execute these changes against ${databaseName}?`,
368+
message: `Would you like to execute these changes against ${databaseId}?`,
303369
type: "list",
304370
choices,
305371
});
@@ -434,21 +500,51 @@ async function ensureServiceIsConnectedToCloudSql(
434500
try {
435501
await upsertSchema(currentSchema, /** validateOnly=*/ false);
436502
} catch (err: any) {
437-
if (err.status >= 500) {
503+
if (err?.status >= 500) {
438504
throw err;
439505
}
440506
logger.debug(err);
441507
}
442508
}
443509

444-
function displaySchemaChanges(error: IncompatibleSqlSchemaError) {
510+
function displaySchemaChanges(
511+
error: IncompatibleSqlSchemaError,
512+
schemaValidation: SchemaValidation | "STRICT_AFTER_COMPATIBLE",
513+
instanceName: string,
514+
databaseId: string,
515+
) {
445516
switch (error.violationType) {
446517
case "INCOMPATIBLE_SCHEMA":
447518
{
448-
const message =
449-
"Your new schema is incompatible with the schema of your CloudSQL database. " +
450-
"The following SQL statements will migrate your database schema to match your new Data Connect schema.\n" +
451-
error.diffs.map(toString).join("\n");
519+
let message;
520+
if (schemaValidation === "COMPATIBLE") {
521+
message =
522+
"Your new application schema is incompatible with the schema of your PostgreSQL database " +
523+
databaseId +
524+
" in your CloudSQL instance " +
525+
instanceName +
526+
". " +
527+
"The following SQL statements will migrate your database schema to be compatible with your new Data Connect schema.\n" +
528+
error.diffs.map(toString).join("\n");
529+
} else if (schemaValidation === "STRICT_AFTER_COMPATIBLE") {
530+
message =
531+
"Your new application schema is compatible with the schema of your PostgreSQL database " +
532+
databaseId +
533+
" in your CloudSQL instance " +
534+
instanceName +
535+
", but contains unused tables or columns. " +
536+
"The following optional SQL statements will migrate your database schema to match your new Data Connect schema.\n" +
537+
error.diffs.map(toString).join("\n");
538+
} else {
539+
message =
540+
"Your new application schema does not match the schema of your PostgreSQL database " +
541+
databaseId +
542+
" in your CloudSQL instance " +
543+
instanceName +
544+
". " +
545+
"The following SQL statements will migrate your database schema to match your new Data Connect schema.\n" +
546+
error.diffs.map(toString).join("\n");
547+
}
452548
logLabeledWarning("dataconnect", message);
453549
}
454550
break;

src/dataconnect/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ export interface Datasource {
2929
postgresql?: PostgreSql;
3030
}
3131

32+
export type SchemaValidation = "STRICT" | "COMPATIBLE";
33+
3234
export interface PostgreSql {
3335
database: string;
3436
cloudSql: CloudSqlInstance;
35-
schemaValidation?: "STRICT" | "COMPATIBLE" | "NONE" | "SQL_SCHEMA_VALIDATION_UNSPECIFIED";
37+
schemaValidation?: SchemaValidation | "NONE" | "SQL_SCHEMA_VALIDATION_UNSPECIFIED";
3638
}
3739

3840
export interface CloudSqlInstance {
@@ -117,6 +119,7 @@ export interface DatasourceYaml {
117119
cloudSql: {
118120
instanceId: string;
119121
};
122+
schemaValidation?: SchemaValidation;
120123
};
121124
}
122125

@@ -182,6 +185,7 @@ export function toDatasource(
182185
cloudSql: {
183186
instance: `projects/${projectId}/locations/${locationId}/instances/${ds.postgresql.cloudSql.instanceId}`,
184187
},
188+
schemaValidation: ds.postgresql.schemaValidation,
185189
},
186190
};
187191
}

0 commit comments

Comments
 (0)