Skip to content

Conversation

@raminqaf
Copy link
Contributor

@raminqaf raminqaf commented Nov 5, 2025

What is the purpose of the change

This pull request implements the CREATE OR ALTER MATERIALIZED TABLE syntax as proposed in FLIP-546. This new command provides an idempotent way to manage materialized tables, enabling declarative deployment patterns for CI/CD pipelines and infrastructure-as-code workflows.

The command intelligently routes to either CREATE or ALTER logic:

  • If the materialized table does not exist, it creates a new table (behaves like CREATE MATERIALIZED TABLE)
  • If the materialized table already exists, it modifies the query definition (behaves like ALTER MATERIALIZED TABLE AS)

This eliminates the need for complex DROP-IF-EXISTS patterns and makes materialized table management more robust and predictable in automated deployment scenarios.

Brief change log

  • Added CREATE OR ALTER MATERIALIZED TABLE SQL syntax to the Flink SQL parser
  • Extended SqlCreateOrAlterMaterializedTable to handle both create and alter operations based on table existence
  • Refactored materialized table parsing and building logic into SqlCreateOrAlterMaterializedTableConverter for better encapsulation
  • Added getOriginalQuery() method to CatalogMaterializedTable interface (aligning with CatalogView interface)
  • Added comprehensive tests for CREATE OR ALTER behavior in both create and alter scenarios
  • Updated documentation to include CREATE OR ALTER MATERIALIZED TABLE syntax and examples

Verifying this change

This change added tests and can be verified as follows:

  • Added unit tests in MaterializedTableStatementParserTest to verify SQL parsing of CREATE OR ALTER syntax
  • Added integration tests in SqlMaterializedTableNodeToOperationConverterTest:
    • testCreateOrAlterMaterializedTable() - verifies CREATE behavior when table doesn't exist
    • testCreateOrAlterMaterializedTableForExistingTable() - verifies ALTER behavior when table exists with schema evolution
  • Extended integration tests in SqlGatewayRestEndpointMaterializedTableITCase to validate end-to-end behavior via REST API
  • Verified that existing CREATE and ALTER tests continue to pass, ensuring backward compatibility
  • Manually verified idempotent behavior by running the same CREATE OR ALTER statement multiple times

Does this pull request potentially affect one of the following parts:

  • Dependencies (does it add or upgrade a dependency): no
  • The public API, i.e., is any changed class annotated with @Public(Evolving): yes - CatalogMaterializedTable interface extended with getOriginalQuery() method
  • The serializers: no
  • The runtime per-record code paths (performance sensitive): no
  • Anything that affects deployment or recovery: JobManager (and its components), Checkpointing, Kubernetes/Yarn, ZooKeeper: no
  • The S3 file system connector: no

Documentation

  • Does this pull request introduce a new feature? yes
  • If yes, how is the feature documented? docs - Added new section in docs/content/docs/dev/table/materialized-table/statements.md explaining CREATE OR ALTER syntax, behavior, examples, and use cases

@raminqaf raminqaf changed the title [FLINK-38355] Support CreateOrAlter MATERIALIZED TABLE [FLINK-38355][table][FLIP-546] Support CREATE OR ALTER MATERIALIZED TABLE Nov 5, 2025
@raminqaf raminqaf changed the title [FLINK-38355][table][FLIP-546] Support CREATE OR ALTER MATERIALIZED TABLE [FLINK-38355][table][FLIP-546] Support CREATE OR ALTER MATERIALIZED TABLE syntax Nov 5, 2025
@flinkbot
Copy link
Collaborator

flinkbot commented Nov 5, 2025

CI report:

Bot commands The @flinkbot bot supports the following commands:
  • @flinkbot run azure re-run the last Azure build

.isEqualTo(newTable.getUnresolvedSchema().getPrimaryKey());
assertThat(oldTable.getUnresolvedSchema().getWatermarkSpecs())
.isEqualTo(newTable.getUnresolvedSchema().getWatermarkSpecs());
assertThat(oldTable.getDefinitionQuery()).isNotEqualTo(newTable.getDefinitionQuery());
Copy link
Contributor

