Skip to content

Commit c55818f

Browse files
committed
DuckDB: accept digit separators in numeric literals
`1_000_000`, `92_000.0` and `1e1_0` are numbers in DuckDB and PostgreSQL 16, but MySQL reads `2024_01` as an identifier. The new `Feature.allowDigitSeparators`, enabled by the PostgreSQL dialect, drops the separators from the token image, so every consumer of number tokens keeps working. Without it, lexing is unchanged.
1 parent 19f12ea commit c55818f

4 files changed

Lines changed: 114 additions & 3 deletions

File tree

‎src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ public enum Dialect {
3939
Feature.allowDoubleQuotedStrings), SQLSERVER(AdjacentStringLiterals.OFF,
4040
Feature.allowSquareBracketQuotation), POSTGRESQL(
4141
AdjacentStringLiterals.NEWLINE,
42-
Feature.allowDollarQuotedStringTags), H2, EXASOL, BIGQUERY(
42+
Feature.allowDollarQuotedStringTags,
43+
Feature.allowDigitSeparators), H2, EXASOL, BIGQUERY(
4344
AdjacentStringLiterals.WHITESPACE,
4445
Feature.allowDoubleQuotedStrings,
4546
Feature.allowHashLineComments,
@@ -194,6 +195,14 @@ public P withHashLineComments(boolean allowHashLineComments) {
194195
return withFeature(Feature.allowHashLineComments, allowHashLineComments);
195196
}
196197

198+
public P withDigitSeparators() {
199+
return withFeature(Feature.allowDigitSeparators, true);
200+
}
201+
202+
public P withDigitSeparators(boolean allowDigitSeparators) {
203+
return withFeature(Feature.allowDigitSeparators, allowDigitSeparators);
204+
}
205+
197206
public P withUnparenthesizedSubSelects() {
198207
return withFeature(Feature.allowUnparenthesizedSubSelects, true);
199208
}

‎src/main/java/net/sf/jsqlparser/parser/feature/Feature.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,12 @@ public enum Feature {
889889
*/
890890
allowHashLineComments(false),
891891

892+
/**
893+
* allows digit separators in numeric literals, e.g. {@code 1_000_000} (DuckDB, PostgreSQL 16);
894+
* disabled by default, where MySQL reads {@code 2024_01} as an identifier
895+
*/
896+
allowDigitSeparators(false),
897+
892898
/**
893899
* allows sub selects without parentheses, e.g. `select * from dual where 1 = select 1`
894900
*/

‎src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2431,6 +2431,38 @@ TOKEN_MGR_DECLS : {
24312431
}
24322432

24332433
/** Splits a longest-match identifier without losing token positions. */
2434+
/**
2435+
* DuckDB's and PostgreSQL's digit separators, as in {@code 1_000_000}. Under
2436+
* Feature.allowDigitSeparators they are dropped from the image. Otherwise the token is
2437+
* lexed as without separators: the longer of the number before the first {@code _} and the
2438+
* identifier starting there, so that MySQL's {@code 2024_01} stays an identifier.
2439+
*/
2440+
private void lexDigitSeparators(Token token) {
2441+
int separator = token.image.indexOf('_');
2442+
if (separator < 0) {
2443+
return;
2444+
}
2445+
if (input_stream.featureConfiguration != null
2446+
&& Boolean.TRUE.equals(configuration.getValue(Feature.allowDigitSeparators))) {
2447+
token.image = token.image.replace("_", "");
2448+
return;
2449+
}
2450+
int identifierLength = 0;
2451+
while (identifierLength < token.image.length()
2452+
&& (Character.isLetterOrDigit(token.image.charAt(identifierLength))
2453+
|| token.image.charAt(identifierLength) == '_')) {
2454+
identifierLength++;
2455+
}
2456+
if (identifierLength > separator) {
2457+
truncateIdentifierToken(token, identifierLength);
2458+
token.kind = S_IDENTIFIER;
2459+
} else {
2460+
truncateIdentifierToken(token, separator);
2461+
token.kind = token.image.indexOf('.') >= 0 || token.image.indexOf('e') >= 0
2462+
|| token.image.indexOf('E') >= 0 ? S_DOUBLE : S_LONG;
2463+
}
2464+
}
2465+
24342466
private void truncateIdentifierToken(Token token, int length) {
24352467
input_stream.backup(token.image.length() - length);
24362468
token.image = token.image.substring(0, length);
@@ -3126,8 +3158,8 @@ TOKEN : /* Numeric Constants */
31263158
<S_LONG> "." (["e","E"] (["+", "-"])? <S_LONG>)?
31273159
|
31283160
<S_LONG> ["e","E"] (["+", "-"])? <S_LONG>
3129-
)>
3130-
| < S_LONG: ( <DIGIT> )+ >
3161+
)> { lexDigitSeparators(matchedToken); }
3162+
| < S_LONG: ( <DIGIT> )+ ( "_" ( <DIGIT> )+ )* > { lexDigitSeparators(matchedToken); }
31313163
| < #DIGIT: ["0" - "9"] >
31323164
| < S_HEX: ("X" <QUOTED_HEX> ( (" ")* <QUOTED_HEX> )* | "0x" ( <HEX_DIGIT> )+ ) >
31333165
| < #QUOTED_HEX: "'" ( <HEX_VALUE> )* "'" >
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.expression;
11+
12+
import net.sf.jsqlparser.JSQLParserException;
13+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
14+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
15+
import net.sf.jsqlparser.schema.Column;
16+
import net.sf.jsqlparser.statement.select.PlainSelect;
17+
import org.junit.jupiter.api.Test;
18+
import org.junit.jupiter.params.ParameterizedTest;
19+
import org.junit.jupiter.params.provider.CsvSource;
20+
21+
import static org.junit.jupiter.api.Assertions.assertEquals;
22+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
23+
24+
class DigitSeparatorsTest {
25+
26+
private static Expression firstItem(String sql, boolean digitSeparators)
27+
throws JSQLParserException {
28+
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse(sql,
29+
parser -> parser.withDigitSeparators(digitSeparators));
30+
return select.getSelectItem(0).getExpression();
31+
}
32+
33+
@ParameterizedTest
34+
@CsvSource({"1_000, 1000", "1_000_000, 1000000", "92_000.0, 92000.0", "1_000.5_5, 1000.55",
35+
".5_5, .55", "1e1_0, 1e10", "1_0e10, 10e10", "1.5e+1_0, 1.5e+10"})
36+
void testSeparatorsAreDropped(String literal, String expected) throws JSQLParserException {
37+
assertEquals(expected, firstItem("SELECT " + literal, true).toString());
38+
}
39+
40+
@Test
41+
void testSeparatedNumbersAreNumbers() throws JSQLParserException {
42+
assertEquals(1000L, ((LongValue) firstItem("SELECT 1_000", true)).getValue());
43+
assertEquals(92000.0, ((DoubleValue) firstItem("SELECT 92_000.0", true)).getValue());
44+
assertEquals("SELECT * FROM t LIMIT 1000", CCJSqlParserUtil
45+
.parse("SELECT * FROM t LIMIT 1_000", parser -> parser.withDigitSeparators())
46+
.toString());
47+
}
48+
49+
@Test
50+
void testPostgreSqlAllowsSeparators() throws JSQLParserException {
51+
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse("SELECT 1_000",
52+
parser -> parser.withDialect(Dialect.POSTGRESQL));
53+
assertInstanceOf(LongValue.class, select.getSelectItem(0).getExpression());
54+
}
55+
56+
// MySQL identifiers may start with digits
57+
@Test
58+
void testWithoutSeparatorsDigitLedNamesStayIdentifiers() throws JSQLParserException {
59+
assertInstanceOf(Column.class, firstItem("SELECT 1_000", false));
60+
assertInstanceOf(Column.class, firstItem("SELECT 2024_01.c FROM 2024_01", false));
61+
assertEquals("SELECT .5 _5", CCJSqlParserUtil.parse("SELECT .5_5").toString());
62+
assertEquals("SELECT 1.5e+1 _0", CCJSqlParserUtil.parse("SELECT 1.5e+1_0").toString());
63+
}
64+
}

0 commit comments

Comments
 (0)