From 86eaea7ca20be7c63ab67ca2ce24c13f738949c3 Mon Sep 17 00:00:00 2001 From: Minjae Lee Date: Sun, 6 Sep 2026 23:31:52 +0900 Subject: [PATCH] Support Redshift materialized view BACKUP clauses --- .../statement/create/view/CreateView.java | 34 +++++++- .../util/deparser/CreateViewDeParser.java | 5 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 2 + .../create/RedshiftCreateViewTest.java | 82 +++++++++++++++++++ 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/RedshiftCreateViewTest.java diff --git a/src/main/java/net/sf/jsqlparser/statement/create/view/CreateView.java b/src/main/java/net/sf/jsqlparser/statement/create/view/CreateView.java index 54cf782bc..3747023fd 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/view/CreateView.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/view/CreateView.java @@ -32,6 +32,7 @@ public class CreateView implements Statement { private boolean secure = false; private TemporaryOption temp = TemporaryOption.NONE; private AutoRefreshOption autoRefresh = AutoRefreshOption.NONE; + private Boolean backup; private boolean withReadOnly = false; private boolean ifNotExists = false; private List viewCommentOptions = null; @@ -122,8 +123,11 @@ public void setWithData(Boolean withData) { this.withData = withData; } - /** Checks combinations introduced by the PostgreSQL view clauses. */ + /** Checks combinations requiring ordinary, recursive or materialized views. */ public void validateOptions() { + if (backup != null && !materialized) { + throw new IllegalArgumentException("BACKUP requires a materialized view"); + } if (recursive && (materialized || columnNames == null || columnNames.isEmpty() || checkOption != null)) { throw new IllegalArgumentException( @@ -220,6 +224,30 @@ public void setAutoRefresh(AutoRefreshOption autoRefresh) { this.autoRefresh = autoRefresh; } + /** Null means omitted; true and false preserve Redshift's BACKUP YES and BACKUP NO. */ + public Boolean getBackup() { + return backup; + } + + public void setBackup(Boolean backup) { + this.backup = backup; + } + + public CreateView withBackup(Boolean backup) { + setBackup(backup); + return this; + } + + /** Shared SQL rendering for options immediately following the materialized view name. */ + public void appendMaterializationOptionsTo(StringBuilder sql) { + if (backup != null) { + sql.append(backup ? " BACKUP YES" : " BACKUP NO"); + } + if (autoRefresh != AutoRefreshOption.NONE) { + sql.append(" AUTO REFRESH ").append(autoRefresh.name()); + } + } + public boolean isWithReadOnly() { return withReadOnly; } @@ -266,9 +294,7 @@ public String toString() { if (ifNotExists && ifNotExistsAfterViewName) { sql.append(" IF NOT EXISTS"); } - if (autoRefresh != AutoRefreshOption.NONE) { - sql.append(" AUTO REFRESH ").append(autoRefresh.name()); - } + appendMaterializationOptionsTo(sql); if (columnNames != null) { sql.append("("); sql.append(columnNames); diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/CreateViewDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/CreateViewDeParser.java index 253188043..432ef52b9 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/CreateViewDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/CreateViewDeParser.java @@ -9,7 +9,6 @@ */ package net.sf.jsqlparser.util.deparser; -import net.sf.jsqlparser.statement.create.view.AutoRefreshOption; import net.sf.jsqlparser.statement.create.view.CreateView; import net.sf.jsqlparser.statement.create.view.TemporaryOption; import net.sf.jsqlparser.statement.select.PlainSelect; @@ -74,9 +73,7 @@ public void deParse(CreateView createView) { if (createView.isIfNotExists() && createView.isIfNotExistsAfterViewName()) { builder.append(" IF NOT EXISTS"); } - if (createView.getAutoRefresh() != AutoRefreshOption.NONE) { - builder.append(" AUTO REFRESH ").append(createView.getAutoRefresh().name()); - } + createView.appendMaterializationOptionsTo(builder); if (createView.getColumnNames() != null) { builder.append("("); builder.append(createView.getColumnNames()); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index ebb26f492..f3c888fc9 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -12907,6 +12907,8 @@ CreateView CreateView(boolean isUsingOrReplace): [ LOOKAHEAD(3) { createView.setIfNotExists(true); } ] view=Table() { createView.setView(view); } + [ LOOKAHEAD({ isKeywordAhead("BACKUP") }) tk= + (tk= | tk=) { createView.setBackup(tk.kind == K_YES); } ] [LOOKAHEAD(3) (tk= | tk=) { createView.setAutoRefresh(AutoRefreshOption.from(tk.image)); } ] [LOOKAHEAD(3) { createView.setIfNotExists(true); diff --git a/src/test/java/net/sf/jsqlparser/statement/create/RedshiftCreateViewTest.java b/src/test/java/net/sf/jsqlparser/statement/create/RedshiftCreateViewTest.java new file mode 100644 index 000000000..d5346390d --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/RedshiftCreateViewTest.java @@ -0,0 +1,82 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create; + +import static org.junit.jupiter.api.Assertions.*; + +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.create.view.AutoRefreshOption; +import net.sf.jsqlparser.statement.create.view.CreateView; +import net.sf.jsqlparser.statement.select.Select; +import net.sf.jsqlparser.test.TestUtils; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class RedshiftCreateViewTest { + @ParameterizedTest + @ValueSource(strings = {"", " BACKUP YES", " BACKUP NO"}) + void preserveBackupAndOmission(String clause) throws JSQLParserException { + String sql = "CREATE MATERIALIZED VIEW myview" + clause + " AS SELECT * FROM mytab"; + CreateView view = (CreateView) TestUtils.assertSqlCanBeParsedAndDeparsed(sql); + assertEquals(clause.isEmpty() ? null : clause.endsWith("YES"), view.getBackup()); + CreateView reparsed = (CreateView) CCJSqlParserUtil.parse(view.toString()); + assertEquals(view.getBackup(), reparsed.getBackup()); + assertTrue(TablesNamesFinder.findTables(sql).contains("mytab")); + } + + @ParameterizedTest + @ValueSource(strings = {"YES", "NO"}) + void combineWithAutoRefresh(String value) throws JSQLParserException { + CreateView view = (CreateView) TestUtils.assertSqlCanBeParsedAndDeparsed( + "CREATE MATERIALIZED VIEW report.mv BACKUP NO AUTO REFRESH " + value + + " AS SELECT * FROM source"); + assertEquals(Boolean.FALSE, view.getBackup()); + assertEquals(AutoRefreshOption.from(value), view.getAutoRefresh()); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE MATERIALIZED VIEW mv BACKUP AS SELECT 1", + "CREATE MATERIALIZED VIEW mv BACKUP TRUE AS SELECT 1", + "CREATE MATERIALIZED VIEW mv BACKUP YES BACKUP NO AS SELECT 1", + "CREATE MATERIALIZED VIEW mv AUTO REFRESH YES BACKUP NO AS SELECT 1", + "CREATE VIEW mv BACKUP NO AS SELECT 1" + }) + void rejectMalformedOptions(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, + parser -> parser.withUnsupportedStatements(false))); + } + + @Test + void constructAndClearBackup() throws JSQLParserException { + CreateView view = new CreateView().withMaterialized(true).withBackup(false) + .withView(new Table("mv")) + .withSelect((Select) CCJSqlParserUtil.parse("SELECT * FROM source")); + TestUtils.assertStatementCanBeDeparsedAs(view, + "CREATE MATERIALIZED VIEW mv BACKUP NO AS SELECT * FROM source"); + view.setBackup(null); + TestUtils.assertStatementCanBeDeparsedAs(view, + "CREATE MATERIALIZED VIEW mv AS SELECT * FROM source"); + view.setMaterialized(false); + view.setBackup(true); + assertThrows(IllegalArgumentException.class, view::validateOptions); + } + + @Test + void backupRemainsAnIdentifier() throws JSQLParserException { + TestUtils.assertSqlCanBeParsedAndDeparsed( + "CREATE MATERIALIZED VIEW backup BACKUP YES AS SELECT backup FROM backup"); + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT backup FROM backup"); + } +}