Skip to content

Commit 97c61b1

Browse files
authored
fix: end column DEFAULT values before a top-level COLLATE (#2849)
* fix: end column DEFAULT values before a top-level COLLATE In MySQL and PostgreSQL column definitions, a COLLATE after the DEFAULT value is a column attribute: MySQL's DEFAULT takes a literal and PostgreSQL's a b_expr, and neither includes COLLATE. The column DEFAULT parsed a full expression, so DEFAULT 'N' COLLATE x became one DEFAULT option holding a CollateExpression. Parse column DEFAULT values with a ColumnDefaultValue production that stops before a COLLATE at its top level. That COLLATE becomes the column's COLLATE option. A COLLATE inside parentheses, function arguments or CASE stays in the expression. SET DEFAULT, an a_expr in PostgreSQL, and CREATE DOMAIN are unchanged. SQL output does not change. The nesting walk of slice subscripts moves to the package-private TokenNesting, like RoutineBodyBoundary, and serves both checks. The generated CCJSqlParser class is close to the size JaCoCo can still instrument. Signed-off-by: minleejae <mmj9808@gmail.com> * refactor: keep the token nesting walk in the grammar Move TokenNesting from its own file into the grammar as a private nested class next to the lookaheads that use it, as asked in review. The nested class compiles to its own class file, so its branches stay out of CCJSqlParser.class. Inlining the walk as a parser method took 18 more of the constant-pool entries JaCoCo can still instrument. Behaviour does not change. Signed-off-by: minleejae <mmj9808@gmail.com> --------- Signed-off-by: minleejae <mmj9808@gmail.com>
1 parent cec9fb8 commit 97c61b1

3 files changed

Lines changed: 174 additions & 17 deletions

File tree

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

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -224,20 +224,46 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
224224
*/
225225
private boolean isSliceColonAhead() {
226226
Token colon = getToken(1);
227-
if (subscriptOpenings.isEmpty() || !":".equals(colon.image)) {
228-
return false;
229-
}
230-
int depth = 0;
231-
for (Token t = subscriptOpenings.peek().next; t != null && t != colon; t = t.next) {
232-
if (t.kind == K_CASE || "(".equals(t.image) || "[".equals(t.image)
233-
|| "{".equals(t.image)) {
234-
depth++;
235-
} else if (t.kind == K_END || ")".equals(t.image) || "]".equals(t.image)
236-
|| "}".equals(t.image)) {
237-
depth--;
227+
return !subscriptOpenings.isEmpty() && ":".equals(colon.image)
228+
&& TokenNesting.isTopLevelFrom(subscriptOpenings.peek().next, colon);
229+
}
230+
231+
/**
232+
* Token nesting checks shared by the lookaheads above and below. A nested class keeps its
233+
* branches out of CCJSqlParser.class, whose constant pool is close to what JaCoCo can still
234+
* instrument.
235+
*/
236+
private static final class TokenNesting {
237+
private TokenNesting() {}
238+
239+
/**
240+
* Whether the target is not nested in brackets, braces, parentheses or a CASE expression
241+
* opened from the first token on.
242+
*/
243+
static boolean isTopLevelFrom(Token first, Token target) {
244+
int depth = 0;
245+
for (Token t = first; t != null && t != target; t = t.next) {
246+
if (t.kind == K_CASE || "(".equals(t.image) || "[".equals(t.image)
247+
|| "{".equals(t.image)) {
248+
depth++;
249+
} else if (t.kind == K_END || ")".equals(t.image) || "]".equals(t.image)
250+
|| "}".equals(t.image)) {
251+
depth--;
252+
}
238253
}
254+
return depth == 0;
239255
}
240-
return depth == 0;
256+
}
257+
258+
// first tokens of the enclosing column DEFAULT values: a COLLATE at their top level is the
259+
// column's own attribute, as in MySQL's column_attribute and PostgreSQL's ColQualList
260+
private final Deque<Token> columnDefaultStarts = new ArrayDeque<Token>();
261+
262+
/** Whether the pending COLLATE follows an unparenthesized column DEFAULT value. */
263+
private boolean isColumnCollateAhead() {
264+
Token collate = getToken(1);
265+
return !columnDefaultStarts.isEmpty() && collate.kind == K_COLLATE
266+
&& TokenNesting.isTopLevelFrom(columnDefaultStarts.peek(), collate);
241267
}
242268

243269
private Expression createOutputArgument(Expression argument) throws ParseException {
@@ -13244,7 +13270,7 @@ Expression PrimaryExpression() #PrimaryExpression:
1324413270
)
1324513271

1324613272
[
13247-
LOOKAHEAD(2) <K_COLLATE> (token=<S_CHAR_LITERAL> | token=<S_QUOTED_IDENTIFIER> | token=<S_IDENTIFIER>) { retval = new CollateExpression(retval, token.image); }
13273+
LOOKAHEAD(2, { !isColumnCollateAhead() }) <K_COLLATE> (token=<S_CHAR_LITERAL> | token=<S_QUOTED_IDENTIFIER> | token=<S_IDENTIFIER>) { retval = new CollateExpression(retval, token.image); }
1324813274
]
1324913275

1325013276
[
@@ -16484,14 +16510,33 @@ ColumnOption ColumnDefinitionOption(): {
1648416510
LOOKAHEAD(<K_DEFAULT>) <K_DEFAULT> { option = ColumnOption.raw("DEFAULT"); }
1648516511
// Db2 permits an omitted default value. Keep these implicit defaults raw.
1648616512
[ LOOKAHEAD(2, { !(getToken(1).kind == K_NOT && getToken(2).kind == K_NULL) })
16487-
defaultExpression=Expression() { option = ColumnOption.defaultValue(defaultExpression); } ]
16513+
defaultExpression=ColumnDefaultValue() { option = ColumnOption.defaultValue(defaultExpression); } ]
1648816514
|
1648916515
parameter=ColumnDefinitionParameter()
1649016516
{ option = ColumnOption.raw(parameter); }
1649116517
)
1649216518
{ return option; }
1649316519
}
1649416520

16521+
/**
16522+
* An explicit column DEFAULT value. MySQL's DEFAULT literal and PostgreSQL's DEFAULT b_expr exclude
16523+
* COLLATE, so the value ends before a COLLATE at its top level, which is the column's own
16524+
* attribute. A COLLATE inside parentheses, function arguments or CASE stays in the expression.
16525+
*/
16526+
Expression ColumnDefaultValue():
16527+
{
16528+
Expression expression;
16529+
}
16530+
{
16531+
{ columnDefaultStarts.push(getToken(1)); }
16532+
try {
16533+
expression=Expression()
16534+
} finally {
16535+
columnDefaultStarts.pop();
16536+
}
16537+
{ return expression; }
16538+
}
16539+
1649516540
GeneratedColumnDefinition GeneratedColumnDefinition():
1649616541
{
1649716542
Expression expression;

‎src/site/sphinx/usage.rst‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,18 @@ Charset remains in ``ColDataType.getCharacterSet()``; column collation is in its
935935
COLLATE option. Consumers can map both directly without reconstructing tokens.
936936
StringValue comment bodies retain their SQL escape representation.
937937

938+
A column DEFAULT value ends before a COLLATE at its top level, which becomes the
939+
column's COLLATE option: MySQL's DEFAULT literal and PostgreSQL's DEFAULT ``b_expr``
940+
do not include COLLATE. A COLLATE inside parentheses, function arguments or a CASE
941+
expression stays in the default expression. ``SET DEFAULT`` actions and CREATE
942+
DOMAIN keep an expression COLLATE.
943+
944+
Migration: ``DEFAULT 'N' COLLATE utf8mb4_bin`` in a column definition previously
945+
produced one DEFAULT option holding a ``CollateExpression``. It now produces a
946+
DEFAULT option with ``'N'`` and a COLLATE option with ``getCollation()``;
947+
``getColumnSpecs()`` returns ``[DEFAULT, 'N', COLLATE, utf8mb4_bin]``. SQL output
948+
is unchanged.
949+
938950
MySQL column attributes have their own kinds: ``SRID`` (``getSrid()``),
939951
``COLUMN_FORMAT`` (``getColumnFormat()``: FIXED, DYNAMIC or DEFAULT), and
940952
``ENGINE_ATTRIBUTE``/``SECONDARY_ENGINE_ATTRIBUTE`` (``getEngineAttribute()``

‎src/test/java/net/sf/jsqlparser/statement/create/ColumnDefaultExpressionTest.java‎

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,48 @@
99
*/
1010
package net.sf.jsqlparser.statement.create;
1111

12+
import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed;
1213
import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNotAllowed;
1314
import static org.assertj.core.api.Assertions.assertThat;
1415
import static org.junit.jupiter.api.Assertions.assertEquals;
16+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
1517
import static org.junit.jupiter.api.Assertions.assertNotNull;
1618
import static org.junit.jupiter.api.Assertions.assertNull;
1719
import static org.junit.jupiter.api.Assertions.assertThrows;
18-
1920
import java.util.ArrayList;
2021
import java.util.Arrays;
2122
import java.util.List;
23+
import java.util.function.Consumer;
24+
import java.util.stream.Stream;
2225
import net.sf.jsqlparser.JSQLParserException;
26+
import net.sf.jsqlparser.expression.CollateExpression;
2327
import net.sf.jsqlparser.expression.Expression;
2428
import net.sf.jsqlparser.expression.ExpressionVisitorAdapter;
2529
import net.sf.jsqlparser.expression.LongValue;
30+
import net.sf.jsqlparser.expression.StringValue;
31+
import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList;
32+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
33+
import net.sf.jsqlparser.parser.CCJSqlParser;
2634
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
2735
import net.sf.jsqlparser.parser.feature.Feature;
28-
import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed;
29-
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
3036
import net.sf.jsqlparser.statement.Statement;
3137
import net.sf.jsqlparser.statement.StatementVisitorAdapter;
3238
import net.sf.jsqlparser.statement.alter.Alter;
39+
import net.sf.jsqlparser.statement.alter.AlterDomain;
40+
import net.sf.jsqlparser.statement.alter.AlterRelation;
41+
import net.sf.jsqlparser.statement.create.domain.CreateDomain;
3342
import net.sf.jsqlparser.statement.create.table.ColumnDefinition;
3443
import net.sf.jsqlparser.statement.create.table.ColumnOption;
3544
import net.sf.jsqlparser.statement.create.table.CreateTable;
3645
import net.sf.jsqlparser.statement.select.SelectVisitorAdapter;
3746
import net.sf.jsqlparser.util.deparser.ExpressionDeParser;
3847
import net.sf.jsqlparser.util.deparser.SelectDeParser;
3948
import net.sf.jsqlparser.util.deparser.StatementDeParser;
49+
import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed;
4050
import org.junit.jupiter.api.Test;
4151
import org.junit.jupiter.params.ParameterizedTest;
52+
import org.junit.jupiter.params.provider.Arguments;
53+
import org.junit.jupiter.params.provider.MethodSource;
4254
import org.junit.jupiter.params.provider.ValueSource;
4355

4456
class ColumnDefaultExpressionTest {
@@ -161,6 +173,94 @@ void neighboringAndLegacyColumnOptionsStillRoundTrip(String sql) throws JSQLPars
161173
assertRoundTrip(CCJSqlParserUtil.parse(sql));
162174
}
163175

176+
@ParameterizedTest
177+
@ValueSource(
178+
strings = {"CREATE TABLE t (c VARCHAR(30) ", "ALTER TABLE t ADD COLUMN c VARCHAR(30) ",
179+
"ALTER TABLE t MODIFY COLUMN c VARCHAR(30) ",
180+
"ALTER TABLE t CHANGE COLUMN old_c c VARCHAR(30) "})
181+
void collateAfterDefaultIsAColumnAttribute(String prefix) throws JSQLParserException {
182+
String sql = prefix + "NOT NULL DEFAULT 'N' COLLATE utf8mb4_unicode_ci COMMENT 'flag'"
183+
+ (prefix.startsWith("CREATE") ? ")" : "");
184+
List<Consumer<CCJSqlParser>> configurations = Arrays.asList(parser -> {
185+
}, parser -> parser.withDialect(Dialect.MYSQL));
186+
for (Consumer<CCJSqlParser> configuration : configurations) {
187+
Statement statement = assertSqlCanBeParsedAndDeparsed(sql, true, configuration);
188+
ColumnDefinition column = firstColumn(statement);
189+
assertThat(column.getColumnOptions()).extracting(ColumnOption::getKind)
190+
.containsExactly(ColumnOption.Kind.NULLABILITY, ColumnOption.Kind.DEFAULT,
191+
ColumnOption.Kind.COLLATE, ColumnOption.Kind.COMMENT);
192+
assertInstanceOf(StringValue.class, defaultOption(column).getDefaultExpression());
193+
assertEquals("utf8mb4_unicode_ci", column.getColumnOptions().get(2).getCollation());
194+
assertThat(column.getColumnSpecs()).containsExactly("NOT", "NULL", "DEFAULT", "'N'",
195+
"COLLATE", "utf8mb4_unicode_ci", "COMMENT", "'flag'");
196+
assertRoundTrip(statement);
197+
}
198+
}
199+
200+
static Stream<Arguments> defaultValuesBeforeCollate() {
201+
return Stream.of(Arguments.of(Dialect.MYSQL, "_utf8mb4'N'", "utf8mb4_bin"),
202+
Arguments.of(Dialect.MYSQL, "'N'", "'utf8mb4_bin'"),
203+
Arguments.of(Dialect.MYSQL, "1", "utf8mb4_bin"),
204+
Arguments.of(Dialect.MYSQL, "-1", "utf8mb4_bin"),
205+
Arguments.of(Dialect.POSTGRESQL, "'a' || 'b'", "\"C\""),
206+
Arguments.of(Dialect.POSTGRESQL, "lower('A' COLLATE \"C\")", "\"C\""),
207+
Arguments.of(Dialect.POSTGRESQL, "CASE WHEN true THEN 'a' END", "\"C\""),
208+
Arguments.of(Dialect.POSTGRESQL, "'x'::text", "\"C\""),
209+
Arguments.of(Dialect.POSTGRESQL, "'x'", "pg_catalog.\"default\""));
210+
}
211+
212+
@ParameterizedTest
213+
@MethodSource("defaultValuesBeforeCollate")
214+
void defaultValueEndsBeforeTopLevelCollate(Dialect dialect, String value, String collation)
215+
throws JSQLParserException {
216+
String definition = " VARCHAR(30) DEFAULT " + value + " COLLATE " + collation + " NOT NULL";
217+
String alter = dialect == Dialect.MYSQL ? "ALTER TABLE t MODIFY COLUMN c"
218+
: "ALTER TABLE t ADD COLUMN c";
219+
for (String sql : Arrays.asList("CREATE TABLE t (c" + definition + ")",
220+
alter + definition)) {
221+
ColumnDefinition column = firstColumn(assertSqlCanBeParsedAndDeparsed(sql, true,
222+
parser -> parser.withDialect(dialect)));
223+
assertThat(column.getColumnOptions()).extracting(ColumnOption::getKind)
224+
.containsExactly(ColumnOption.Kind.DEFAULT, ColumnOption.Kind.COLLATE,
225+
ColumnOption.Kind.NULLABILITY);
226+
assertEquals(value, defaultOption(column).getDefaultExpression().toString());
227+
assertEquals(collation, column.getColumnOptions().get(1).getCollation());
228+
}
229+
}
230+
231+
@Test
232+
void parenthesizedCollateStaysInTheDefaultValue() throws JSQLParserException {
233+
ColumnDefinition column = firstColumn(assertSqlCanBeParsedAndDeparsed(
234+
"ALTER TABLE t MODIFY COLUMN c VARCHAR(3) DEFAULT ('N' COLLATE utf8mb4_bin) NOT NULL",
235+
true, parser -> parser.withDialect(Dialect.MYSQL)));
236+
assertThat(column.getColumnOptions()).extracting(ColumnOption::getKind)
237+
.containsExactly(ColumnOption.Kind.DEFAULT, ColumnOption.Kind.NULLABILITY);
238+
Expression value = defaultOption(column).getDefaultExpression();
239+
assertInstanceOf(ParenthesedExpressionList.class, value);
240+
assertInstanceOf(CollateExpression.class, ((ParenthesedExpressionList<?>) value).get(0));
241+
}
242+
243+
@Test
244+
void setDefaultAndDomainDefaultsKeepTheExpressionCollate() throws JSQLParserException {
245+
// PostgreSQL's SET DEFAULT takes an a_expr, which includes COLLATE. CREATE DOMAIN is
246+
// unchanged.
247+
Consumer<CCJSqlParser> postgres = parser -> parser.withDialect(Dialect.POSTGRESQL);
248+
Alter table = (Alter) assertSqlCanBeParsedAndDeparsed(
249+
"ALTER TABLE t ALTER COLUMN c SET DEFAULT 'x' COLLATE \"C\"", true, postgres);
250+
assertInstanceOf(CollateExpression.class, table.getAlterExpressions().get(0)
251+
.getColumnDefaults().get(0).getDefaultExpression());
252+
AlterRelation view = (AlterRelation) assertSqlCanBeParsedAndDeparsed(
253+
"ALTER VIEW v ALTER COLUMN c SET DEFAULT 'x' COLLATE \"C\"", true, postgres);
254+
assertInstanceOf(CollateExpression.class,
255+
view.getActions().get(0).getDefaultExpression());
256+
AlterDomain alterDomain = (AlterDomain) assertSqlCanBeParsedAndDeparsed(
257+
"ALTER DOMAIN d SET DEFAULT 'x' COLLATE \"C\"", true, postgres);
258+
assertInstanceOf(CollateExpression.class, alterDomain.getDefaultExpression());
259+
CreateDomain createDomain = (CreateDomain) assertSqlCanBeParsedAndDeparsed(
260+
"CREATE DOMAIN d AS text DEFAULT 'x' COLLATE \"C\"", true, postgres);
261+
assertInstanceOf(CollateExpression.class, createDomain.getDefaultExpression());
262+
}
263+
164264
@ParameterizedTest
165265
@ValueSource(strings = {"CREATE TABLE t (a INT DEFAULT (1 + 2",
166266
"CREATE TABLE t (a INT DEFAULT (1 +))",

0 commit comments

Comments
 (0)