diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index e8a6d6d19..53d54a7d2 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1769,6 +1769,14 @@ TOKEN_MGR_DECLS : { token.endColumn = input_stream.getEndColumn(); } + /** Splits a longest-match identifier without losing token positions. */ + private void truncateIdentifierToken(Token token, int length) { + input_stream.backup(token.image.length() - length); + token.image = token.image.substring(0, length); + token.endLine = input_stream.getEndLine(); + token.endColumn = input_stream.getEndColumn(); + } + /** * Consumes the body of a block comment after the opening delimiter has been matched, * honouring nesting, up to and including the outermost closing delimiter. Then backs @@ -2510,8 +2518,19 @@ TOKEN: && Boolean.TRUE.equals(configuration.getValue(Feature.allowHashLineComments))) { int hashIndex = matchedToken.image.indexOf('#'); if (hashIndex > 0) { - input_stream.backup(matchedToken.image.length() - hashIndex); - matchedToken.image = matchedToken.image.substring(0, hashIndex); + truncateIdentifierToken(matchedToken, hashIndex); + } + } + // PostgreSQL does not allow # in unquoted identifiers. Re-lex it as an + // operator, including #> and #>>, even when it touches the left operand. + if (matchedToken.kind == S_IDENTIFIER + && AbstractJSqlParser.Dialect.POSTGRESQL.name().equals(configuration.getValue(Feature.dialect))) { + int hashIndex = matchedToken.image.indexOf('#'); + if (hashIndex > 0) { + truncateIdentifierToken(matchedToken, hashIndex); + } else if (hashIndex == 0) { + truncateIdentifierToken(matchedToken, 1); + matchedToken.kind = S_HASH_OPERATOR; } } } @@ -2532,8 +2551,7 @@ TOKEN: && Boolean.TRUE.equals(configuration.getValue(Feature.allowHashLineComments))) { int hashIndex = matchedToken.image.indexOf('#'); if (hashIndex > 0) { - input_stream.backup(matchedToken.image.length() - hashIndex); - matchedToken.image = matchedToken.image.substring(0, hashIndex); + truncateIdentifierToken(matchedToken, hashIndex); } } } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 35bdc082a..afa847942 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -766,6 +766,11 @@ uses the existing ``Update`` model's ``fromItem`` and ``joins`` properties. Table discovery and metadata validation recognize a target alias declared in that FROM clause. Other dialects retain the existing FROM-after-SET syntax. +With ``Dialect.POSTGRESQL``, ``#`` terminates an unquoted identifier, so JSON +operators such as ``js#>>'{a}'`` and ``js#>'{a}'`` work without surrounding +spaces. Quote identifiers containing ``#``, for example ``"js#"``. Other +dialects retain their existing identifier and hash-comment rules. + With ``Dialect.SQLSERVER``, ``PRIMARY KEY NONCLUSTERED (id)`` and ``UNIQUE CLUSTERED (id)`` store their clustering option in ``Index.getClustering()`` for both ``CREATE TABLE`` and ``ALTER TABLE``. Without that dialect, these words diff --git a/src/test/java/net/sf/jsqlparser/parser/PostgreSqlHashOperatorTest.java b/src/test/java/net/sf/jsqlparser/parser/PostgreSqlHashOperatorTest.java new file mode 100644 index 000000000..564416192 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/parser/PostgreSqlHashOperatorTest.java @@ -0,0 +1,99 @@ +/*- + * #%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.*; + +import net.sf.jsqlparser.expression.JsonExpression; +import net.sf.jsqlparser.expression.operators.arithmetic.BitwiseRightShift; +import net.sf.jsqlparser.expression.operators.relational.Intersects; +import net.sf.jsqlparser.expression.operators.relational.NotEqualsTo; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.schema.Column; +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.ValueSource; + +class PostgreSqlHashOperatorTest { + private static PlainSelect parse(String sql) throws Exception { + return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); + } + + @ParameterizedTest + @ValueSource(strings = {"js#>>'{a,b,1}'", "js #>> '{a,b,1}'", "js#>> '{a,b,1}'", + "t.js#>>'{a,b,1}'", "\"js#\"#>>'{a,b,1}'", "js/*comment*/#>>'{a,b,1}'"}) + void preservesJsonPrecedenceWithOrWithoutSpacesIssue2163(String expression) throws Exception { + PlainSelect select = parse("SELECT * FROM t WHERE " + expression + " <> 'bar'"); + NotEqualsTo condition = (NotEqualsTo) select.getWhere(); + assertInstanceOf(JsonExpression.class, condition.getLeftExpression()); + assertTrue(select.toString().contains(" #>> ")); + StringBuilder output = new StringBuilder(); + select.accept(new StatementDeParser(output)); + assertEquals(select.toString(), output.toString()); + assertEquals(select.toString(), parse(output.toString()).toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"js#>'{a}'", "js#>'{a}'#>>'{b}'"}) + void recognizesOtherHashJsonOperators(String expression) throws Exception { + PlainSelect select = parse("SELECT " + expression + " FROM t"); + assertInstanceOf(JsonExpression.class, select.getSelectItem(0).getExpression()); + assertEquals(select.toString(), parse(select.toString()).toString()); + } + + @Test + void splitsHashXorAndPreservesQuotedIdentifiersAndLiterals() throws Exception { + PlainSelect select = parse("SELECT a#b, a#2, \"a#b\", '#>>', $$a#b$$ FROM t"); + assertInstanceOf(Intersects.class, select.getSelectItem(0).getExpression()); + assertInstanceOf(Intersects.class, select.getSelectItem(1).getExpression()); + assertEquals("\"a#b\"", ((Column) select.getSelectItem(2).getExpression()).getColumnName()); + assertEquals(select.toString(), parse(select.toString()).toString()); + } + + @Test + void leavesLegacyAndSqlServerNamesAndMysqlCommentsIntact() throws Exception { + for (Dialect dialect : Dialect.values()) { + if (dialect != Dialect.POSTGRESQL && dialect != Dialect.MYSQL + && dialect != Dialect.MARIADB && dialect != Dialect.BIGQUERY) { + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse("SELECT a#b FROM #temp", + p -> p.withDialect(dialect)); + assertEquals("a#b", + ((Column) select.getSelectItem(0).getExpression()).getColumnName()); + } + } + PlainSelect legacy = (PlainSelect) CCJSqlParserUtil.parse("SELECT js#>>'{a}' FROM t"); + assertInstanceOf(BitwiseRightShift.class, legacy.getSelectItem(0).getExpression()); + PlainSelect mysql = (PlainSelect) CCJSqlParserUtil.parse("SELECT a#comment\nFROM t", + p -> p.withDialect(Dialect.MYSQL)); + assertEquals("SELECT a FROM t", mysql.toString()); + } + + @Test + void keepsTokenOffsetsAfterSplittingAndAcrossStatements() throws Exception { + CCJSqlParser parser = CCJSqlParserUtil.newParser("js#>>'{}'") + .withDialect(Dialect.POSTGRESQL); + Token name = parser.getNextToken(); + Token operator = parser.getNextToken(); + assertEquals("js", name.image); + assertEquals(1, name.beginColumn); + assertEquals(2, name.endColumn); + assertEquals(1, name.absoluteBegin); + assertEquals(3, name.absoluteEnd); + assertEquals("#>>", operator.image); + assertEquals(3, operator.beginColumn); + assertEquals(5, operator.endColumn); + assertEquals(3, operator.absoluteBegin); + assertEquals(6, operator.absoluteEnd); + assertEquals(2, CCJSqlParserUtil.parseStatements("SELECT js#>>'{}' FROM t; SELECT 1;", + p -> p.withDialect(Dialect.POSTGRESQL)).size()); + } +}