diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 2c7a89876..03755d652 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -11,6 +11,8 @@ import net.sf.jsqlparser.statement.alter.AlterPolicy; import net.sf.jsqlparser.statement.drop.DropPolicy; +import net.sf.jsqlparser.statement.create.statistics.CreateStatistics; +import net.sf.jsqlparser.statement.alter.AlterStatistics; import net.sf.jsqlparser.statement.alter.AlterRelation; import net.sf.jsqlparser.statement.alter.AlterTablespaceMove; import net.sf.jsqlparser.statement.alter.database.AlterDatabase; @@ -715,4 +717,20 @@ default T visit(DropPolicy statement, S context) { default void visit(DropPolicy statement) { visit(statement, null); } + + default T visit(CreateStatistics statement, S context) { + return null; + } + + default void visit(CreateStatistics statement) { + visit(statement, null); + } + + default T visit(AlterStatistics statement, S context) { + return null; + } + + default void visit(AlterStatistics statement) { + visit(statement, null); + } } diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterStatistics.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterStatistics.java new file mode 100644 index 000000000..b14f9977b --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterStatistics.java @@ -0,0 +1,107 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2025 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.alter; + +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** PostgreSQL statistics object metadata; a null target denotes SET STATISTICS DEFAULT. */ +public class AlterStatistics implements Statement { + public enum Action { + RENAME, OWNER, SET_SCHEMA, SET_STATISTICS + } + + private String name; + private Action action; + private String newName; + private String owner; + private String schemaName; + private Integer statistics; + + public String getName() { + return name; + } + + public AlterStatistics setName(String name) { + this.name = name; + return this; + } + + public Action getAction() { + return action; + } + + public AlterStatistics setAction(Action action) { + this.action = action; + return this; + } + + public String getNewName() { + return newName; + } + + public AlterStatistics setNewName(String newName) { + this.newName = newName; + return this; + } + + public String getOwner() { + return owner; + } + + public AlterStatistics setOwner(String owner) { + this.owner = owner; + return this; + } + + public String getSchemaName() { + return schemaName; + } + + public AlterStatistics setSchemaName(String schemaName) { + this.schemaName = schemaName; + return this; + } + + public Integer getStatistics() { + return statistics; + } + + public AlterStatistics setStatistics(Integer statistics) { + this.statistics = statistics; + return this; + } + + public StringBuilder appendTo(StringBuilder sql) { + sql.append("ALTER STATISTICS ").append(name); + if (action == Action.RENAME) { + sql.append(" RENAME TO ").append(newName); + } else if (action == Action.OWNER) { + sql.append(" OWNER TO ").append(owner); + } else if (action == Action.SET_SCHEMA) { + sql.append(" SET SCHEMA ").append(schemaName); + } else if (action == Action.SET_STATISTICS) { + sql.append(" SET STATISTICS ").append(statistics == null ? "DEFAULT" : statistics); + } else { + throw new IllegalStateException("Expected statistics alteration action"); + } + return sql; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor visitor, S context) { + return visitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/statistics/CreateStatistics.java b/src/main/java/net/sf/jsqlparser/statement/create/statistics/CreateStatistics.java new file mode 100644 index 000000000..4e9775f70 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/statistics/CreateStatistics.java @@ -0,0 +1,111 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2025 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create.statistics; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** PostgreSQL extended statistics over columns and parenthesized expressions. */ +public class CreateStatistics implements Statement { + public enum Kind { + NDISTINCT, DEPENDENCIES, MCV + } + + private String name; + private boolean ifNotExists = false; + private List kinds = new ArrayList<>(); + private ExpressionList expressions = new ExpressionList<>(); + private Table table; + + public String getName() { + return name; + } + + public CreateStatistics setName(String name) { + this.name = name; + return this; + } + + public boolean isIfNotExists() { + return ifNotExists; + } + + public CreateStatistics setIfNotExists(boolean ifNotExists) { + this.ifNotExists = ifNotExists; + return this; + } + + public List getKinds() { + return kinds; + } + + public CreateStatistics setKinds(List kinds) { + this.kinds = kinds; + return this; + } + + public ExpressionList getExpressions() { + return expressions; + } + + public CreateStatistics setExpressions(ExpressionList expressions) { + this.expressions = expressions; + return this; + } + + public Table getTable() { + return table; + } + + public CreateStatistics setTable(Table table) { + this.table = table; + return this; + } + + public StringBuilder appendTo(StringBuilder sql, Consumer printer) { + sql.append("CREATE STATISTICS"); + if (ifNotExists) { + sql.append(" IF NOT EXISTS"); + } + if (name != null) { + sql.append(' ').append(name); + } + if (kinds != null && !kinds.isEmpty()) { + sql.append(" ("); + for (int i = 0; i < kinds.size(); i++) { + if (i > 0) { + sql.append(", "); + } + sql.append(kinds.get(i).name().toLowerCase(java.util.Locale.ROOT)); + } + sql.append(')'); + } + sql.append(" ON "); + printer.accept(expressions); + return sql.append(" FROM ").append(table); + } + + @Override + public String toString() { + StringBuilder sql = new StringBuilder(); + return appendTo(sql, sql::append).toString(); + } + + @Override + public T accept(StatementVisitor visitor, S context) { + return visitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 1ed6a928f..9d21d8377 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -11,6 +11,8 @@ import net.sf.jsqlparser.statement.alter.AlterPolicy; import net.sf.jsqlparser.statement.drop.DropPolicy; +import net.sf.jsqlparser.statement.create.statistics.CreateStatistics; +import net.sf.jsqlparser.statement.alter.AlterStatistics; import net.sf.jsqlparser.statement.alter.AlterRelation; import net.sf.jsqlparser.statement.alter.AlterTablespaceMove; import net.sf.jsqlparser.statement.alter.database.AlterDatabase; @@ -2954,4 +2956,16 @@ public Void visit(DropPolicy statement, S context) { visit(statement.getTable(), context); return null; } + + @Override + public Void visit(CreateStatistics statement, S context) { + visit(statement.getTable(), context); + statement.getExpressions().accept(this, context); + return null; + } + + @Override + public Void visit(AlterStatistics statement, S context) { + return null; + } } diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index 889d4ac3d..d949c3e62 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -11,6 +11,8 @@ import net.sf.jsqlparser.statement.alter.AlterPolicy; import net.sf.jsqlparser.statement.drop.DropPolicy; +import net.sf.jsqlparser.statement.create.statistics.CreateStatistics; +import net.sf.jsqlparser.statement.alter.AlterStatistics; import net.sf.jsqlparser.statement.alter.AlterRelation; import net.sf.jsqlparser.statement.alter.AlterTablespaceMove; import net.sf.jsqlparser.statement.alter.database.AlterDatabase; @@ -840,4 +842,15 @@ public StringBuilder visit(AlterPolicy statement, S context) { public StringBuilder visit(DropPolicy statement, S context) { return statement.appendTo(builder); } + + @Override + public StringBuilder visit(CreateStatistics statement, S context) { + return statement.appendTo(builder, + expression -> expression.accept(expressionDeParser, context)); + } + + @Override + public StringBuilder visit(AlterStatistics statement, S context) { + return statement.appendTo(builder); + } } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4ecee0e89..8a857c38b 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -63,6 +63,7 @@ import net.sf.jsqlparser.statement.create.event.*; import net.sf.jsqlparser.statement.create.function.*; import net.sf.jsqlparser.statement.create.index.*; import net.sf.jsqlparser.statement.create.macro.*; +import net.sf.jsqlparser.statement.create.statistics.CreateStatistics; import net.sf.jsqlparser.statement.create.policy.*; import net.sf.jsqlparser.statement.create.procedure.*; import net.sf.jsqlparser.statement.create.schema.*; @@ -18303,6 +18304,8 @@ Statement Alter(): ( ( + LOOKAHEAD({ isKeywordAhead("STATISTICS") }) statement = AlterStatistics() + | LOOKAHEAD() statement = AlterPolicy() | LOOKAHEAD({ isKeywordAhead("ROLE") || isKeywordAhead("USER") || isKeywordAhead("GROUP") }) @@ -19478,6 +19481,8 @@ Statement Create(): { [ { isUsingOrReplace = true; } ] ( + LOOKAHEAD({ isKeywordAhead("STATISTICS") }) statement = CreateStatistics() + | LOOKAHEAD({ isPostgreSqlRoleAhead() }) statement = CreateRole() { requireAccessSyntax(!isUsingOrReplace, "CREATE ROLE does not accept OR REPLACE"); } | @@ -20733,3 +20738,48 @@ String TimeTravelAfterAlias(): return builder.toString(); } } + +CreateStatistics CreateStatistics(): +{ + CreateStatistics statement = new CreateStatistics(); + String name; CreateStatistics.Kind kind; Expression expression; Table table; +} +{ + ContextualKeyword("STATISTICS") + [ LOOKAHEAD(2) { statement.setIfNotExists(true); } ] + [ LOOKAHEAD({ getToken(1).kind != K_ON && getToken(1).kind != OPENING_BRACKET }) + name=TypeDdlName() { statement.setName(name); } ] + [ "(" name=RelObjectName() { kind=accessEnum(CreateStatistics.Kind.class,name); statement.getKinds().add(kind); } + ( "," name=RelObjectName() { kind=accessEnum(CreateStatistics.Kind.class,name); + requireDdlSyntax(!statement.getKinds().contains(kind),"Duplicate statistics kind"); statement.getKinds().add(kind); } )* ")" ] + expression=PostgreSqlStatisticsExpression() { statement.getExpressions().add(expression); } + ( "," expression=PostgreSqlStatisticsExpression() { statement.getExpressions().add(expression); } )* + table=Table() { statement.setTable(table); } + { + requireDdlSyntax(!statement.isIfNotExists() || statement.getName()!=null,"IF NOT EXISTS requires a statistics name"); + requireDdlSyntax(statement.getExpressions().size()>1 || statement.getKinds().isEmpty() + && statement.getExpressions().get(0) instanceof ParenthesedExpressionList, + "Single-expression statistics require a parenthesized expression and no explicit kinds"); + return statement; + } +} +Expression PostgreSqlStatisticsExpression(): +{ Expression expression; String name; } +{ + ( "(" expression=Expression() ")" { expression = new ParenthesedExpressionList(expression); } + | name=RelObjectName() { expression = createColumn(name); } ) + { return expression; } +} +AlterStatistics AlterStatistics(): +{ AlterStatistics statement = new AlterStatistics(); String name; Long target; } +{ + ContextualKeyword("STATISTICS") name=TypeDdlName() { statement.setName(name); } + ( name=RelObjectName() { statement.setAction(AlterStatistics.Action.RENAME); statement.setNewName(name); } + | ContextualKeyword("OWNER") name=RelObjectName() { statement.setAction(AlterStatistics.Action.OWNER); statement.setOwner(name); } + | ( name=RelObjectName() { statement.setAction(AlterStatistics.Action.SET_SCHEMA); statement.setSchemaName(name); } + | ContextualKeyword("STATISTICS") { statement.setAction(AlterStatistics.Action.SET_STATISTICS); } + ( | target=SequenceParameterValue() { + requireDdlSyntax(target>=-1 && target<=10000,"Statistics target must be DEFAULT or between -1 and 10000"); + statement.setStatistics(target.intValue()); } ) ) ) + { return statement; } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlStatisticsDdlTest.java b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlStatisticsDdlTest.java new file mode 100644 index 000000000..290b4239c --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlStatisticsDdlTest.java @@ -0,0 +1,105 @@ +/*- + * #%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 java.util.List; +import java.util.Set; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.alter.AlterStatistics; +import net.sf.jsqlparser.statement.create.statistics.CreateStatistics; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PostgreSqlStatisticsDdlTest { + @ParameterizedTest + @ValueSource(strings = { + "CREATE STATISTICS st ON id,label FROM t", + "CREATE STATISTICS st ON (lower(label)) FROM t", + "CREATE STATISTICS st ON (id+1),label FROM t", + "CREATE STATISTICS IF NOT EXISTS st ON id,label FROM t", + "CREATE STATISTICS IF NOT EXISTS st ON (lower(label)) FROM t", + "CREATE STATISTICS IF NOT EXISTS st ON (id+1),label FROM t", + "CREATE STATISTICS ON id,label FROM t", "CREATE STATISTICS ON (lower(label)) FROM t", + "CREATE STATISTICS ON (id+1),label FROM t", + "CREATE STATISTICS st(ndistinct) ON id,label FROM t", + "CREATE STATISTICS st(dependencies) ON id,label FROM t", + "CREATE STATISTICS st(mcv) ON id,label FROM t", + "CREATE STATISTICS st(ndistinct,dependencies,mcv) ON id,label FROM t", + "ALTER STATISTICS st OWNER TO CURRENT_USER", "ALTER STATISTICS st RENAME TO st2", + "ALTER STATISTICS st SET SCHEMA ddl_aux", "ALTER STATISTICS st SET STATISTICS 500", + "ALTER STATISTICS st SET STATISTICS DEFAULT", "DROP STATISTICS st", + "DROP STATISTICS IF EXISTS st,st2 RESTRICT"}) + void definitionAndActionsRoundTrip(String sql) throws JSQLParserException { + assertRoundTrip(parse(sql)); + assertEquals(2, CCJSqlParserUtil.parseStatements(sql + "; SELECT 1", + p -> p.withDialect(Dialect.POSTGRESQL)).size()); + } + + @Test + void namesKindsAndKeyExpressionsCanBeChanged() throws JSQLParserException { + CreateStatistics statistics = (CreateStatistics) parse( + "CREATE STATISTICS IF NOT EXISTS old_stats(ndistinct) ON id,label FROM t"); + assertTrue(statistics.isIfNotExists()); + assertEquals(List.of(CreateStatistics.Kind.NDISTINCT), statistics.getKinds()); + statistics.setName("audit.new_stats").setKinds(List.of(CreateStatistics.Kind.DEPENDENCIES)) + .setTable(new Table("other_table")); + statistics.getExpressions().set(0, new Column("new_id")); + assertEquals( + "CREATE STATISTICS IF NOT EXISTS audit.new_stats (dependencies) ON new_id, label FROM other_table", + statistics.toString()); + assertRoundTrip(statistics); + assertEquals(Set.of("other_table"), TablesNamesFinder.findTables(statistics.toString())); + } + + @Test + void statisticsTargetDistinguishesDefault() throws JSQLParserException { + AlterStatistics statistics = + (AlterStatistics) parse("ALTER STATISTICS st SET STATISTICS DEFAULT"); + assertEquals(AlterStatistics.Action.SET_STATISTICS, statistics.getAction()); + assertNull(statistics.getStatistics()); + statistics.setName("new_st").setStatistics(200); + assertEquals("ALTER STATISTICS new_st SET STATISTICS 200", statistics.toString()); + assertRoundTrip(statistics); + assertTrue(TablesNamesFinder.findTables(statistics.toString()).isEmpty()); + } + + @Test + void malformedDefinitionsAndUnknownKindsFail() { + for (String sql : new String[] {"CREATE STATISTICS st ON id FROM t", + "CREATE STATISTICS IF NOT EXISTS ON id,label FROM t", + "CREATE STATISTICS st(unknown) ON id,label FROM t", + "CREATE STATISTICS st(mcv,mcv) ON id,label FROM t", + "CREATE STATISTICS st(mcv) ON (id+1) FROM t", + "CREATE STATISTICS st ON id+1,label FROM t", + "ALTER STATISTICS st SET STATISTICS 10001"}) { + assertThrows(JSQLParserException.class, () -> parse(sql), sql); + } + } + + private static Statement parse(String sql) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); + } + + private static void assertRoundTrip(Statement statement) throws JSQLParserException { + StringBuilder sql = new StringBuilder(); + statement.accept(new StatementDeParser(sql), null); + assertEquals(statement.toString(), sql.toString()); + assertEquals(statement.toString(), parse(sql.toString()).toString()); + } +}