From 29f5cab98dd2e47344d374cc0a0b3df2b09f3fd7 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 18:25:56 +0900 Subject: [PATCH] feat: parse COMMENT ON operators, operator classes, text search objects and large objects Extend CommentTarget with the PostgreSQL COMMENT ON kinds that still failed: OPERATOR with its operand types (NONE for a missing operand), OPERATOR CLASS and OPERATOR FAMILY with their access method, LARGE OBJECT with its OID, TEXT SEARCH CONFIGURATION, DICTIONARY, PARSER and TEMPLATE, CONVERSION, and TRANSFORM FOR type LANGUAGE lang. COMMENT ON OPERATOR reuses the operator reference shared with exclusion constraints and ALTER EXTENSION. That production now accepts the JSON, pattern, bitwise and geometric operators, and joins operator parts that the lexer splits but that touch each other, such as <<= or -|-. The new kind words stay non-reserved. Signed-off-by: minleejae --- .../sf/jsqlparser/parser/feature/Feature.java | 9 + .../statement/comment/CommentTarget.java | 108 ++++++++- .../create/table/ExclusionOperator.java | 5 +- .../validation/feature/PostgresqlVersion.java | 6 + .../validator/CommentValidator.java | 20 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 102 ++++++++- src/site/sphinx/usage.rst | 28 ++- .../PostgreSqlExclusionOperatorTest.java | 2 +- .../statement/comment/CommentTargetTest.java | 212 +++++++++++++++++- .../validator/CommentValidatorTest.java | 14 +- src/test/resources/postgresql/type-ddl.sql | 2 + 11 files changed, 490 insertions(+), 18 deletions(-) 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 2cf247d4ba..fc70c4ae09 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -678,6 +678,15 @@ public enum Feature { /** COMMENT ON EVENT TRIGGER, PUBLICATION or SUBSCRIPTION. */ commentOnEventTrigger, commentOnPublication, commentOnSubscription, + /** COMMENT ON OPERATOR, OPERATOR CLASS or OPERATOR FAMILY. */ + commentOnOperator, commentOnOperatorClass, commentOnOperatorFamily, + + /** COMMENT ON CONVERSION, LARGE OBJECT or TRANSFORM. */ + commentOnConversion, commentOnLargeObject, commentOnTransform, + + /** COMMENT ON TEXT SEARCH CONFIGURATION, DICTIONARY, PARSER or TEMPLATE. */ + commentOnTextSearchConfiguration, commentOnTextSearchDictionary, commentOnTextSearchParser, commentOnTextSearchTemplate, + /** * SQL "DESCRIBE" statement is allowed * diff --git a/src/main/java/net/sf/jsqlparser/statement/comment/CommentTarget.java b/src/main/java/net/sf/jsqlparser/statement/comment/CommentTarget.java index 73ee4e359e..5cd3e0009d 100644 --- a/src/main/java/net/sf/jsqlparser/statement/comment/CommentTarget.java +++ b/src/main/java/net/sf/jsqlparser/statement/comment/CommentTarget.java @@ -14,6 +14,7 @@ import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.RoutineReference; import net.sf.jsqlparser.statement.create.table.ColDataType; +import net.sf.jsqlparser.statement.create.table.ExclusionOperator; /** A catalog object addressed by COMMENT, rather than a function invocation or a query. */ public class CommentTarget implements Serializable { @@ -24,7 +25,11 @@ public enum Kind { COLLATION, FOREIGN_TABLE, STATISTICS, DATABASE, ROLE, TABLESPACE, ACCESS_METHOD, EXTENSION, - FOREIGN_DATA_WRAPPER, LANGUAGE, SERVER, EVENT_TRIGGER, PUBLICATION, SUBSCRIPTION; + FOREIGN_DATA_WRAPPER, LANGUAGE, SERVER, EVENT_TRIGGER, PUBLICATION, SUBSCRIPTION, + + OPERATOR, OPERATOR_CLASS, OPERATOR_FAMILY, LARGE_OBJECT, CONVERSION, TRANSFORM, + + TEXT_SEARCH_CONFIGURATION, TEXT_SEARCH_DICTIONARY, TEXT_SEARCH_PARSER, TEXT_SEARCH_TEMPLATE; /** * Whether the target is a routine signature, exposed by {@link CommentTarget#getRoutine()}. @@ -40,6 +45,14 @@ public boolean isRoutine() { public boolean isOwnedByRelation() { return this == CONSTRAINT || this == TRIGGER || this == POLICY || this == RULE; } + + /** + * Whether the target is named within the index access method of + * {@link CommentTarget#getAccessMethod()}. + */ + public boolean isOwnedByAccessMethod() { + return this == OPERATOR_CLASS || this == OPERATOR_FAMILY; + } } private Kind kind; @@ -50,6 +63,13 @@ public boolean isOwnedByRelation() { private boolean procedural; private ColDataType sourceType; private ColDataType targetType; + private ExclusionOperator operator; + private ColDataType leftOperandType; + private ColDataType rightOperandType; + private String accessMethod; + private Long largeObjectOid; + private ColDataType transformType; + private String language; public Kind getKind() { return kind; @@ -60,8 +80,8 @@ public void setKind(Kind kind) { } /** - * The object's identifier; using Table preserves the individual name components. Routine and - * cast targets have no name. + * The object's identifier; using Table preserves the individual name components. Routine, cast, + * operator, large object and transform targets have no name. */ public Table getName() { return name; @@ -126,6 +146,75 @@ public void setTargetType(ColDataType targetType) { this.targetType = targetType; } + /** + * The possibly schema-qualified operator of an {@link Kind#OPERATOR} target, read by the + * operator reference shared with exclusion constraints and ALTER EXTENSION. + */ + public ExclusionOperator getOperator() { + return operator; + } + + public void setOperator(ExclusionOperator operator) { + this.operator = operator; + } + + /** The left operand type of an {@link Kind#OPERATOR} target; null stands for NONE. */ + public ColDataType getLeftOperandType() { + return leftOperandType; + } + + public void setLeftOperandType(ColDataType leftOperandType) { + this.leftOperandType = leftOperandType; + } + + /** The right operand type of an {@link Kind#OPERATOR} target; null stands for NONE. */ + public ColDataType getRightOperandType() { + return rightOperandType; + } + + public void setRightOperandType(ColDataType rightOperandType) { + this.rightOperandType = rightOperandType; + } + + /** + * The index access method of an {@link Kind#OPERATOR_CLASS} or {@link Kind#OPERATOR_FAMILY} + * target. + */ + public String getAccessMethod() { + return accessMethod; + } + + public void setAccessMethod(String accessMethod) { + this.accessMethod = accessMethod; + } + + /** The OID of a {@link Kind#LARGE_OBJECT} target. */ + public Long getLargeObjectOid() { + return largeObjectOid; + } + + public void setLargeObjectOid(Long largeObjectOid) { + this.largeObjectOid = largeObjectOid; + } + + /** The data type of a {@link Kind#TRANSFORM} target. */ + public ColDataType getTransformType() { + return transformType; + } + + public void setTransformType(ColDataType transformType) { + this.transformType = transformType; + } + + /** The procedural language of a {@link Kind#TRANSFORM} target. */ + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + /** * Returns only an explicitly named table, foreign table or view, never an index, type, * function, statistics object or domain. @@ -150,6 +239,13 @@ public StringBuilder appendTo(StringBuilder builder, Consumer relationWri builder.append(routine); } else if (kind == Kind.CAST) { builder.append('(').append(sourceType).append(" AS ").append(targetType).append(')'); + } else if (kind == Kind.OPERATOR) { + builder.append(operator).append(" (").append(operandType(leftOperandType)) + .append(", ").append(operandType(rightOperandType)).append(')'); + } else if (kind == Kind.LARGE_OBJECT) { + builder.append(largeObjectOid); + } else if (kind == Kind.TRANSFORM) { + builder.append("FOR ").append(transformType).append(" LANGUAGE ").append(language); } else if (namesRelation()) { relationWriter.accept(name); } else { @@ -161,11 +257,17 @@ public StringBuilder appendTo(StringBuilder builder, Consumer
relationWri } else { relationWriter.accept(relation); } + } else if (kind.isOwnedByAccessMethod()) { + builder.append(" USING ").append(accessMethod); } } return builder; } + private static Object operandType(ColDataType type) { + return type == null ? "NONE" : type; + } + @Override public String toString() { StringBuilder builder = new StringBuilder(); diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/ExclusionOperator.java b/src/main/java/net/sf/jsqlparser/statement/create/table/ExclusionOperator.java index 5ae3529a9a..182b1e124b 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/ExclusionOperator.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/ExclusionOperator.java @@ -11,7 +11,10 @@ import java.io.Serializable; -/** An exclusion operator, optionally qualified and enclosed in PostgreSQL OPERATOR(...). */ +/** + * An exclusion operator, optionally qualified and enclosed in PostgreSQL OPERATOR(...). COMMENT ON + * OPERATOR uses the same reference without the OPERATOR keyword. + */ public class ExclusionOperator implements Serializable { private String schemaName; private String name; 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 b5de6e7519..8c1adc7646 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 @@ -105,6 +105,12 @@ public enum PostgresqlVersion implements Version { Feature.commentOnLanguage, Feature.commentOnServer, Feature.commentOnEventTrigger, Feature.commentOnPublication, Feature.commentOnSubscription, + Feature.commentOnOperator, Feature.commentOnOperatorClass, + Feature.commentOnOperatorFamily, Feature.commentOnConversion, + Feature.commentOnLargeObject, Feature.commentOnTransform, + Feature.commentOnTextSearchConfiguration, + Feature.commentOnTextSearchDictionary, Feature.commentOnTextSearchParser, + Feature.commentOnTextSearchTemplate, // 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 index b737b39d4e..dcffd3e4a1 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/CommentValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/CommentValidator.java @@ -88,6 +88,26 @@ private static Feature targetFeature(CommentTarget.Kind kind) { return Feature.commentOnPublication; case SUBSCRIPTION: return Feature.commentOnSubscription; + case OPERATOR: + return Feature.commentOnOperator; + case OPERATOR_CLASS: + return Feature.commentOnOperatorClass; + case OPERATOR_FAMILY: + return Feature.commentOnOperatorFamily; + case CONVERSION: + return Feature.commentOnConversion; + case LARGE_OBJECT: + return Feature.commentOnLargeObject; + case TRANSFORM: + return Feature.commentOnTransform; + case TEXT_SEARCH_CONFIGURATION: + return Feature.commentOnTextSearchConfiguration; + case TEXT_SEARCH_DICTIONARY: + return Feature.commentOnTextSearchDictionary; + case TEXT_SEARCH_PARSER: + return Feature.commentOnTextSearchParser; + case TEXT_SEARCH_TEMPLATE: + return Feature.commentOnTextSearchTemplate; default: throw new IllegalArgumentException("Unknown COMMENT target: " + kind); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 97856f1072..58a92ce50e 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -21678,6 +21678,8 @@ CommentTarget CommentTarget(): String objectName; RoutineReference routine; ColDataType type; + ExclusionOperator operator; + Long oid; } { kind=CommentTargetKind(result) { result.setKind(kind); } @@ -21697,6 +21699,24 @@ CommentTarget CommentTarget(): "(" type=ColDataType() { result.setSourceType(type); } type=ColDataType() { result.setTargetType(type); } ")" | + LOOKAHEAD({ kind == CommentTarget.Kind.OPERATOR }) + operator=PostgreSqlOperatorReference() { result.setOperator(operator); } + "(" type=ExtensionOperatorType() { result.setLeftOperandType(type); } + "," type=ExtensionOperatorType() { result.setRightOperandType(type); } ")" + { requireTypeDdlSyntax(result.getLeftOperandType() != null || result.getRightOperandType() != null, + "An operator signature needs an operand type"); } + | + LOOKAHEAD({ kind.isOwnedByAccessMethod() }) + name=CommentObjectName() { result.setName(name); } + objectName=RelObjectName() { result.setAccessMethod(objectName); } + | + LOOKAHEAD({ kind == CommentTarget.Kind.LARGE_OBJECT }) + oid=ObjectIdentifier() { result.setLargeObjectOid(oid); } + | + LOOKAHEAD({ kind == CommentTarget.Kind.TRANSFORM }) + type=ColDataType() { result.setTransformType(type); } + ContextualKeyword("LANGUAGE") objectName=RelObjectName() { result.setLanguage(objectName); } + | name=CommentObjectName() { result.setName(name); } ) { return result; } @@ -21742,10 +21762,44 @@ CommentTarget.Kind CommentTargetKind(CommentTarget target): | LOOKAHEAD({ isKeywordAhead("STATISTICS") }) ContextualKeyword("STATISTICS") { kind = CommentTarget.Kind.STATISTICS; } | LOOKAHEAD({ isKeywordAhead("SUBSCRIPTION") }) ContextualKeyword("SUBSCRIPTION") { kind = CommentTarget.Kind.SUBSCRIPTION; } | { kind = CommentTarget.Kind.TABLESPACE; } + | LOOKAHEAD({ isKeywordAhead("OPERATOR") }) ContextualKeyword("OPERATOR") { kind = CommentTarget.Kind.OPERATOR; } + // a schema named CLASS or FAMILY may qualify an operator, as in class.+ + [ LOOKAHEAD({ (isKeywordAhead("CLASS") || isKeywordAhead("FAMILY")) && !".".equals(getToken(2).image) }) + ( LOOKAHEAD({ isKeywordAhead("CLASS") }) ContextualKeyword("CLASS") { kind = CommentTarget.Kind.OPERATOR_CLASS; } + | ContextualKeyword("FAMILY") { kind = CommentTarget.Kind.OPERATOR_FAMILY; } ) ] + | LOOKAHEAD({ isKeywordAhead("LARGE") }) ContextualKeyword("LARGE") ContextualKeyword("OBJECT") + { kind = CommentTarget.Kind.LARGE_OBJECT; } + | LOOKAHEAD({ isKeywordAhead("CONVERSION") }) ContextualKeyword("CONVERSION") { kind = CommentTarget.Kind.CONVERSION; } + | LOOKAHEAD({ isKeywordAhead("TRANSFORM") }) ContextualKeyword("TRANSFORM") { kind = CommentTarget.Kind.TRANSFORM; } + | LOOKAHEAD({ isKeywordAhead("TEXT") }) + ( + { kind = CommentTarget.Kind.TEXT_SEARCH_PARSER; } + | LOOKAHEAD({ isKeywordAhead("CONFIGURATION") }) ContextualKeyword("CONFIGURATION") + { kind = CommentTarget.Kind.TEXT_SEARCH_CONFIGURATION; } + | LOOKAHEAD({ isKeywordAhead("DICTIONARY") }) ContextualKeyword("DICTIONARY") + { kind = CommentTarget.Kind.TEXT_SEARCH_DICTIONARY; } + | ContextualKeyword("TEMPLATE") { kind = CommentTarget.Kind.TEXT_SEARCH_TEMPLATE; } + ) ) { return kind; } } +/** An unsigned 32-bit PostgreSQL object identifier, such as the OID of a large object. */ +Long ObjectIdentifier(): +{ Token token; long value; } +{ + token= + { + try { + value = Long.parseLong(token.image); + } catch (NumberFormatException exception) { + value = -1; + } + requireTypeDdlSyntax(value >= 0 && value <= 0xFFFFFFFFL, "OID out of range: " + token.image); + return value; + } +} + /** Consumes a keyword that remains an ordinary identifier outside this grammar context. */ void ContextualKeyword(String expected): { Token keyword; } @@ -23201,15 +23255,49 @@ String ExtensionOperatorName(): /** Shared schema-qualified operator spelling used in DDL references. */ ExclusionOperator PostgreSqlOperatorReference(): -{ String schema = null; Token token; } +{ String schema = null; String name; } { [ LOOKAHEAD(RelObjectName() ".") schema=RelObjectName() "." ] - ( token="+" | token="-" | token="*" | token="/" | token="%" | token="=" - | token= | token= | token="<" | token=">" - | token= | token= | token="&&" | token="@>" | token="<@" | token="~" - | token="~*" | token="!~" | token="!~*" | token="?" | token="?|" | token="?&" - | token= ) - { return new ExclusionOperator().withSchemaName(schema).withName(token.image); } + name=PostgreSqlOperatorName() + { return new ExclusionOperator().withSchemaName(schema).withName(name); } +} + +/** + * The lexer splits some PostgreSQL operators, such as <<= or -|-, into several tokens. Parts that + * touch each other are joined, because PostgreSQL reads them as one operator. + */ +String PostgreSqlOperatorName(): +{ StringBuilder name = new StringBuilder(); Token token; } +{ + token=PostgreSqlOperatorPart() { name.append(token.image); } + ( + LOOKAHEAD(1, { getToken(1).specialToken == null && getToken(1).beginLine == getToken(0).endLine + && getToken(1).beginColumn == getToken(0).endColumn + 1 }) + token=PostgreSqlOperatorPart() { name.append(token.image); } + )* + { return name.toString(); } +} + +Token PostgreSqlOperatorPart(): +{ Token token; } +{ + ( + token="+" | token="-" | token="*" | token="/" | token="%" | token= | token= + | token="<" | token=">" | token="=" | token="==" | token= | token= + | token= | token= | token= | token="*=" | token="=*" + | token= | token= | token= | token="|>" | token="&" | token= + | token= | token= | token= | token= + | token="~" | token="~*" | token="!~" | token="!~*" | token="~~" | token="~~*" | token="!~~" | token="!~~*" + | token="~~~" | token="!" | token= | token="@>" | token="<@" | token="@@" | token= + | token="?" | token="?|" | token="?&" | token="->" | token="->>" | token= | token="#>" + | token="#>>" | token= | token="-#" | token="<->" | token="<#>" + ) + { + // PostgreSQL reads "< =" as two operators, while the lexer tolerates whitespace inside it + requireTypeDdlSyntax(!token.image.matches("(?s).*\\s.*"), + "Unexpected whitespace in operator " + token.image); + return token; + } } ColDataType ExtensionOperatorType(): diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 0215f17542..228dc7f0f1 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -2383,16 +2383,22 @@ PostgreSQL COMMENT targets ``TABLE``, ``COLUMN`` and ``VIEW`` forms: - ``INDEX``, ``SCHEMA``, ``SEQUENCE``, ``DOMAIN``, ``TYPE``, ``MATERIALIZED VIEW``, - ``FOREIGN TABLE``, ``COLLATION`` and ``STATISTICS`` + ``FOREIGN TABLE``, ``COLLATION``, ``STATISTICS``, ``CONVERSION`` and + ``TEXT SEARCH {CONFIGURATION | DICTIONARY | PARSER | TEMPLATE}`` - ``ACCESS METHOD``, ``DATABASE``, ``EVENT TRIGGER``, ``EXTENSION``, ``FOREIGN DATA WRAPPER``, ``[PROCEDURAL] LANGUAGE``, ``PUBLICATION``, ``ROLE``, ``SERVER``, ``SUBSCRIPTION`` and ``TABLESPACE`` - ``FUNCTION``, ``PROCEDURE``, ``ROUTINE`` and ``AGGREGATE`` signatures - ``CONSTRAINT``, ``TRIGGER``, ``POLICY`` and ``RULE`` with their owning table - ``CAST (source_type AS target_type)`` +- ``OPERATOR [schema.]op (left_type, right_type)``, where ``NONE`` marks the missing + operand of a unary operator +- ``OPERATOR CLASS`` and ``OPERATOR FAMILY`` with ``USING access_method`` +- ``LARGE OBJECT oid`` and ``TRANSFORM FOR type_name LANGUAGE lang_name`` These unambiguous target forms also parse without a dialect preset. Kind words such as -``ROLE``, ``SERVER`` or ``RULE`` are not reserved and remain ordinary identifiers. +``ROLE``, ``SERVER``, ``RULE``, ``OPERATOR`` or ``CONVERSION`` are not reserved and +remain ordinary identifiers. Tagged dollar strings still require ``Dialect.POSTGRESQL`` or ``withDollarQuotedStringTags(true)``. @@ -2420,6 +2426,24 @@ the domain as the owner instead. A ``CAST`` target exposes ``getSourceType()`` a ``getTargetType()``. ``NULL`` removes the comment and remains represented by a null ``Comment.getComment()``. +``COMMENT ON OPERATOR app.-> (jsonb, NONE)`` reads the operator with the reference +shared by exclusion constraints and ``ALTER EXTENSION``: ``getOperator()`` returns an +``ExclusionOperator`` with ``getSchemaName()`` and the operator symbol in ``getName()``. +``getLeftOperandType()`` and ``getRightOperandType()`` hold the operand types, and null +stands for ``NONE``; at least one operand type is required. The lexer reads some +PostgreSQL operators, such as ``<<=`` or ``-|-``, as several tokens. Parts that touch +each other are joined into one operator, while whitespace or a comment separates two +operators as in PostgreSQL. Exclusion constraints and ``ALTER EXTENSION`` accept the same +operators. Operators that the lexer reads as part of an identifier, such as ``##`` +without ``Dialect.POSTGRESQL``, are not supported. + +``OPERATOR CLASS`` and ``OPERATOR FAMILY`` targets keep their name in ``getName()`` +and the index access method in ``getAccessMethod()``; ``Kind.isOwnedByAccessMethod()`` +identifies these kinds. ``LARGE OBJECT`` exposes ``getLargeObjectOid()``, which +accepts an unsigned 32-bit integer. ``TRANSFORM`` exposes ``getTransformType()`` and +``getLanguage()``. Each ``TEXT SEARCH`` object type has its own ``Kind``, for example +``TEXT_SEARCH_DICTIONARY``. + Table discovery visits tables, column owners, views, materialized views, foreign tables and the tables owning constraints, triggers, policies and rules. It does not infer an index's table or treat domains, sequences, types, routines or other catalog diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlExclusionOperatorTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlExclusionOperatorTest.java index 8be4883a1b..91490616b2 100644 --- a/src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlExclusionOperatorTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/alter/PostgreSqlExclusionOperatorTest.java @@ -26,7 +26,7 @@ class PostgreSqlExclusionOperatorTest { @ParameterizedTest @ValueSource(strings = {"OPERATOR(pg_catalog.&&)", "OPERATOR(&&)", "OPERATOR(\"My.Schema\".&&)", - "OPERATOR(pg_catalog.=)", "&&"}) + "OPERATOR(pg_catalog.=)", "OPERATOR(pg_catalog.-|-)", "OPERATOR(<<=)", "&&"}) void supportsSharedCreateAndAlterGrammar(String operator) throws JSQLParserException { for (String sql : new String[] { "CREATE TABLE t (c INT4RANGE, CONSTRAINT ex EXCLUDE USING gist (c WITH " + operator diff --git a/src/test/java/net/sf/jsqlparser/statement/comment/CommentTargetTest.java b/src/test/java/net/sf/jsqlparser/statement/comment/CommentTargetTest.java index 451c0d2534..e48148fac7 100644 --- a/src/test/java/net/sf/jsqlparser/statement/comment/CommentTargetTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/comment/CommentTargetTest.java @@ -32,6 +32,7 @@ import net.sf.jsqlparser.statement.Statements; import net.sf.jsqlparser.statement.StmtFeature; import net.sf.jsqlparser.statement.create.table.ColDataType; +import net.sf.jsqlparser.statement.create.table.ExclusionOperator; import net.sf.jsqlparser.statement.select.SelectVisitorAdapter; import net.sf.jsqlparser.util.TablesNamesFinder; import net.sf.jsqlparser.util.deparser.ExpressionDeParser; @@ -49,7 +50,9 @@ class CommentTargetTest { CommentTarget.Kind.INDEX, CommentTarget.Kind.SEQUENCE, CommentTarget.Kind.DOMAIN, CommentTarget.Kind.TYPE, CommentTarget.Kind.MATERIALIZED_VIEW, CommentTarget.Kind.FOREIGN_TABLE, CommentTarget.Kind.COLLATION, - CommentTarget.Kind.STATISTICS); + CommentTarget.Kind.STATISTICS, CommentTarget.Kind.CONVERSION, + CommentTarget.Kind.TEXT_SEARCH_CONFIGURATION, CommentTarget.Kind.TEXT_SEARCH_DICTIONARY, + CommentTarget.Kind.TEXT_SEARCH_PARSER, CommentTarget.Kind.TEXT_SEARCH_TEMPLATE); private static final Set UNQUALIFIED = EnumSet.of( CommentTarget.Kind.SCHEMA, CommentTarget.Kind.ACCESS_METHOD, @@ -269,10 +272,136 @@ void recordsProceduralLanguageSpelling() throws Exception { } } + static Stream operators() { + List cases = new ArrayList<>(); + // arithmetic, comparison, bitwise, string, pattern, JSON, array, range and geometric + for (String operator : List.of("+", "-", "*", "/", "%", "^", "|/", "||/", "@", "<", ">", + "=", "<=", ">=", "<>", "!=", "&", "|", "#", "~", "<<", ">>", "||", "~~", "~~*", + "!~~", "!~~*", "~*", "!~", "!~*", "^@", "->", "->>", "#>", "#>>", "#-", "@>", "<@", + "?", "?|", "?&", "@?", "@@", "@@@", "&&", "<->", "<#>", "<=>", "-|-", "<<=", ">>=", + "&<", "&>", "<<|", "|>>", "&<|", "|&>", "<^", ">^", "?#", "?-|", "?||", "~=", + "@-@", "##", "!!", "*=", "*<>", "~>=~")) { + cases.add(Arguments.of(operator, null, operator)); + } + cases.add(Arguments.of("app.+", "app", "+")); + cases.add(Arguments.of("pg_catalog.||", "pg_catalog", "||")); + cases.add(Arguments.of("\"My.Schema\".->>", "\"My.Schema\"", "->>")); + // CLASS and FAMILY only start an operator class or family when no dot follows + cases.add(Arguments.of("class.<<=", "class", "<<=")); + cases.add(Arguments.of("family.-|-", "family", "-|-")); + return cases.stream(); + } + + @ParameterizedTest + @MethodSource("operators") + void preservesOperatorNamesAndOperandTypes(String operator, String schema, String symbol) + throws Exception { + for (Comment comment : roundTrip( + "COMMENT ON OPERATOR " + operator + " (integer, app.t[]) IS 'body'")) { + CommentTarget target = comment.getTarget(); + assertEquals(CommentTarget.Kind.OPERATOR, target.getKind()); + assertNull(target.getName()); + assertEquals(schema, target.getOperator().getSchemaName()); + assertEquals(symbol, target.getOperator().getName()); + assertFalse(target.getOperator().isUseOperatorKeyword()); + assertEquals("integer", target.getLeftOperandType().toString()); + assertEquals("app.t[]", target.getRightOperandType().toString()); + assertTrue(new TablesNamesFinder().getTables(comment).isEmpty()); + } + } + + @Test + void joinsOnlyOperatorPartsThatTouch() throws Exception { + assertEquals("COMMENT ON OPERATOR <<= (inet, inet) IS NULL", + parse("COMMENT ON OPERATOR <<=(inet,inet) IS NULL").toString()); + assertEquals("COMMENT ON OPERATOR - (NONE, integer) IS NULL", + parse("COMMENT ON OPERATOR -/* prefix */(NONE, integer) IS NULL").toString()); + for (String operator : List.of("< =", "< >", "> =", "! =", "+ -", "+/**/-", "-\n|-")) { + assertThrows(JSQLParserException.class, + () -> parse("COMMENT ON OPERATOR " + operator + " (integer, integer) IS NULL"), + operator); + } + } + + @ParameterizedTest + @ValueSource(strings = {"NONE, integer", "integer, NONE", "\"NONE\", none", + "timestamp with time zone, double precision", "NONE, character varying"}) + void representsMissingOperandsAsNull(String operands) throws Exception { + String[] types = operands.split(", "); + for (Comment comment : roundTrip("COMMENT ON OPERATOR - (" + operands + ") IS NULL")) { + CommentTarget target = comment.getTarget(); + assertEquals(types[0].equals("NONE") ? null : types[0], + target.getLeftOperandType() == null ? null + : target.getLeftOperandType().toString()); + assertEquals(types[1].equalsIgnoreCase("NONE") ? null : types[1], + target.getRightOperandType() == null ? null + : target.getRightOperandType().toString()); + assertEquals("OPERATOR - (" + operands.replace("none", "NONE") + ")", + target.toString()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"int4_ops USING btree", "app.\"my.ops\" USING gist", + "\"s.p\".ops USING \"my am\"", "class USING family"}) + void namesOperatorClassesAndFamiliesWithinTheirAccessMethod(String reference) + throws Exception { + String name = reference.substring(0, reference.indexOf(" USING ")); + String method = reference.substring(reference.indexOf(" USING ") + 7); + for (CommentTarget.Kind kind : List.of(CommentTarget.Kind.OPERATOR_CLASS, + CommentTarget.Kind.OPERATOR_FAMILY)) { + String sql = "COMMENT ON " + kind.name().replace('_', ' ') + " " + reference + + " IS 'body'"; + for (Comment comment : roundTrip(sql)) { + CommentTarget target = comment.getTarget(); + assertEquals(kind, target.getKind()); + assertEquals(name, target.getName().getFullyQualifiedName()); + assertEquals(method, target.getAccessMethod()); + assertNull(target.getRelation()); + assertNull(target.getOperator()); + assertEquals(sql, comment.toString()); + assertTrue(new TablesNamesFinder().getTables(comment).isEmpty()); + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"0", "4242", "0004242", "4294967295"}) + void storesLargeObjectOids(String oid) throws Exception { + for (Comment comment : roundTrip("COMMENT ON LARGE OBJECT " + oid + " IS 'lo'")) { + CommentTarget target = comment.getTarget(); + assertEquals(CommentTarget.Kind.LARGE_OBJECT, target.getKind()); + assertEquals(Long.valueOf(oid), target.getLargeObjectOid()); + assertNull(target.getName()); + assertEquals("COMMENT ON LARGE OBJECT " + Long.valueOf(oid) + " IS 'lo'", + comment.toString()); + assertTrue(new TablesNamesFinder().getTables(comment).isEmpty()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"int LANGUAGE sql", "hstore LANGUAGE plpython3u", + "app.\"my.type\"[] LANGUAGE \"my lang\"", + "timestamp with time zone LANGUAGE plperl", "language LANGUAGE language"}) + void preservesTransformTypeAndLanguage(String reference) throws Exception { + String type = reference.substring(0, reference.indexOf(" LANGUAGE ")); + String language = reference.substring(reference.indexOf(" LANGUAGE ") + 10); + for (Comment comment : roundTrip("COMMENT ON TRANSFORM FOR " + reference + " IS NULL")) { + CommentTarget target = comment.getTarget(); + assertEquals(CommentTarget.Kind.TRANSFORM, target.getKind()); + assertEquals(type, target.getTransformType().toString()); + assertEquals(language, target.getLanguage()); + assertNull(target.getName()); + assertNull(target.getSourceType()); + assertTrue(new TablesNamesFinder().getTables(comment).isEmpty()); + } + } + @Test void kindPredicatesDescribeTheReferenceShape() { Set routines = EnumSet.noneOf(CommentTarget.Kind.class); Set owned = EnumSet.noneOf(CommentTarget.Kind.class); + Set accessMethod = EnumSet.noneOf(CommentTarget.Kind.class); for (CommentTarget.Kind kind : CommentTarget.Kind.values()) { if (kind.isRoutine()) { routines.add(kind); @@ -280,11 +409,16 @@ void kindPredicatesDescribeTheReferenceShape() { if (kind.isOwnedByRelation()) { owned.add(kind); } + if (kind.isOwnedByAccessMethod()) { + accessMethod.add(kind); + } } assertEquals(EnumSet.of(CommentTarget.Kind.FUNCTION, CommentTarget.Kind.PROCEDURE, CommentTarget.Kind.ROUTINE, CommentTarget.Kind.AGGREGATE), routines); assertEquals(EnumSet.of(CommentTarget.Kind.CONSTRAINT, CommentTarget.Kind.TRIGGER, CommentTarget.Kind.POLICY, CommentTarget.Kind.RULE), owned); + assertEquals(EnumSet.of(CommentTarget.Kind.OPERATOR_CLASS, + CommentTarget.Kind.OPERATOR_FAMILY), accessMethod); } @ParameterizedTest @@ -302,7 +436,20 @@ void kindPredicatesDescribeTheReferenceShape() { "COMMENT ON PROCEDURE app.p(IN a integer, INOUT b text) IS 'x'", "COMMENT ON ROUTINE r() IS 'x'", "COMMENT ON AGGREGATE percentile_disc(float8 ORDER BY anyelement) IS 'x'", - "COMMENT ON CAST (bigint AS int4) IS 'x'"}) + "COMMENT ON CAST (bigint AS int4) IS 'x'", + "COMMENT ON OPERATOR + (integer, integer) IS 'op'", + "COMMENT ON OPERATOR - (NONE, integer) IS 'prefix op'", + "COMMENT ON OPERATOR pg_catalog.->> (jsonb, text) IS 'x'", + "COMMENT ON OPERATOR app.-|- (int4range, int4range) IS NULL", + "COMMENT ON OPERATOR CLASS int4_ops USING btree IS 'opclass'", + "COMMENT ON OPERATOR FAMILY integer_ops USING btree IS 'opfamily'", + "COMMENT ON LARGE OBJECT 4242 IS 'lo'", + "COMMENT ON TEXT SEARCH CONFIGURATION english IS 'tscfg'", + "COMMENT ON TEXT SEARCH DICTIONARY english_stem IS 'tsdict'", + "COMMENT ON TEXT SEARCH PARSER \"default\" IS 'tsparser'", + "COMMENT ON TEXT SEARCH TEMPLATE simple IS 'tstemplate'", + "COMMENT ON CONVERSION utf8_to_iso_8859_1 IS 'conversion'", + "COMMENT ON TRANSFORM FOR int LANGUAGE sql IS 'transform'"}) void parsesAndDeparsesWithAndWithoutPostgreSqlDialect(String sql) throws Exception { assertSqlCanBeParsedAndDeparsed(sql, false, parser -> parser.withDialect(Dialect.POSTGRESQL)); @@ -325,6 +472,25 @@ void kindWordsRemainIdentifiers() throws Exception { .get(0).getColumn().getFullyQualifiedName()); } + @Test + void operatorAndTextSearchKindWordsRemainIdentifiers() throws Exception { + assertSqlCanBeParsedAndDeparsed("SELECT operator, class, family, large, object, " + + "conversion, transform, configuration, dictionary, parser, template, search, " + + "none FROM operator"); + assertEquals("operator", roundTrip("COMMENT ON TABLE operator IS NULL").get(0).getTable() + .getName()); + assertEquals("conversion.transform", + roundTrip("COMMENT ON COLUMN conversion.transform IS NULL").get(0).getColumn() + .getFullyQualifiedName()); + assertEquals("template", roundTrip("COMMENT ON TEXT SEARCH TEMPLATE template IS NULL") + .get(0).getTarget().getName().getName()); + assertEquals("conversion", roundTrip("COMMENT ON CONVERSION conversion IS NULL").get(0) + .getTarget().getName().getName()); + assertEquals("operator", + roundTrip("COMMENT ON OPERATOR operator.+ (operator, NONE) IS NULL") + .get(0).getTarget().getOperator().getSchemaName()); + } + @Test void rendersProgrammaticTargets() { CommentTarget trigger = new CommentTarget(); @@ -348,6 +514,30 @@ void rendersProgrammaticTargets() { assertEquals("ROLE r", named.toString()); named.setKind(CommentTarget.Kind.LANGUAGE); assertEquals("PROCEDURAL LANGUAGE r", named.toString()); + + CommentTarget operator = new CommentTarget(); + operator.setKind(CommentTarget.Kind.OPERATOR); + operator.setOperator(new ExclusionOperator().withSchemaName("app").withName("->>")); + operator.setLeftOperandType(new ColDataType("jsonb")); + assertEquals("OPERATOR app.->> (jsonb, NONE)", operator.toString()); + + CommentTarget operatorClass = new CommentTarget(); + operatorClass.setKind(CommentTarget.Kind.OPERATOR_FAMILY); + operatorClass.setName(new Table("app", "ops")); + operatorClass.setAccessMethod("gist"); + assertEquals("OPERATOR FAMILY app.ops USING gist", operatorClass.toString()); + + CommentTarget largeObject = new CommentTarget(); + largeObject.setKind(CommentTarget.Kind.LARGE_OBJECT); + largeObject.setLargeObjectOid(4242L); + assertEquals("COMMENT ON LARGE OBJECT 4242 IS NULL", + new Comment().withTarget(largeObject).toString()); + + CommentTarget transform = new CommentTarget(); + transform.setKind(CommentTarget.Kind.TRANSFORM); + transform.setTransformType(new ColDataType("hstore")); + transform.setLanguage("plpython3u"); + assertEquals("TRANSFORM FOR hstore LANGUAGE plpython3u", transform.toString()); } @ParameterizedTest @@ -362,6 +552,18 @@ void rendersProgrammaticTargets() { "AGGREGATE agg", "CAST (integer)", "CAST integer AS text", "CAST (integer AS text", "FOREIGN ft", "FOREIGN DATA fdw", "EVENT et", "ACCESS heap", "ACCESS METHOD", "PROCEDURAL plpgsql", "LANGUAGE", "ROLE", + "OPERATOR", "OPERATOR +", "OPERATOR + ()", "OPERATOR + (integer)", + "OPERATOR + (NONE, NONE)", "OPERATOR + (integer, integer, integer)", + "OPERATOR + integer, integer", "OPERATOR app (integer, integer)", + "OPERATOR app. (integer, integer)", "OPERATOR => (integer, integer)", + "OPERATOR := (integer, integer)", "OPERATOR OPERATOR(pg_catalog.+) (integer, integer)", + "OPERATOR CLASS ops", "OPERATOR CLASS ops USING", "OPERATOR FAMILY ops USING a.b", + "OPERATOR CLASS USING btree", "LARGE OBJECT", "LARGE 42", "LARGE OBJECT lo", + "LARGE OBJECT -1", "LARGE OBJECT 4294967296", "LARGE OBJECT 99999999999999999999", + "LARGE OBJECT 1.5", "LARGE OBJECT 1, 2", "TEXT SEARCH", "TEXT SEARCH english", + "TEXT SEARCH INDEX english", "TEXT CONFIGURATION english", + "TINYTEXT SEARCH PARSER p", "CONVERSION", "TRANSFORM", "TRANSFORM int LANGUAGE sql", + "TRANSFORM FOR int", "TRANSFORM FOR int LANGUAGE", "TRANSFORM FOR int LANGUAGE a.b", "UNKNOWN obj"}) void rejectsIncompleteOrInvalidTargets(String target) { assertThrows(JSQLParserException.class, () -> parse("COMMENT ON " + target + " IS NULL")); @@ -374,7 +576,11 @@ void findsOnlyRelationsExplicitlyNamedByComments() throws Exception { "AGGREGATE a(*)", "CAST (integer AS text)", "COLLATION app.c", "STATISTICS app.st", "ACCESS METHOD am", "DATABASE d", "EVENT TRIGGER et", "EXTENSION e", "FOREIGN DATA WRAPPER w", "LANGUAGE l", "PUBLICATION p", "ROLE r", - "SERVER s", "SUBSCRIPTION s", "TABLESPACE ts")) { + "SERVER s", "SUBSCRIPTION s", "TABLESPACE ts", "OPERATOR app.+ (integer, NONE)", + "OPERATOR CLASS app.t USING btree", "OPERATOR FAMILY app.t USING btree", + "LARGE OBJECT 42", "CONVERSION app.t", "TEXT SEARCH CONFIGURATION app.t", + "TEXT SEARCH DICTIONARY app.t", "TEXT SEARCH PARSER app.t", + "TEXT SEARCH TEMPLATE app.t", "TRANSFORM FOR app.t LANGUAGE sql")) { assertTrue(new TablesNamesFinder().getTables(parse("COMMENT ON " + target + " IS NULL")) .isEmpty()); } 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 index fe26d321e6..5efa977511 100644 --- a/src/test/java/net/sf/jsqlparser/util/validation/validator/CommentValidatorTest.java +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/CommentValidatorTest.java @@ -57,7 +57,19 @@ static Stream targets() { Arguments.of("SERVER s", Feature.commentOnServer), Arguments.of("STATISTICS st", Feature.commentOnStatistics), Arguments.of("SUBSCRIPTION s", Feature.commentOnSubscription), - Arguments.of("TABLESPACE ts", Feature.commentOnTablespace)); + Arguments.of("TABLESPACE ts", Feature.commentOnTablespace), + Arguments.of("OPERATOR - (NONE, integer)", Feature.commentOnOperator), + Arguments.of("OPERATOR CLASS ops USING btree", Feature.commentOnOperatorClass), + Arguments.of("OPERATOR FAMILY ops USING gist", Feature.commentOnOperatorFamily), + Arguments.of("CONVERSION conv", Feature.commentOnConversion), + Arguments.of("LARGE OBJECT 4242", Feature.commentOnLargeObject), + Arguments.of("TRANSFORM FOR hstore LANGUAGE plpython3u", + Feature.commentOnTransform), + Arguments.of("TEXT SEARCH CONFIGURATION cfg", + Feature.commentOnTextSearchConfiguration), + Arguments.of("TEXT SEARCH DICTIONARY dict", Feature.commentOnTextSearchDictionary), + Arguments.of("TEXT SEARCH PARSER prs", Feature.commentOnTextSearchParser), + Arguments.of("TEXT SEARCH TEMPLATE tmpl", Feature.commentOnTextSearchTemplate)); } @Test diff --git a/src/test/resources/postgresql/type-ddl.sql b/src/test/resources/postgresql/type-ddl.sql index 375b79446c..943260e4de 100644 --- a/src/test/resources/postgresql/type-ddl.sql +++ b/src/test/resources/postgresql/type-ddl.sql @@ -82,3 +82,5 @@ ALTER EXTENSION hstore ADD TRANSFORM FOR hstore LANGUAGE plpython3u ALTER EXTENSION hstore ADD OPERATOR app.&& (integer, integer) ALTER EXTENSION hstore DROP OPERATOR = (hstore, hstore) ALTER EXTENSION hstore ADD OPERATOR - (NONE, integer) +ALTER EXTENSION hstore ADD OPERATOR app.-> (hstore, text) +ALTER EXTENSION hstore DROP OPERATOR <<= (inet, inet)