Choose a reason for hiding this comment

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

I am curious what you think, we have removed DefinitionQuery, but we still have DefinitionFreshness. Should we rename DefinitionFreshness so it does not include the word Definition - maybe change just to freshness to simplify.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a valid point but we have deprecated the getFreshness() method in CatalogMaterializedTable interface.

https://github.com/apache/flink/blob/master/flink-table/flink-table-common/src/main/java/org/apache/flink/table/catalog/CatalogMaterializedTable.java#L123-L141

@github-actions github-actions bot added the community-reviewed PR has been reviewed by the community. label Nov 6, 2025
@raminqaf raminqaf force-pushed the FLINK-38355 branch 5 times, most recently from b08780d to ba9286e Compare November 12, 2025 10:17
Comment on lines +93 to +159
writer.keyword("MATERIALIZED TABLE");
getTableName().unparse(writer, leftPrec, rightPrec);

if (!getColumnList().isEmpty()
|| !getTableConstraints().isEmpty()
|| getWatermark().isPresent()) {
SqlUnparseUtils.unparseTableSchema(
writer,
leftPrec,
rightPrec,
getColumnList(),
getTableConstraints(),
getWatermark().orElse(null));
}

getComment()
.ifPresent(
comment -> {
writer.newlineAndIndent();
writer.keyword("COMMENT");
comment.unparse(writer, leftPrec, rightPrec);
});

if (getDistribution() != null) {
writer.newlineAndIndent();
getDistribution().unparse(writer, leftPrec, rightPrec);
}

if (!getPartitionKeyList().isEmpty()) {
writer.newlineAndIndent();
writer.keyword("PARTITIONED BY");
SqlWriter.Frame partitionedByFrame = writer.startList("(", ")");
getPartitionKeyList().unparse(writer, leftPrec, rightPrec);
writer.endList(partitionedByFrame);
}

if (!getPropertyList().isEmpty()) {
writer.newlineAndIndent();
writer.keyword("WITH");
SqlWriter.Frame withFrame = writer.startList("(", ")");
for (SqlNode property : getPropertyList()) {
SqlUnparseUtils.printIndent(writer);
property.unparse(writer, leftPrec, rightPrec);
}
writer.newlineAndIndent();
writer.endList(withFrame);
}

if (getFreshness() != null) {
writer.newlineAndIndent();
writer.keyword("FRESHNESS");
writer.keyword("=");
getFreshness().unparse(writer, leftPrec, rightPrec);
}

if (getRefreshMode() != null) {
writer.newlineAndIndent();
writer.keyword("REFRESH_MODE");
writer.keyword("=");
writer.keyword(getRefreshMode().name());
}

writer.newlineAndIndent();
writer.keyword("AS");
writer.newlineAndIndent();
getAsQuery().unparse(writer, leftPrec, rightPrec);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess it is duplicating what we have in SqlCreateMaterializedTable
I wonder if we can extract this part into a separate method and reuse for both?

Copy link
Contributor Author

@raminqaf raminqaf Nov 13, 2025

Choose a reason for hiding this comment

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

I have removed the code from the SqlCreateMaterializedTable and only the child class SqlCreateOrAlterMaterializedTable is implementing the logic now. Or do we need both classes to implement it?

Copy link
Contributor

Choose a reason for hiding this comment

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

Well, it means we need to reimplement it again if want to support something like ALTER MATERIALIZED TABLE ...
so yes, better to reuse

Comment on lines +320 to 321
"SELECT id, name FROM tbl_a",
"SELECT id, name FROM tbl_a"),
Copy link
Contributor

Choose a reason for hiding this comment

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

do we have at least one test where original and expanded are different?
If not then. probably should add it

