diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index 21ae94db54..557e399093 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -610,6 +610,9 @@ public enum Feature { */ commentOnView, + /** Additional structured catalog targets of COMMENT ON. */ + commentOnIndex, commentOnSchema, commentOnSequence, commentOnDomain, commentOnType, commentOnMaterializedView, commentOnFunction, commentOnConstraint, + /** * SQL "DESCRIBE" statement is allowed * diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index ab05725ad5..b7e3fafc32 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -184,7 +184,8 @@ private void adoptAsStatementVisitor(SelectVisitor visitor) { @Override public T visit(Comment comment, S context) { - + comment.visitRelations(table -> table.accept(fromItemVisitor, context)); + expressionVisitor.visitExpression(comment.getComment(), context); return null; } diff --git a/src/main/java/net/sf/jsqlparser/statement/comment/Comment.java b/src/main/java/net/sf/jsqlparser/statement/comment/Comment.java index 5511e8519c..1a5310be0c 100755 --- a/src/main/java/net/sf/jsqlparser/statement/comment/Comment.java +++ b/src/main/java/net/sf/jsqlparser/statement/comment/Comment.java @@ -9,6 +9,7 @@ */ package net.sf.jsqlparser.statement.comment; +import java.util.function.Consumer; import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.schema.Column; import net.sf.jsqlparser.schema.Table; @@ -20,6 +21,7 @@ public class Comment implements Statement { private Table table; private Column column; private Table view; + private CommentTarget target; private StringValue comment; @Override @@ -33,6 +35,9 @@ public Table getTable() { public void setTable(Table table) { this.table = table; + if (table != null) { + target = null; + } } public Column getColumn() { @@ -41,6 +46,9 @@ public Column getColumn() { public void setColumn(Column column) { this.column = column; + if (column != null) { + target = null; + } } public Table getView() { @@ -49,6 +57,39 @@ public Table getView() { public void setView(Table view) { this.view = view; + if (view != null) { + target = null; + } + } + + /** Additional catalog targets; the existing table, column and view accessors remain intact. */ + public CommentTarget getTarget() { + return target; + } + + public void setTarget(CommentTarget target) { + this.target = target; + if (target != null) { + table = null; + column = null; + view = null; + } + } + + public Comment withTarget(CommentTarget target) { + setTarget(target); + return this; + } + + /** Visits the relation explicitly named by this comment, without resolving catalog objects. */ + public void visitRelations(Consumer visitor) { + Table relation = table != null ? table + : column != null ? column.getTable() + : view != null ? view + : target != null ? target.getReferencedRelation() : null; + if (relation != null) { + visitor.accept(relation); + } } public StringValue getComment() { @@ -61,17 +102,37 @@ public void setComment(StringValue comment) { @Override public String toString() { - String sql = "COMMENT ON "; + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append, builder::append, builder::append).toString(); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer
relationWriter, + Consumer columnWriter, Consumer commentWriter) { + builder.append("COMMENT ON "); if (table != null) { - sql += "TABLE " + table + " "; + builder.append("TABLE "); + relationWriter.accept(table); + builder.append(' '); } else if (column != null) { - sql += "COLUMN " + column + " "; + builder.append("COLUMN "); + columnWriter.accept(column); + builder.append(' '); } else if (view != null) { - sql += "VIEW " + view + " "; + builder.append("VIEW "); + relationWriter.accept(view); + builder.append(' '); + } else if (target != null) { + target.appendTo(builder, relationWriter); + builder.append(' '); } // a null comment stands for PostgreSQL's COMMENT ON ... IS NULL, which removes the comment - sql += "IS " + (comment != null ? comment : "NULL"); - return sql; + builder.append("IS "); + if (comment == null) { + builder.append("NULL"); + } else { + commentWriter.accept(comment); + } + return builder; } public Comment withTable(Table table) { diff --git a/src/main/java/net/sf/jsqlparser/statement/comment/CommentTarget.java b/src/main/java/net/sf/jsqlparser/statement/comment/CommentTarget.java new file mode 100644 index 0000000000..7269d29a60 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/comment/CommentTarget.java @@ -0,0 +1,104 @@ +/*- + * #%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.comment; + +import java.io.Serializable; +import java.util.function.Consumer; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.RoutineReference; + +/** A catalog object addressed by COMMENT, rather than a function invocation or a query. */ +public class CommentTarget implements Serializable { + public enum Kind { + INDEX, SCHEMA, SEQUENCE, DOMAIN, TYPE, MATERIALIZED_VIEW, FUNCTION, CONSTRAINT + } + + private Kind kind; + private Table name; + private RoutineReference routine; + private Table relation; + private boolean onDomain; + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = kind; + } + + /** The object's identifier; using Table preserves the individual name components. */ + public Table getName() { + return name; + } + + public void setName(Table name) { + this.name = name; + } + + public RoutineReference getRoutine() { + return routine; + } + + public void setRoutine(RoutineReference routine) { + this.routine = routine; + } + + /** The table or domain owning a constraint, distinguished by {@link #isOnDomain()}. */ + public Table getRelation() { + return relation; + } + + public void setRelation(Table relation) { + this.relation = relation; + } + + public boolean isOnDomain() { + return onDomain; + } + + public void setOnDomain(boolean onDomain) { + this.onDomain = onDomain; + } + + /** Returns only an explicitly named table/view, never an index, type, function or domain. */ + public Table getReferencedRelation() { + if (kind == Kind.MATERIALIZED_VIEW) { + return name; + } + return kind == Kind.CONSTRAINT && !onDomain ? relation : null; + } + + public StringBuilder appendTo(StringBuilder builder, Consumer
relationWriter) { + builder.append(kind.name().replace('_', ' ')).append(' '); + if (kind == Kind.FUNCTION) { + builder.append(routine); + } else if (kind == Kind.MATERIALIZED_VIEW) { + relationWriter.accept(name); + } else { + builder.append(name); + if (kind == Kind.CONSTRAINT) { + builder.append(" ON "); + if (onDomain) { + builder.append("DOMAIN ").append(relation); + } else { + relationWriter.accept(relation); + } + } + } + return builder; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 3bf8df2e0b..1668a1e37a 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -2006,15 +2006,7 @@ public void visit(Block block) { @Override public Void visit(Comment comment, S context) { - if (comment.getTable() != null) { - visit(comment.getTable(), context); - } - if (comment.getColumn() != null) { - Table table = comment.getColumn().getTable(); - if (table != null) { - visit(table, context); - } - } + comment.visitRelations(table -> visit(table, 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 69cb9770cb..a69d26352c 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -436,8 +436,10 @@ public StringBuilder visit(Block block, S context) { @Override public StringBuilder visit(Comment comment, S context) { - builder.append(comment.toString()); - return builder; + return comment.appendTo(builder, + table -> table.accept(selectDeParser, context), + column -> column.accept(expressionDeParser, context), + literal -> literal.accept(expressionDeParser, context)); } @Override diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java index d0f00f166d..5663a4031b 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java @@ -90,6 +90,10 @@ public enum PostgresqlVersion implements Version { Feature.commentOnTable, Feature.commentOnColumn, Feature.commentOnView, + Feature.commentOnIndex, Feature.commentOnSchema, Feature.commentOnSequence, + Feature.commentOnDomain, Feature.commentOnType, + Feature.commentOnMaterializedView, + Feature.commentOnFunction, Feature.commentOnConstraint, // https://www.postgresql.org/docs/current/sql-createsequence.html Feature.createSequence, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/CommentValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/CommentValidator.java new file mode 100644 index 0000000000..b5ad37f8bb --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/CommentValidator.java @@ -0,0 +1,53 @@ +/*- + * #%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.util.validation.validator; + +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.statement.comment.Comment; +import net.sf.jsqlparser.statement.comment.CommentTarget; +import net.sf.jsqlparser.util.validation.ValidationCapability; + +public class CommentValidator extends AbstractValidator { + @Override + public void validate(Comment comment) { + for (ValidationCapability capability : getCapabilities()) { + validateFeature(capability, Feature.comment); + validateOptionalFeature(capability, comment.getTable(), Feature.commentOnTable); + validateOptionalFeature(capability, comment.getColumn(), Feature.commentOnColumn); + validateOptionalFeature(capability, comment.getView(), Feature.commentOnView); + if (comment.getTarget() != null) { + validateFeature(capability, targetFeature(comment.getTarget().getKind())); + } + } + } + + private static Feature targetFeature(CommentTarget.Kind kind) { + switch (kind) { + case INDEX: + return Feature.commentOnIndex; + case SCHEMA: + return Feature.commentOnSchema; + case SEQUENCE: + return Feature.commentOnSequence; + case DOMAIN: + return Feature.commentOnDomain; + case TYPE: + return Feature.commentOnType; + case MATERIALIZED_VIEW: + return Feature.commentOnMaterializedView; + case FUNCTION: + return Feature.commentOnFunction; + case CONSTRAINT: + return Feature.commentOnConstraint; + default: + throw new IllegalArgumentException("Unknown COMMENT target: " + kind); + } + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index 6764ef740d..78be78cdb4 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -97,7 +97,6 @@ import net.sf.jsqlparser.statement.update.ParenthesedUpdate; import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.upsert.Upsert; -import net.sf.jsqlparser.util.validation.ValidationCapability; import net.sf.jsqlparser.util.validation.metadata.NamedObject; /** @@ -322,12 +321,7 @@ public Void visit(Block block, S context) { @Override public Void visit(Comment comment, S context) { - for (ValidationCapability c : getCapabilities()) { - validateFeature(c, Feature.comment); - validateOptionalFeature(c, comment.getTable(), Feature.commentOnTable); - validateOptionalFeature(c, comment.getColumn(), Feature.commentOnColumn); - validateOptionalFeature(c, comment.getView(), Feature.commentOnView); - } + getValidator(CommentValidator.class).validate(comment); return null; } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4ab8d15051..ed9246a718 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -16343,6 +16343,7 @@ Commit Commit(): Comment Comment(): { + CommentTarget target; Comment result = new Comment(); Table table; Table view; @@ -16363,6 +16364,8 @@ Comment Comment(): ( view = Table() { result.setView(view); } ) + | + target=CommentTarget() { result.setTarget(target); } ) ( @@ -16374,6 +16377,47 @@ Comment Comment(): } } +/** Catalog names are already tokenized into parts; quoted dots are part of one identifier. */ +Table CommentObjectName(): +{ ObjectNames names; } +{ + names=RelObjectNames() + { return names.getNames().size() == 1 ? new Table(names.getNames().get(0), false) + : new Table(names.getNames()); } +} + +CommentTarget CommentTarget(): +{ + CommentTarget result = new CommentTarget(); + Table name; + String constraintName; + RoutineReference routine; +} +{ + ( + routine=RoutineReference(false) + { result.setKind(CommentTarget.Kind.FUNCTION); result.setRoutine(routine); } + | + constraintName=RelObjectName() + { result.setKind(CommentTarget.Kind.CONSTRAINT); result.setName(new Table(constraintName, false)); } + + [ LOOKAHEAD({ getToken(1).kind == K_DOMAIN && !".".equals(getToken(2).image) + && getToken(2).kind != K_IS }) { result.setOnDomain(true); } ] + name=CommentObjectName() { result.setRelation(name); } + | + ( + { result.setKind(CommentTarget.Kind.INDEX); } + | { result.setKind(CommentTarget.Kind.SCHEMA); } + | { result.setKind(CommentTarget.Kind.SEQUENCE); } + | { result.setKind(CommentTarget.Kind.MATERIALIZED_VIEW); } + | { result.setKind(CommentTarget.Kind.DOMAIN); } + | { result.setKind(CommentTarget.Kind.TYPE); } + ) + name=CommentObjectName() { result.setName(name); } + ) + { return result; } +} + /** Consumes a keyword that remains an ordinary identifier outside this grammar context. */ void ContextualKeyword(String expected): { Token keyword; } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 814888befb..13a14014b9 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1063,3 +1063,37 @@ The option is disabled by default and does not enable this syntax in other diale returns each explicit direction, or null when omitted. Directions follow list positions; replacing the expression list clears them. Validators report the separate ``selectGroupByOrdering`` feature, which is not enabled in the MySQL 8.0 capability. + +PostgreSQL COMMENT targets +========================= + +``COMMENT ON`` supports ``INDEX``, ``SCHEMA``, ``SEQUENCE``, ``DOMAIN``, ``TYPE``, +``MATERIALIZED VIEW``, ``FUNCTION`` and ``CONSTRAINT`` in addition to the existing +``TABLE``, ``COLUMN`` and ``VIEW`` forms. These unambiguous target forms also parse +without a dialect preset. Tagged dollar strings still require +``Dialect.POSTGRESQL`` or ``withDollarQuotedStringTags(true)``. + +The additional targets are exposed through ``Comment.getTarget()`` as a +``CommentTarget``. Its ``Kind`` identifies the object, and ``getName()`` preserves +the individual identifier components. ``Table`` is used as the name container; +an index or type name does not thereby represent a table dependency. +The original ``Comment.getTable()``, ``getColumn()`` and ``getView()`` accessors +continue to describe their respective existing forms. + +``COMMENT ON FUNCTION app.f(IN value integer) IS 'description'`` uses the shared +``RoutineReference`` in ``getTarget().getRoutine()``. Argument mode, optional name +and ``ColDataType`` are preserved. An omitted signature has null arguments; +``f()`` has an empty argument list. The signature identifies a function and is not +a function call. + +For ``COMMENT ON CONSTRAINT ck ON app.t IS NULL``, ``getName()`` is the constraint +name and ``getRelation()`` is its owning table. ``ON DOMAIN app.d`` sets +``isOnDomain()`` and stores the domain as the owner instead. ``NULL`` removes the +comment and remains represented by a null ``Comment.getComment()``. + +Table discovery visits tables, column owners, views, materialized views and +table-owned constraints. It does not infer an index's table or treat domains, +sequences, types or functions as tables. Statement visitors visit comment +literals, and custom SQL deparsers can replace the explicit relation or literal. +Feature analysis reports a schema modification. Validation exposes a separate +``commentOn...`` capability for each additional target kind. diff --git a/src/test/java/net/sf/jsqlparser/statement/comment/CommentTargetTest.java b/src/test/java/net/sf/jsqlparser/statement/comment/CommentTargetTest.java new file mode 100644 index 0000000000..67f2dddf3f --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/comment/CommentTargetTest.java @@ -0,0 +1,260 @@ +/*- + * #%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.comment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.StringValue; +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.RoutineReference; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.select.SelectVisitorAdapter; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +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.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class CommentTargetTest { + static Stream names() { + List cases = new ArrayList<>(); + for (CommentTarget.Kind kind : List.of(CommentTarget.Kind.INDEX, CommentTarget.Kind.SCHEMA, + CommentTarget.Kind.SEQUENCE, CommentTarget.Kind.DOMAIN, CommentTarget.Kind.TYPE, + CommentTarget.Kind.MATERIALIZED_VIEW)) { + for (String name : List.of("obj", "\"a.b\"", "\"a\"\"b.c\"", "\"한.글\"", "\".\"")) { + cases.add(Arguments.of(kind, name, null, name)); + } + if (kind != CommentTarget.Kind.SCHEMA) { + cases.add(Arguments.of(kind, "public.\"a.b\"", "public", "\"a.b\"")); + cases.add(Arguments.of(kind, "\"s.p\".\"a.b\"", "\"s.p\"", "\"a.b\"")); + } + } + return cases.stream(); + } + + @ParameterizedTest + @MethodSource("names") + void preservesNamedTargetsAndTheirComponents(CommentTarget.Kind kind, String name, + String schema, String leaf) throws Exception { + for (String literal : List.of("'body'", "$tag$body;\nquote's \\path$tag$", "NULL", "''")) { + String sql = + "COMMENT ON " + kind.name().replace('_', ' ') + " " + name + " IS " + literal; + for (Comment comment : roundTrip(sql)) { + assertNull(comment.getTable()); + assertNull(comment.getColumn()); + assertNull(comment.getView()); + CommentTarget target = comment.getTarget(); + assertEquals(kind, target.getKind()); + assertEquals(leaf, target.getName().getName()); + assertEquals(schema, target.getName().getSchemaName()); + assertEquals(name, target.getName().getFullyQualifiedName()); + if (literal.equals("NULL")) { + assertNull(comment.getComment()); + } else { + assertEquals(literal, comment.getComment().toString()); + } + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"f", "f()", "f(integer)", "f(IN arg integer)", + "f(OUT result text)", "f(INOUT arg integer)", "f(VARIADIC args text[])", + "\"s.p\".\"f.x\"(IN \"arg.x\" pg_catalog.int4[])", + "f(timestamp with time zone, double precision)"}) + void preservesRoutineSignaturesWithoutTreatingThemAsCalls(String signature) throws Exception { + for (Comment comment : roundTrip("COMMENT ON FUNCTION " + signature + " IS 'body'")) { + assertEquals(CommentTarget.Kind.FUNCTION, comment.getTarget().getKind()); + RoutineReference routine = comment.getTarget().getRoutine(); + assertEquals( + signature.contains("(") ? signature.substring(0, signature.indexOf('(')) + : signature, + routine.getName()); + assertEquals(!signature.contains("("), routine.getArguments() == null); + assertTrue(new TablesNamesFinder().getTables(comment).isEmpty()); + assertTrue(comment.getFeatures().modifiesSchema()); + assertFalse(comment.getFeatures().may(StmtFeature.READS_DATA)); + assertFalse(comment.getFeatures().isOpaque()); + assertTrue(comment.getFeatures().getUnresolvedReferences().isEmpty()); + } + } + + @Test + void exposesArgumentModeNameAndDataType() throws Exception { + Comment comment = parse( + "COMMENT ON FUNCTION f(INOUT \"arg.x\" app.custom_type, VARIADIC rest text[]) IS NULL"); + List arguments = comment.getTarget().getRoutine().getArguments(); + assertEquals(RoutineReference.Argument.Mode.INOUT, arguments.get(0).getMode()); + assertEquals("\"arg.x\"", arguments.get(0).getName()); + assertEquals("app.custom_type", arguments.get(0).getDataType().toString()); + assertEquals(RoutineReference.Argument.Mode.VARIADIC, arguments.get(1).getMode()); + assertEquals("rest", arguments.get(1).getName()); + assertEquals("text[]", arguments.get(1).getDataType().toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"t", "\"t.x\"", "public.\"t.x\"", "\"s.p\".\"t.x\""}) + void distinguishesConstraintTablesFromDomains(String owner) throws Exception { + for (boolean domain : List.of(false, true)) { + String sql = "COMMENT ON CONSTRAINT \"c.k\" ON " + (domain ? "DOMAIN " : "") + owner + + " IS $$body$$"; + for (Comment comment : roundTrip(sql)) { + CommentTarget target = comment.getTarget(); + assertEquals(CommentTarget.Kind.CONSTRAINT, target.getKind()); + assertEquals("\"c.k\"", target.getName().getName()); + assertNull(target.getName().getSchemaName()); + assertEquals(domain, target.isOnDomain()); + assertEquals(owner, target.getRelation().getFullyQualifiedName()); + assertEquals(domain ? Set.of() : Set.of(owner), + new TablesNamesFinder().getTables(comment)); + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"domain", "domain.t", "\"DOMAIN\""}) + void domainKeywordCanNameTheConstraintTable(String name) throws Exception { + for (Comment comment : roundTrip("COMMENT ON CONSTRAINT ck ON " + name + " IS NULL")) { + assertFalse(comment.getTarget().isOnDomain()); + assertEquals(name, comment.getTarget().getRelation().getFullyQualifiedName()); + assertEquals(Set.of(name), new TablesNamesFinder().getTables(comment)); + } + } + + @ParameterizedTest + @ValueSource(strings = {"INDEX", "MATERIALIZED obj", "MATERIALIZED VIEW", "FUNCTION f(*)", + "FUNCTION f(integer ORDER BY integer)", "FUNCTION f(IN)", + "FUNCTION f(x integer DEFAULT 1)", + "CONSTRAINT ck", "CONSTRAINT ck ON", + "CONSTRAINT public.ck ON t", + "UNKNOWN obj"}) + void rejectsIncompleteOrInvalidTargets(String target) { + assertThrows(JSQLParserException.class, () -> parse("COMMENT ON " + target + " IS NULL")); + } + + @Test + void findsOnlyRelationsExplicitlyNamedByComments() throws Exception { + for (String target : List.of("INDEX idx", "SCHEMA app", "SEQUENCE seq", "DOMAIN d", + "TYPE ty", "FUNCTION f(int)")) { + assertTrue(new TablesNamesFinder().getTables(parse("COMMENT ON " + target + " IS NULL")) + .isEmpty()); + } + for (String target : List.of("TABLE app.t", "COLUMN app.t.c", "VIEW app.t", + "MATERIALIZED VIEW app.t", "CONSTRAINT ck ON app.t")) { + assertEquals(Set.of("app.t"), + new TablesNamesFinder().getTables(parse("COMMENT ON " + target + " IS NULL"))); + } + } + + @Test + void defaultConfigurationAcceptsUnambiguousTargetsButTagsRemainOptIn() throws Exception { + Comment plain = (Comment) CCJSqlParserUtil.parse("COMMENT ON INDEX idx IS 'body'"); + assertEquals(CommentTarget.Kind.INDEX, plain.getTarget().getKind()); + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse("COMMENT ON INDEX idx IS $tag$body$tag$")); + Comment tagged = (Comment) CCJSqlParserUtil.parse("COMMENT ON INDEX idx IS $tag$body$tag$", + p -> p.withDollarQuotedStringTags(true)); + assertEquals("body", tagged.getComment().getValue()); + } + + @Test + void keepsFollowingStatementsOutsideCommentBodies() throws Exception { + Statements statements = CCJSqlParserUtil.parseStatements( + "COMMENT ON FUNCTION f() IS $tag$body; SELECT 0$tag$; COMMENT ON INDEX idx IS NULL; SELECT 42", + p -> p.withDialect(Dialect.POSTGRESQL)); + assertEquals(3, statements.size()); + assertEquals("body; SELECT 0", ((Comment) statements.get(0)).getComment().getValue()); + assertEquals("SELECT 42", statements.get(2).toString()); + } + + @Test + void retainsLegacyAccessorsAndAllowsSwitchingTargets() throws Exception { + Comment comment = parse("COMMENT ON INDEX idx IS 'body'"); + CommentTarget target = comment.getTarget(); + comment.setTable(new Table("t")); + assertNull(comment.getTarget()); + assertEquals("COMMENT ON TABLE t IS 'body'", comment.toString()); + comment.withTarget(target); + assertNull(comment.getTable()); + assertEquals("COMMENT ON INDEX idx IS 'body'", comment.toString()); + comment.setColumn(new Column(new Table("t"), "c")); + assertNull(comment.getTarget()); + assertEquals("COMMENT ON COLUMN t.c IS 'body'", comment.toString()); + } + + @Test + void expressionVisitorsReceiveCommentLiteralAndContext() throws Exception { + List values = new ArrayList<>(); + ExpressionVisitorAdapter expressions = new ExpressionVisitorAdapter() { + @Override + public Void visit(StringValue value, S context) { + assertEquals("context", context); + values.add(value.getValue()); + return null; + } + }; + parse("COMMENT ON INDEX idx IS 'body'").accept( + new StatementVisitorAdapter<>(new SelectVisitorAdapter<>(expressions)), "context"); + assertEquals(List.of("body"), values); + } + + @Test + void customDeparsersCanRewriteRelationsAndLiterals() throws Exception { + Comment comment = parse("COMMENT ON CONSTRAINT ck ON app.t IS 'body'"); + StringBuilder builder = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(StringValue value, S context) { + assertEquals("context", context); + return getBuilder().append("'changed'"); + } + }; + SelectDeParser selects = new SelectDeParser() { + @Override + public StringBuilder visit(Table table, S context) { + assertEquals("context", context); + return getBuilder().append("app.new_t"); + } + }; + comment.accept(new StatementDeParser(expressions, selects, builder), "context"); + assertEquals("COMMENT ON CONSTRAINT ck ON app.new_t IS 'changed'", builder.toString()); + assertEquals("COMMENT ON CONSTRAINT ck ON app.t IS 'body'", comment.toString()); + } + + private static Comment parse(String sql) throws JSQLParserException { + return (Comment) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); + } + + private static List roundTrip(String sql) throws JSQLParserException { + Comment comment = parse(sql); + StringBuilder builder = new StringBuilder(); + comment.accept(new StatementDeParser(builder), null); + assertEquals(comment.toString(), builder.toString()); + return List.of(comment, parse(comment.toString()), parse(builder.toString())); + } +} diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/CommentValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/CommentValidatorTest.java new file mode 100644 index 0000000000..276e416e91 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/CommentValidatorTest.java @@ -0,0 +1,54 @@ +/*- + * #%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.util.validation.validator; + +import java.util.stream.Stream; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.util.validation.ValidationTestAsserts; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class CommentValidatorTest extends ValidationTestAsserts { + static Stream targets() { + return Stream.of(Arguments.of("INDEX idx", Feature.commentOnIndex), + Arguments.of("SCHEMA app", Feature.commentOnSchema), + Arguments.of("SEQUENCE seq", Feature.commentOnSequence), + Arguments.of("DOMAIN d", Feature.commentOnDomain), + Arguments.of("TYPE ty", Feature.commentOnType), + Arguments.of("MATERIALIZED VIEW mv", Feature.commentOnMaterializedView), + Arguments.of("FUNCTION f(IN x integer)", Feature.commentOnFunction), + Arguments.of("CONSTRAINT ck ON t", Feature.commentOnConstraint), + Arguments.of("CONSTRAINT ck ON DOMAIN d", Feature.commentOnConstraint)); + } + + @ParameterizedTest + @MethodSource("targets") + void validatesEachTargetCapability(String target, Feature feature) { + String sql = "COMMENT ON " + target + " IS 'body'"; + validateNoErrors(sql, 1, DatabaseType.POSTGRESQL); + validateNotAllowed(sql, 1, 1, new FeaturesAllowed("comment only", Feature.comment), + feature); + validateNotAllowed(sql, 1, 1, FeaturesAllowed.DML, Feature.comment, feature); + } + + @Test + void retainsLegacyCommentCapabilities() { + validateNoErrors( + "COMMENT ON TABLE t IS 'body'; COMMENT ON COLUMN t.c IS NULL; COMMENT ON VIEW v IS ''", + 3, DatabaseType.POSTGRESQL, DatabaseType.ORACLE, DatabaseType.H2); + validateNotAllowed("COMMENT ON COLUMN t.c IS NULL", 1, 1, + new FeaturesAllowed("table comments", Feature.comment, Feature.commentOnTable), + Feature.commentOnColumn); + } +}