From 7fa9a39e467048bba7315bc59701dfa690197408 Mon Sep 17 00:00:00 2001 From: minleejae Date: Mon, 14 Sep 2026 14:13:53 +0900 Subject: [PATCH] fix: preserve PostgreSQL quoted names, numeric scales and comment literals --- .../java/net/sf/jsqlparser/schema/Table.java | 11 +- .../statement/create/table/ColDataType.java | 25 ++- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 131 ++++++++++---- src/site/sphinx/usage.rst | 33 +++- .../parser/PostgreSqlNumericTypeTest.java | 119 ++++++++++++ .../PostgreSqlQuotedIdentifierTest.java | 169 ++++++++++++++++++ .../comment/PostgreSqlCommentLiteralTest.java | 122 +++++++++++++ 7 files changed, 564 insertions(+), 46 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/parser/PostgreSqlNumericTypeTest.java create mode 100644 src/test/java/net/sf/jsqlparser/parser/PostgreSqlQuotedIdentifierTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/comment/PostgreSqlCommentLiteralTest.java diff --git a/src/main/java/net/sf/jsqlparser/schema/Table.java b/src/main/java/net/sf/jsqlparser/schema/Table.java index b79abbde7..e1d5327c2 100644 --- a/src/main/java/net/sf/jsqlparser/schema/Table.java +++ b/src/main/java/net/sf/jsqlparser/schema/Table.java @@ -529,7 +529,7 @@ public Table getResolvedTable() { public Table setResolvedTable(Table resolvedTable) { // clone, not reference if (resolvedTable != null) { - this.resolvedTable = new Table(resolvedTable.getFullyQualifiedName()); + this.resolvedTable = resolvedTable.copyName(); } return this; } @@ -579,9 +579,16 @@ public static Table[] setUnsetCatalogAndSchema(String currentCatalogName, @Override public Table clone() { - Table clone = new Table(this.getFullyQualifiedName()); + Table clone = copyName(); clone.setTableVariable(tableVariable); clone.setResolvedTable(this.resolvedTable != null ? this.resolvedTable.clone() : null); return clone; } + + private Table copyName() { + Table copy = new Table(); + copy.partItems = new ArrayList<>(partItems); + copy.partDelimiters = new ArrayList<>(partDelimiters); + return copy; + } } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/ColDataType.java b/src/main/java/net/sf/jsqlparser/statement/create/table/ColDataType.java index 77a95f4cc..69857e7c5 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/ColDataType.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/ColDataType.java @@ -54,12 +54,27 @@ public ColDataType() { } public ColDataType(String dataType, int precision, int scale) { - this.dataType = dataType; + this(dataType); + setNumericParameters(precision < 0 ? null : Integer.valueOf(precision), + scale < 0 ? null : Integer.valueOf(scale)); + } + + /** + * Creates a parameterized type, using {@code null} for an omitted parameter. Unlike the legacy + * primitive constructor, this accepts negative scales, including {@code -1}. + */ + public static ColDataType fromNumericParameters(String dataType, Integer precision, + Integer scale) { + ColDataType type = new ColDataType(dataType); + type.setNumericParameters(precision, scale); + return type; + } - if (precision >= 0) { + private void setNumericParameters(Integer precision, Integer scale) { + if (precision != null) { this.precision = precision; this.dataType += " (" + (precision == Integer.MAX_VALUE ? "MAX" : precision); - if (scale >= 0) { + if (scale != null) { this.scale = scale; this.dataType += ", " + scale; } @@ -211,8 +226,8 @@ public void setPrecision(Integer precision) { } /** - * The second numeric type parameter, e.g. {@code 2} for {@code DECIMAL(10, 2)}. Returns - * {@code null} when absent. + * The second numeric type parameter, e.g. {@code 2} for {@code DECIMAL(10, 2)} or {@code -3} + * for PostgreSQL {@code NUMERIC(2, -3)}. Returns {@code null} when absent. */ public Integer getScale() { return scale; diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4ab8d1505..2258c4078 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1628,6 +1628,29 @@ public class CCJSqlParser extends AbstractJSqlParser { return false; } + /** Builds names from lexer-separated components without reinterpreting PostgreSQL dots. */ + protected Table createTable(List names) { + return Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) && names.size() == 1 + ? new Table(names.get(0), false) : new Table(names); + } + + protected Column createColumn(List names, List delimiters) { + if (!Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect))) { + return new Column(names, delimiters); + } + Column column = new Column(); + column.setName(names.get(names.size() - 1), false); + if (names.size() > 1) { + column.setTable(createTable(names.subList(0, names.size() - 1))); + } + column.setTableDelimiter(delimiters.isEmpty() ? "." : delimiters.get(delimiters.size() - 1)); + return column; + } + + protected Column createColumn(String name) { + return createColumn(Collections.singletonList(name), Collections.emptyList()); + } + /** * Lightweight lookahead for SpecialStringFunctionWithNamedParameters: * scans forward from the current position (just past the opening '(') @@ -1675,6 +1698,11 @@ public class CCJSqlParser extends AbstractJSqlParser { if (getToken(1).kind != S_CHAR_LITERAL) { return false; } + if (Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) + && (StringValue.getDollarQuoteDelimiter(getToken(0).image) != null + || !getToken(1).image.startsWith("'"))) { + return false; + } return AdjacentStringLiterals.WHITESPACE.name().equals(mode) || getToken(1).beginLine > getToken(0).endLine; } @@ -2690,7 +2718,9 @@ TOKEN: // which contains the , then we will need to // 1) break the at close it with a "'" // 2) continue tokenizing after that with a new or any other Token - boolean allowEscape = configuration.getAsBoolean(Feature.allowBackslashEscapeCharacter); + boolean allowEscape = configuration.getAsBoolean(Feature.allowBackslashEscapeCharacter) + || (AbstractJSqlParser.Dialect.POSTGRESQL.name().equals(configuration.getValue(Feature.dialect)) + && matchedToken.image.regionMatches(true, 0, "E'", 0, 2)); String img = matchedToken.image; int pos; if (!allowEscape) { @@ -5277,8 +5307,8 @@ Index.ColumnParams InsertConflictIndexElement(): | "(" expression=Expression() ")" { element = new Index.ColumnParams(expression); } ) [ LOOKAHEAD(2) collation=RelObjectNames() - { element.setCollation(new Table(collation.getNames()).getFullyQualifiedName()); } ] - [ LOOKAHEAD(2) operatorClass=RelObjectNames() { element.setOperatorClass(new Table(operatorClass.getNames()).getFullyQualifiedName()); } ] + { element.setCollation(createTable(collation.getNames()).getFullyQualifiedName()); } ] + [ LOOKAHEAD(2) operatorClass=RelObjectNames() { element.setOperatorClass(createTable(operatorClass.getNames()).getFullyQualifiedName()); } ] { return element; } } @@ -5656,7 +5686,7 @@ Column Column() #Column : [ LOOKAHEAD(2) arrayConstructor = ArrayConstructor(false) ] { - Column col = new Column(data.getNames(), data.getDelimiters()); + Column col = createColumn(data.getNames(), data.getDelimiters()); if (tk != null) { col.withCommentText(tk.image); } if (arrayConstructor!=null) { col.setArrayConstructor(arrayConstructor); @@ -5742,7 +5772,7 @@ Table Table() #TableName : ( data = RelObjectNames() [ LOOKAHEAD(2) timeTravelStr = TimeTravelBeforeAlias() ] { - table = new Table(data.getNames()); + table = createTable(data.getNames()); table.setTimeTravel(timeTravelStr); } | @@ -6951,8 +6981,8 @@ WithCycleClause WithCycleClause() #WithCycleClause: } { - name=RelObjectName() { columns.add(new Column(name)); } - ( "," name=RelObjectName() { columns.add(new Column(name)); } )* + name=RelObjectName() { columns.add(createColumn(name)); } + ( "," name=RelObjectName() { columns.add(createColumn(name)); } )* name=RelObjectName() { cycle.setMarkColumnName(name); } [ value=Expression() { cycle.setMarkValue(value); } value=Expression() { cycle.setMarkDefault(value); } ] @@ -9500,6 +9530,20 @@ Expression NavigationStep(Expression base): { return step; } } +StringValue CharacterLiteral(): +{ + Token literal; + StringValue value; +} +{ + literal= { value = new StringValue(literal.image); } + ( LOOKAHEAD({ isAdjacentStringConcat() }) + literal= + { value.setValue(value.getValue() + new StringValue(literal.image).getValue()); } + )* + { return value; } +} + Expression PrimaryExpression() #PrimaryExpression: { Expression retval = null; @@ -9510,7 +9554,6 @@ Expression PrimaryExpression() #PrimaryExpression: Expression timezoneRightExpr = null; Token token = null; Token sign = null; - Token adjacentToken = null; ColDataType type = null; boolean not = false; boolean exclamationMarkNot = false; @@ -9609,14 +9652,7 @@ Expression PrimaryExpression() #PrimaryExpression: | LOOKAHEAD(2, {!interrupted}) (token= | token=) { retval = new BooleanValue(token.image); } - | token= { retval = new StringValue(token.image); linkAST(retval,jjtThis); } - ( LOOKAHEAD({ isAdjacentStringConcat() }) - adjacentToken= - { - ((StringValue) retval) - .setValue(((StringValue) retval).getValue() + new StringValue(adjacentToken.image).getValue()); - } - )* + | retval=CharacterLiteral() { linkAST(retval,jjtThis); } | "{d" token= "}" { retval = new DateValue(token.image); } @@ -11404,7 +11440,7 @@ LambdaExpression LambdaExpression() #LambdaExpression: ( columns = ParenthesedColumnList() | - s = RelObjectName() { columns = new ExpressionList(new Column(s)); } + s = RelObjectName() { columns = new ExpressionList(createColumn(s)); } ) "->" @@ -13629,14 +13665,15 @@ ColDataType DataType(): ColDataType arrayType; Integer zonePrecision = null; - int precision = -1; - int scale = -1; + Integer precision = null; + Integer scale = null; + String scaleText; } { ( LOOKAHEAD(2) tk= { type = tk.image; - return new ColDataType(type, precision, scale); + return ColDataType.fromNumericParameters(type, precision, scale); } | LOOKAHEAD(2) tk= ( @@ -13665,11 +13702,11 @@ ColDataType DataType(): [ LOOKAHEAD(2) "(" ( tk= { precision = Integer.valueOf(tk.image); } | tk= { precision = Integer.MAX_VALUE; } ) [ | ] - [ "," tk = { scale = Integer.valueOf(tk.image); } ] + [ "," scaleText=SignedIntegerTypeArgument() { scale = Integer.valueOf(scaleText); } ] ")" ] { - colDataType = new ColDataType(type, precision, scale); + colDataType = ColDataType.fromNumericParameters(type, precision, scale); if (zonePrecision != null) { colDataType.setPrecision(zonePrecision); } @@ -13698,6 +13735,17 @@ String LargeObjectTypeName(): + type.image + " " + large.image + " " + object.image; } } +/** Type modifiers accept negative integer constants, but not unary-plus expressions. */ +String SignedIntegerTypeArgument(): +{ + Token sign = null; + Token number; +} +{ + [ sign="-" ] number= + { return (sign == null ? "" : sign.image) + number.image; } +} + ColDataType ColDataType(): { ColDataType colDataType = new ColDataType(); @@ -13711,6 +13759,7 @@ ColDataType ColDataType(): List name; ColDataType arrayType; ColDataType nestedType = null; + String numericArgument = null; IntervalQualifier intervalQualifier = null; Integer zonePrecision = null; @@ -13808,19 +13857,24 @@ ColDataType ColDataType(): && getToken(1).kind == K_DATE_LITERAL }) intervalQualifier = IntervalQualifier() { colDataType.setIntervalQualifier(intervalQualifier); } ] - [ LOOKAHEAD(2) "." arrayType = ColDataType() { schema += "." + arrayType.toString(); } ] + [ LOOKAHEAD(2) "." arrayType = ColDataType() { + schema += "." + arrayType.toString(); + colDataType.setPrecision(arrayType.getPrecision()); + colDataType.setScale(arrayType.getScale()); + } ] { colDataType.setDataType(schema); } ) [ - LOOKAHEAD(2) "(" {tk2 =null;} + LOOKAHEAD(2) "(" ( + { tk2 = null; numericArgument = null; } ( LOOKAHEAD(( | ) "(") nestedType=ColDataType() | ( ( - ( tk= | tk= ) [ LOOKAHEAD(2) (tk2= | tk2=) ] + ( numericArgument=SignedIntegerTypeArgument() | tk= ) [ LOOKAHEAD(2) (tk2= | tk2=) ] ) | tk= @@ -13835,7 +13889,8 @@ ColDataType ColDataType(): argumentsStringList.add(nestedType.toString()); nestedType = null; } else { - argumentsStringList.add(tk.image + (tk2!=null?" " + tk2.image:"")); + argumentsStringList.add((numericArgument != null ? numericArgument : tk.image) + + (tk2 != null ? " " + tk2.image : "")); } } @@ -13853,11 +13908,11 @@ ColDataType ColDataType(): "Typed XML cannot have additional type arguments"); if (argumentsStringList.size() > 0) { colDataType.setArgumentsStringList(argumentsStringList); - // Digits-only arguments are the type's numeric parameters, e.g. mediumint(9). + // Integer arguments expose numeric parameters, including negative scales. if (argumentsStringList.size() == 1 && argumentsStringList.get(0).matches("\\d+")) { colDataType.setPrecision(Integer.valueOf(argumentsStringList.get(0))); } else if (argumentsStringList.size() == 2 && argumentsStringList.get(0).matches("\\d+") - && argumentsStringList.get(1).matches("\\d+")) { + && argumentsStringList.get(1).matches("-?\\d+")) { colDataType.setPrecision(Integer.valueOf(argumentsStringList.get(0))); colDataType.setScale(Integer.valueOf(argumentsStringList.get(1))); } @@ -15238,7 +15293,7 @@ DefaultConstraint DefaultConstraintSpec(): { [ name=RelObjectName() { constraint.setName(name); } ] expression=Expression() { constraint.setExpression(expression); } - name=RelObjectName() { constraint.setColumn(new Column(name)); } + name=RelObjectName() { constraint.setColumn(createColumn(name)); } // See InsertBulk(): is in the FOLLOW set of every statement tail. WITH VALUES wins // over a hypothetical CTE named VALUES, which is the useful reading here. [ LOOKAHEAD(2) { constraint.setWithValues(true); } ] @@ -16347,7 +16402,7 @@ Comment Comment(): Table table; Table view; Column column; - Token comment; + StringValue comment; } { @@ -16366,7 +16421,7 @@ Comment Comment(): ) ( - comment= { result.setComment(new StringValue(comment.image)); } + comment=CharacterLiteral() { result.setComment(comment); } | ) { @@ -16661,8 +16716,8 @@ TriggerEvent PostgreSqlTriggerEvent(): | { result = new TriggerEvent(CreateTrigger.Event.TRUNCATE); } | { result = new TriggerEvent(CreateTrigger.Event.UPDATE); } [ { columns = new ExpressionList(); } - name=RelObjectName() { columns.add(new Column(name)); } - ( "," name=RelObjectName() { columns.add(new Column(name)); } )* + name=RelObjectName() { columns.add(createColumn(name)); } + ( "," name=RelObjectName() { columns.add(createColumn(name)); } )* { result.setColumns(columns); } ] ) { return result; } @@ -16683,7 +16738,7 @@ CreateTrigger PostgreSqlTrigger(boolean replace): } { [ { result.setConstraint(true); } ] - name=RelObjectName() { result.setTrigger(new Table(name)); result.setOrReplace(replace); } + name=RelObjectName() { result.setTrigger(createTable(Collections.singletonList(name))); result.setOrReplace(replace); } ( LOOKAHEAD({ isAccessKeywordAhead("INSTEAD") }) AccessKeyword("INSTEAD") { result.setTiming(CreateTrigger.Timing.INSTEAD_OF); } | timing=MySqlTriggerTiming() { result.setTiming(timing); } @@ -16726,7 +16781,7 @@ Expression PostgreSqlTriggerArgument(): token= { result = new StringValue(token.image); } | token= { result = new LongValue(token.image); } | token= { result = new DoubleValue(token.image); } - | name=RelObjectName() { result = new Column(name); } + | name=RelObjectName() { result = createColumn(name); } ) { return result; } } @@ -17113,7 +17168,7 @@ Expression ExtensionVersion(): { Token token; String name; Expression result; } { ( token= { result = new StringValue(token.image); } - | name=RelObjectName() { result = new Column(name); } ) + | name=RelObjectName() { result = createColumn(name); } ) { return result; } } @@ -17489,8 +17544,8 @@ PublicationTable PublicationTable(boolean drop): table=Table() { result.setTable(table); } [ "*" { result.setIncludeDescendants(true); } ] [ LOOKAHEAD(1) "(" { columns = new ExpressionList(); } - name=RelObjectName() { columns.add(new Column(name)); } - ( "," name=RelObjectName() { columns.add(new Column(name)); } )* ")" + name=RelObjectName() { columns.add(createColumn(name)); } + ( "," name=RelObjectName() { columns.add(createColumn(name)); } )* ")" { result.setColumns(columns); } ] [ "(" expression=Expression() ")" { result.setWhere(expression); } ] { diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 814888bef..eae64c949 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -722,7 +722,9 @@ One grammar covers every supported RDBMS, but a few pieces of syntax mean differ - ``withBackslashEscapeCharacter``, ``withHashLineComments``, ``withDoubleQuotedStrings`` (MySQL and MariaDB, the last for the default ``sql_mode``) * - ``SQLSERVER`` - ``withSquareBracketQuotation`` and ``CLUSTERED`` / ``NONCLUSTERED`` options on table-level primary key and unique constraints and ``CREATE INDEX`` - * - ``POSTGRESQL``, ``ANSI_SQL`` + * - ``POSTGRESQL`` + - tagged dollar strings, the newline rule for ordinary string literals, literal-local ``E'...'`` escapes, and preservation of dots inside quoted names + * - ``ANSI_SQL`` - the newline rule for adjacent string literals * - ``BIGQUERY`` - ``withDoubleQuotedStrings``, ``withBackslashEscapeCharacter``, ``withHashLineComments``, any-whitespace rule for adjacent string literals @@ -743,6 +745,35 @@ One grammar covers every supported RDBMS, but a few pieces of syntax mean differ Features set explicitly *after* the preset win over it. +PostgreSQL names and literals +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Select ``Dialect.POSTGRESQL`` when parsing PostgreSQL SQL. For example, +``DROP INDEX "a.b"`` has the single name ``"a.b"`` and no schema; the dot +inside the quotes is not a separator. This applies to table and column +references throughout DDL and DML. The default configuration retains its +historical name-splitting behavior for compatibility with BigQuery names. + +``COMMENT ON TABLE t IS $tag$body$tag$`` accepts tagged dollar strings with +the PostgreSQL preset, or with ``withDollarQuotedStringTags(true)``. Tagged +strings remain disabled by default. Table, column and view comments preserve +their dollar delimiter and literal body. Ordinary single-quoted strings +separated by a newline concatenate; dollar-quoted strings do not. +``E'...'`` enables backslash escapes for that literal without changing +the treatment of ordinary strings elsewhere in the statement. + +The shared type grammar accepts negative scales, including +``numeric(2, -3)``, in type fragments, DDL and casts. ``getPrecision()`` +returns ``2`` and ``getScale()`` returns ``-3``; an omitted scale returns +``null``. PostgreSQL's precision and scale range checks remain the database's +responsibility. When constructing a ``ColDataType`` directly, +``ColDataType.fromNumericParameters("numeric", 2, -3)`` accepts negative scales; +use ``null`` for omitted parameters. The legacy ``int`` +constructor continues to treat negative arguments as omitted parameters. + +Other dialect-specific syntax +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + MySQL user-variable targets in ``SELECT ... INTO @variable`` require ``Dialect.MYSQL`` or ``Dialect.MARIADB``. They are stored in ``PlainSelect.getMySqlSelectIntoClause().getVariables()`` as ``UserVariable`` diff --git a/src/test/java/net/sf/jsqlparser/parser/PostgreSqlNumericTypeTest.java b/src/test/java/net/sf/jsqlparser/parser/PostgreSqlNumericTypeTest.java new file mode 100644 index 000000000..41148ef5c --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/parser/PostgreSqlNumericTypeTest.java @@ -0,0 +1,119 @@ +/*- + * #%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.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.List; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.CastExpression; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.create.table.ColDataType; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.select.PlainSelect; +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 PostgreSqlNumericTypeTest { + static Stream types() { + return Stream.of(Arguments.of("numeric(2,-3)", 2, -3), Arguments.of("decimal(2,-3)", 2, -3), + Arguments.of("dec(2,-3)", 2, -3), Arguments.of("numeric(2,-1)", 2, -1), + Arguments.of("numeric(2,0)", 2, 0), Arguments.of("numeric(3,5)", 3, 5), + Arguments.of("numeric(1000,-1000)", 1000, -1000), + Arguments.of("numeric(1,1000)", 1, 1000), Arguments.of("numeric(2,- 3)", 2, -3), + Arguments.of("numeric(2,/* scale */-3)", 2, -3), + Arguments.of("pg_catalog.numeric(2,-3)", 2, -3), + Arguments.of("\"numeric\"(2,-3)", 2, -3), Arguments.of("numeric(2,-3)[]", 2, -3), + Arguments.of("numeric(2,-3)[][]", 2, -3), Arguments.of("numeric(2)", 2, null), + Arguments.of("numeric", null, null), Arguments.of("varchar(8)", 8, null), + Arguments.of("timestamp(3) with time zone", 3, null)); + } + + @ParameterizedTest + @MethodSource("types") + void exposesParametersInFragmentsDdlAndCasts(String sqlType, Integer precision, Integer scale) + throws Exception { + ColDataType type = CCJSqlParserUtil.parseColDataType(sqlType, + parser -> parser.withDialect(Dialect.POSTGRESQL)); + assertParameters(type, precision, scale); + assertParameters(CCJSqlParserUtil.parseColDataType(type.toString()), precision, scale); + for (String sql : List.of("CREATE TABLE t (c " + sqlType + ")", + "SELECT CAST(NULL AS " + sqlType + ")", "SELECT NULL::" + sqlType)) { + Statement statement = parse(sql); + StringBuilder buffer = new StringBuilder(); + statement.accept(new StatementDeParser(buffer), null); + assertEquals(statement.toString(), buffer.toString()); + for (Statement tree : List.of(statement, parse(buffer.toString()))) { + ColDataType actual = tree instanceof CreateTable + ? ((CreateTable) tree).getColumnDefinitions().get(0).getColDataType() + : ((PlainSelect) tree).getSelectItem(0).getExpression(CastExpression.class) + .getColDataType(); + assertParameters(actual, precision, scale); + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER TABLE t ALTER COLUMN c TYPE numeric(2,-3)", + "CREATE DOMAIN rounded AS numeric(2,-3)", + "CREATE TYPE composite_t AS (c numeric(2,-3))", + "CREATE TABLE t(c numeric(2,-3) DEFAULT 12345::numeric(2,-3))", + "CREATE TABLE t(c numeric(2,-3) GENERATED ALWAYS AS (12345::numeric(2,-3)) STORED)"}) + void supportsSharedTypeGrammarInOtherDdl(String sql) throws Exception { + Statement statement = parse(sql); + assertEquals(statement.toString(), parse(statement.toString()).toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"numeric(2,-)", "numeric(2,+3)", "numeric(+2,3)", "numeric(2,-3.5)", + "numeric(2,--3)", "numeric(2,-3) NOT NULL"}) + void rejectsMalformedFragmentsAndTrailingInput(String type) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parseColDataType(type, + p -> p.withDialect(Dialect.POSTGRESQL))); + } + + @Test + void distinguishesNegativeScaleFromOmittedScaleWithoutChangingLegacyConstructor() { + ColDataType negative = ColDataType.fromNumericParameters("numeric", 2, -1); + assertEquals(-1, negative.getScale()); + assertEquals("numeric (2, -1)", negative.toString()); + ColDataType legacy = new ColDataType("numeric", 2, -1); + assertNull(legacy.getScale()); + assertEquals("numeric (2)", legacy.toString()); + ColDataType boxedLegacy = new ColDataType("numeric", 2, Integer.valueOf(-1)); + assertNull(boxedLegacy.getScale()); + } + + @Test + void preservesSharedOracleNumberSyntaxAndDefaultConfiguration() throws Exception { + for (ColDataType type : List.of(CCJSqlParserUtil.parseColDataType("NUMBER(5,-2)"), + CCJSqlParserUtil.parseColDataType("NUMBER(5,-2)", + p -> p.withDialect(Dialect.ORACLE)))) { + assertParameters(type, 5, -2); + } + } + + private static Statement parse(String sql) throws Exception { + return CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.POSTGRESQL)); + } + + private static void assertParameters(ColDataType type, Integer precision, Integer scale) { + assertEquals(precision, type.getPrecision(), type.toString()); + assertEquals(scale, type.getScale(), type.toString()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/parser/PostgreSqlQuotedIdentifierTest.java b/src/test/java/net/sf/jsqlparser/parser/PostgreSqlQuotedIdentifierTest.java new file mode 100644 index 000000000..1252fe349 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/parser/PostgreSqlQuotedIdentifierTest.java @@ -0,0 +1,169 @@ +/*- + * #%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.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import java.util.List; +import java.util.stream.Stream; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.alter.Alter; +import net.sf.jsqlparser.statement.comment.Comment; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.delete.Delete; +import net.sf.jsqlparser.statement.drop.Drop; +import net.sf.jsqlparser.statement.insert.Insert; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.update.Update; +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 PostgreSqlQuotedIdentifierTest { + static Stream names() { + return Stream.of(Arguments.of("plain", null, "plain"), + Arguments.of("\"normal\"", null, "\"normal\""), + Arguments.of("\"a.b\"", null, "\"a.b\""), + Arguments.of("public.\"a.b\"", "public", "\"a.b\""), + Arguments.of("\"s.p\".\"a.b\"", "\"s.p\"", "\"a.b\""), + Arguments.of("\"a\"\"b.c\"", null, "\"a\"\"b.c\""), + Arguments.of("\"한.글\"", null, "\"한.글\""), + Arguments.of("\"a.\"", null, "\"a.\""), + Arguments.of("\".b\"", null, "\".b\""), + Arguments.of("\"a..b\"", null, "\"a..b\""), + Arguments.of("\".\"", null, "\".\"")); + } + + @ParameterizedTest + @MethodSource("names") + void preservesObjectNamesAcrossStatements(String name, String schema, String leaf) + throws Exception { + for (String sql : List.of("DROP INDEX " + name, "DROP TABLE IF EXISTS " + name, + "CREATE TABLE " + name + " (c int)", "SELECT c FROM " + name, + "INSERT INTO " + name + " (c) VALUES (1)", "UPDATE " + name + " SET c = 1", + "DELETE FROM " + name, "ALTER TABLE " + name + " ADD COLUMN d int", + "COMMENT ON TABLE " + name + " IS $tag$body$tag$")) { + for (Statement statement : roundTrip(sql)) { + Table table = tableOf(statement); + assertEquals(leaf, table.getName(), sql); + assertEquals(schema, table.getSchemaName(), sql); + assertNull(table.getDatabaseName(), sql); + assertEquals(name, table.getFullyQualifiedName(), sql); + assertEquals(table.getNameParts(), table.clone().getNameParts(), sql); + assertEquals(name, table.clone().getFullyQualifiedName(), sql); + Table reference = new Table("ref").setResolvedTable(table); + assertNotSame(table, reference.getResolvedTable()); + assertEquals(table.getNameParts(), reference.getResolvedTable().getNameParts()); + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"c", "\"a.b\"", "\"a\"\"b.c\"", "\"한.글\"", "\"a.\"", "\".\""}) + void preservesColumnAndQualifierComponents(String name) throws Exception { + for (String ref : List.of(name, "\"t.x\"." + name, "\"s.p\".\"t.x\"." + name)) { + for (Statement statement : roundTrip("SELECT " + ref + " FROM \"s.p\".\"t.x\"")) { + Column column = + ((PlainSelect) statement).getSelectItem(0).getExpression(Column.class); + assertEquals(name, column.getColumnName()); + assertEquals(ref, column.getFullyQualifiedName()); + if (ref.equals(name)) { + assertNull(column.getTable()); + } else { + assertEquals("\"t.x\"", column.getTable().getName()); + } + } + } + for (Statement statement : roundTrip( + "COMMENT ON COLUMN \"t.x\"." + name + " IS $$body$$")) { + Column column = ((Comment) statement).getColumn(); + assertEquals(name, column.getColumnName()); + assertEquals("\"t.x\"", column.getTable().getName()); + } + for (Statement statement : roundTrip("INSERT INTO \"t.x\" (" + name + ") VALUES (1)")) { + assertEquals(name, ((Insert) statement).getColumns().get(0).getColumnName()); + } + } + + @Test + void preservesEveryNameInDropList() throws Exception { + for (Statement statement : roundTrip( + "DROP INDEX IF EXISTS \"a.b\", public.\"c.d\" CASCADE")) { + Drop drop = (Drop) statement; + assertEquals("\"a.b\"", drop.getNames().get(0).getName()); + assertNull(drop.getNames().get(0).getSchemaName()); + assertEquals("\"c.d\"", drop.getNames().get(1).getName()); + assertEquals("public", drop.getNames().get(1).getSchemaName()); + } + } + + @Test + void keepsLegacyBigQuerySplittingAndParserIsolation() throws Exception { + PlainSelect postgres = (PlainSelect) parse("SELECT \"a.b\" FROM \"s.t\""); + assertEquals("\"a.b\"", + postgres.getSelectItem(0).getExpression(Column.class).getColumnName()); + for (boolean explicitDialect : List.of(false, true)) { + PlainSelect bigquery = (PlainSelect) CCJSqlParserUtil.parse("SELECT * FROM `d.s.t`", + parser -> { + if (explicitDialect) { + parser.withDialect(Dialect.BIGQUERY); + } + }); + Table table = (Table) bigquery.getFromItem(); + assertEquals("d", table.getUnquotedDatabaseName()); + assertEquals("s", table.getUnquotedSchemaName()); + assertEquals("t", table.getUnquotedName()); + } + } + + private static Statement parse(String sql) throws Exception { + return CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.POSTGRESQL)); + } + + private static List roundTrip(String sql) throws Exception { + Statement statement = parse(sql); + StringBuilder buffer = new StringBuilder(); + statement.accept(new StatementDeParser(buffer), null); + assertEquals(statement.toString(), buffer.toString()); + return List.of(statement, parse(statement.toString()), parse(buffer.toString())); + } + + private static Table tableOf(Statement statement) { + if (statement instanceof Drop) { + return ((Drop) statement).getName(); + } + if (statement instanceof CreateTable) { + return ((CreateTable) statement).getTable(); + } + if (statement instanceof PlainSelect) { + return (Table) ((PlainSelect) statement).getFromItem(); + } + if (statement instanceof Insert) { + return ((Insert) statement).getTable(); + } + if (statement instanceof Update) { + return ((Update) statement).getTable(); + } + if (statement instanceof Delete) { + return ((Delete) statement).getTable(); + } + if (statement instanceof Alter) { + return ((Alter) statement).getTable(); + } + return ((Comment) statement).getTable(); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/comment/PostgreSqlCommentLiteralTest.java b/src/test/java/net/sf/jsqlparser/statement/comment/PostgreSqlCommentLiteralTest.java new file mode 100644 index 000000000..64190a76d --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/comment/PostgreSqlCommentLiteralTest.java @@ -0,0 +1,122 @@ +/*- + * #%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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.List; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.select.PlainSelect; +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 PostgreSqlCommentLiteralTest { + static Stream dollarBodies() { + return Stream.of(Arguments.of("", "body"), Arguments.of("tag", "body"), + Arguments.of("Tag_123", ""), Arguments.of("_", "quote's \"double\" \\path"), + Arguments.of("한글", "한글 본문"), Arguments.of("étiquette", "$other$x$other$ $$ $1"), + Arguments.of("tag", "line1\nline2; --sql\n/* comment */"), + Arguments.of("tag", "$Tag$body$Tag$")); + } + + @ParameterizedTest + @MethodSource("dollarBodies") + void preservesDollarBodyAndTagAcrossCommentTargets(String tag, String body) throws Exception { + String quote = "$" + tag + "$"; + for (String target : List.of("TABLE t", "COLUMN t.c", "VIEW v")) { + String sql = "COMMENT ON " + target + " IS " + quote + body + quote; + Comment comment = parse(sql); + StringBuilder buffer = new StringBuilder(); + comment.accept(new StatementDeParser(buffer), null); + assertEquals(sql, buffer.toString()); + for (Comment tree : List.of(comment, parse(comment.toString()), + parse(buffer.toString()))) { + assertEquals(body, tree.getComment().getValue()); + assertEquals(quote, tree.getComment().getQuoteStr()); + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"'one'\n 'two'", "'one' -- line comment\n 'two'", + "'one'\r\n 'two'"}) + void concatenatesNewlineSeparatedOrdinaryLiterals(String literal) throws Exception { + Comment comment = parse("COMMENT ON TABLE t IS " + literal); + assertEquals("onetwo", comment.getComment().getValue()); + assertEquals("onetwo", parse(comment.toString()).getComment().getValue()); + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse("SELECT " + literal, + p -> p.withDialect(Dialect.POSTGRESQL)); + assertEquals("onetwo", select.getSelectItem(0).getExpression(StringValue.class).getValue()); + } + + @ParameterizedTest + @ValueSource(strings = {"E'quote\\'s'", "e'quote\\'s'", "E'a\\nb'", "E'\\\\path'"}) + void acceptsEscapeStringsWithoutChangingOrdinaryStringMode(String literal) throws Exception { + Comment comment = parse("COMMENT ON TABLE t IS " + literal); + assertEquals("E", comment.getComment().getPrefix()); + assertEquals(comment.getComment().toString(), + parse(comment.toString()).getComment().toString()); + Statements statements = CCJSqlParserUtil.parseStatements( + "COMMENT ON TABLE t IS " + literal + "; SELECT '\\' AS backslash; SELECT 2", + p -> p.withDialect(Dialect.POSTGRESQL)); + assertEquals(3, statements.size()); + StringValue ordinary = + ((PlainSelect) statements.get(1)).getSelectItem(0).getExpression(StringValue.class); + assertEquals("\\", ordinary.getValue()); + assertNull(ordinary.getPrefix()); + } + + @Test + void keepsSemicolonsInsideDollarBodiesAndFollowingStatements() throws Exception { + Statements statements = CCJSqlParserUtil.parseStatements( + "COMMENT ON TABLE t IS $tag$body; SELECT 0; --text\n$tag$; SELECT 42;", + p -> p.withDialect(Dialect.POSTGRESQL)); + assertEquals(2, statements.size()); + assertEquals("body; SELECT 0; --text\n", + ((Comment) statements.get(0)).getComment().getValue()); + assertEquals("SELECT 42", statements.get(1).toString()); + } + + @Test + void keepsTaggedQuotesOptInAndSupportsExplicitFeature() throws Exception { + String sql = "COMMENT ON TABLE t IS $tag$body$tag$"; + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, + p -> p.withDialect(Dialect.POSTGRESQL).withDollarQuotedStringTags(false))); + Comment comment = + (Comment) CCJSqlParserUtil.parse(sql, p -> p.withDollarQuotedStringTags(true)); + assertEquals("body", comment.getComment().getValue()); + assertNull(parse("COMMENT ON TABLE t IS NULL").getComment()); + assertEquals("", parse("COMMENT ON TABLE t IS ''").getComment().getValue()); + } + + @ParameterizedTest + @ValueSource(strings = {"'one' 'two'", "$t$one$t$\n $t$two$t$", "$t$one$t$\n 'two'", + "'one'\n $t$two$t$", "'one'\n E'two'", "$Tag$body$tag$", "$tag$unterminated", + "$1$body$1$"}) + void rejectsInvalidLiteralForms(String literal) { + assertThrows(JSQLParserException.class, () -> parse("COMMENT ON TABLE t IS " + literal)); + } + + private static Comment parse(String sql) throws JSQLParserException { + return (Comment) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); + } +}