Comment on lines +66 to +67
? handleAlter(sqlCreateOrAlterMaterializedTable, context)
: handleCreate(sqlCreateOrAlterMaterializedTable, context, identifier);
Copy link
Contributor

Choose a reason for hiding this comment

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

can we instead just redirect call to either SqlCreateMaterializedTableConverter or SqlAlterMaterializedTableConverter?

public static final SqlSpecialOperator CREATE_OR_ALTER_OPERATOR =
new SqlSpecialOperator("CREATE OR ALTER MATERIALIZED TABLE", SqlKind.OTHER_DDL);

private final boolean isOrAlter;
Copy link
Contributor

Choose a reason for hiding this comment

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

do we really need this field if we have different operators?

Comment on lines 102 to 105
@Override
public SqlOperator getOperator() {
return OPERATOR;
return CREATE_OPERATOR;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess if you drop this method override it will return the operator which is passed to the constructor and this is what we want, right?

Comment on lines +78 to +81
@Override
public SqlOperator getOperator() {
return isOrAlter ? CREATE_OR_ALTER_OPERATOR : CREATE_OPERATOR;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

probably no need for this method if already passed operator to constructor

@Override
public void unparse(SqlWriter writer, int leftPrec, int rightPrec) {
writer.keyword("CREATE");
if (isOrAlter) {
Copy link
Contributor

Choose a reason for hiding this comment

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

can we use

Suggested change
if (isOrAlter) {
if (getOperator() == CREATE_OR_ALTER_OPERATOR) {

?
and then no need for isOrAlter

Comment on lines +40 to +41
private static final String CREATE_COMMAND = "CREATE ";
private static final String CREATE_OR_ALTER_COMMAND = "CREATE OR ALTER ";
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
private static final String CREATE_COMMAND = "CREATE ";
private static final String CREATE_OR_ALTER_COMMAND = "CREATE OR ALTER ";
private static final String CREATE_OPERATION = "CREATE ";
private static final String CREATE_OR_ALTER_OPERATION = "CREATE OR ALTER ";

Comment on lines +73 to +76
final ObjectIdentifier identifier =
this.getIdentifier(sqlCreateOrAlterMaterializedTable, context);
final ResolvedCatalogMaterializedTable oldTable =
getExistingResolvedMaterializedTable(context, identifier);
Copy link
Contributor

Choose a reason for hiding this comment

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

Right before calling this method we already have derived these 2 values
an we reuse them instead doing same twice?

oldTable, sqlCreateOrAlterMaterializedTable, context);

List<MaterializedTableChange> tableChanges =
buildTableChanges(sqlCreateOrAlterMaterializedTable, oldTable, context);
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
buildTableChanges(sqlCreateOrAlterMaterializedTable, oldTable, context);
buildTableChanges(sqlCreateOrAlterMaterializedTable, oldMaterializedTable, context);

here and in other places oldTable -> oldMaterializedTable

final MergeContext mergeContext =
this.getMergeContext(sqlCreateOrAlterMaterializedTable, context);

// Extract new columns
Copy link
Contributor

Choose a reason for hiding this comment

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

probably no need for this comment

.refreshMode(refreshMode)
.refreshStatus(RefreshStatus.INITIALIZING);

// Preserve refresh handler from old table
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Preserve refresh handler from old table
// Preserve refresh handler from old materialized table

Comment on lines +130 to +132
private boolean tableExists(ConvertContext context, ObjectIdentifier identifier) {
return context.getCatalogManager().getTable(identifier).isPresent();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

not sure we need this method at all given the fact that there is getExistingResolvedMaterializedTable
moreover tableExists doesn't check table type

return context.getCatalogManager().getTable(identifier).isPresent();
}

private CatalogMaterializedTable buildNewCatalogMaterializedTableFromOldTable(
Copy link
Contributor

Choose a reason for hiding this comment

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

ideally this method should be in alterMaterializedTable converter
may be we can move it in future when that converter becomes more mature

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-reviewed PR has been reviewed by the community.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants