From c28aa5250d009bfa26f1ad692222403d991a843a Mon Sep 17 00:00:00 2001 From: minleejae Date: Fri, 18 Sep 2026 13:20:08 +0900 Subject: [PATCH] feat: expose base type names and retain large numeric lengths --- .../statement/create/table/ColDataType.java | 108 +++++++++-- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 12 +- .../create/table/TypeMetadataTest.java | 171 ++++++++++++++++++ .../net/sf/jsqlparser/test/TestUtils.java | 8 +- 4 files changed, 276 insertions(+), 23 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/table/TypeMetadataTest.java 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..ba84b01e2 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 @@ -13,6 +13,7 @@ import net.sf.jsqlparser.statement.select.PlainSelect; import java.io.Serializable; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -43,7 +44,8 @@ public enum NationalCharacterType { private List arrayData = new ArrayList(); private Signedness signedness; private boolean zerofill; - private Integer precision; + private BigInteger precision; + private boolean maxPrecision; private Integer scale; private List typeModifiers; private NationalCharacterType nationalCharacterType; @@ -54,16 +56,29 @@ public ColDataType() { } public ColDataType(String dataType, int precision, int scale) { - this.dataType = dataType; + this(dataType); + setNumericTypeParameters(precision < 0 ? null + : precision == Integer.MAX_VALUE ? "MAX" : Integer.toString(precision), + scale < 0 ? null : Integer.valueOf(scale)); + } - if (precision >= 0) { - this.precision = precision; - this.dataType += " (" + (precision == Integer.MAX_VALUE ? "MAX" : precision); - if (scale >= 0) { - this.scale = scale; - this.dataType += ", " + scale; - } - this.dataType += ")"; + /** + * Creates a type from its numeric parameter spelling. Unlike the legacy primitive constructor, + * a numeric 2147483647 is distinct from MAX and larger lengths are retained without narrowing. + */ + public static ColDataType fromTypeParameters(String dataType, String precision, Integer scale) { + ColDataType type = new ColDataType(dataType); + type.setNumericTypeParameters(precision, scale); + return type; + } + + private void setNumericTypeParameters(String parameter, Integer scale) { + if (parameter != null) { + maxPrecision = "MAX".equalsIgnoreCase(parameter); + precision = maxPrecision ? null : new BigInteger(parameter); + this.scale = scale; + dataType += " (" + (maxPrecision ? "MAX" : precision) + + (scale != null ? ", " + scale : "") + ")"; } } @@ -83,6 +98,54 @@ public String getDataType() { return dataType; } + /** + * Returns the type name without parenthesized parameters, retaining qualification, quoting and + * multiword names such as TIMESTAMP WITH TIME ZONE. The legacy {@link #getDataType()} spelling + * is unchanged. Quoted parentheses are part of an identifier and are retained. + */ + public String getBaseTypeName() { + if (dataType == null) { + return null; + } + StringBuilder name = new StringBuilder(); + int depth = 0; + char quote = 0; + for (int i = 0; i < dataType.length(); i++) { + char c = dataType.charAt(i); + if (quote != 0) { + if (depth == 0) { + name.append(c); + } + if (c == quote) { + if (i + 1 < dataType.length() && dataType.charAt(i + 1) == quote) { + if (depth == 0) { + name.append(quote); + } + i++; + } else { + quote = 0; + } + } + } else if (c == '\'' || c == '"' || c == '`' || c == '[') { + quote = c == '[' ? ']' : c; + if (depth == 0) { + name.append(c); + } + } else if (c == '(') { + depth++; + while (name.length() > 0 + && Character.isWhitespace(name.charAt(name.length() - 1))) { + name.setLength(name.length() - 1); + } + } else if (c == ')' && depth > 0) { + depth--; + } else if (depth == 0) { + name.append(c); + } + } + return name.toString().trim(); + } + public void setDataType(String string) { dataType = string; } @@ -200,14 +263,35 @@ public ColDataType withXmlTypeModifier(XmlTypeModifier xmlTypeModifier) { * The first numeric type parameter, e.g. {@code 255} for {@code VARCHAR(255)} or {@code 10} for * {@code DECIMAL(10, 2)}. {@code MAX} is reported as {@link Integer#MAX_VALUE}. Returns * {@code null} when the type carries no numeric parameters, e.g. {@code INT} or - * {@code ENUM('a', 'b')}. + * {@code ENUM('a', 'b')}, or when a numeric length exceeds the integer range. Use + * {@link #getNumericPrecision()} for the full range and {@link #isMaxPrecision()} to + * distinguish MAX from the numeric value 2147483647. */ public Integer getPrecision() { - return precision; + return maxPrecision ? Integer.valueOf(Integer.MAX_VALUE) + : precision != null && precision.bitLength() < Integer.SIZE + ? Integer.valueOf(precision.intValue()) + : null; } public void setPrecision(Integer precision) { + setNumericPrecision(precision == null ? null : BigInteger.valueOf(precision)); + } + + /** Returns the exact numeric parameter, or null for an omitted parameter or MAX. */ + public BigInteger getNumericPrecision() { + return precision; + } + + /** Updates numeric metadata without changing the legacy rendered type spelling. */ + public void setNumericPrecision(BigInteger precision) { this.precision = precision; + maxPrecision = false; + } + + /** Distinguishes the MAX keyword from an equal numeric value in the legacy accessor. */ + public boolean isMaxPrecision() { + return maxPrecision; } /** diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index fd6b41f64..e2155d0b7 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -14929,14 +14929,14 @@ ColDataType DataType(): ColDataType arrayType; Integer zonePrecision = null; - int precision = -1; + String precision = null; int scale = -1; } { ( LOOKAHEAD(2) tk= { type = tk.image; - return new ColDataType(type, precision, scale); + return ColDataType.fromTypeParameters(type, precision, scale < 0 ? null : Integer.valueOf(scale)); } | LOOKAHEAD(2) tk= ( @@ -14963,13 +14963,13 @@ ColDataType DataType(): | tk= | tk= | tk= ) { type += " " + tk.image; } )* [ - LOOKAHEAD(2) "(" ( tk= { precision = Integer.valueOf(tk.image); } | tk= { precision = Integer.MAX_VALUE; } ) + LOOKAHEAD(2) "(" ( tk= { precision = tk.image; } | tk= { precision = "MAX"; } ) [ | ] [ "," tk = { scale = Integer.valueOf(tk.image); } ] ")" ] { - colDataType = new ColDataType(type, precision, scale); + colDataType = ColDataType.fromTypeParameters(type, precision, scale < 0 ? null : Integer.valueOf(scale)); if (zonePrecision != null) { colDataType.setPrecision(zonePrecision); } @@ -15158,10 +15158,10 @@ ColDataType ColDataType(): colDataType.setArgumentsStringList(argumentsStringList); // Digits-only arguments are the type's numeric parameters, e.g. mediumint(9). if (argumentsStringList.size() == 1 && argumentsStringList.get(0).matches("\\d+")) { - colDataType.setPrecision(Integer.valueOf(argumentsStringList.get(0))); + colDataType.setNumericPrecision(new java.math.BigInteger(argumentsStringList.get(0))); } else if (argumentsStringList.size() == 2 && argumentsStringList.get(0).matches("\\d+") && argumentsStringList.get(1).matches("\\d+")) { - colDataType.setPrecision(Integer.valueOf(argumentsStringList.get(0))); + colDataType.setNumericPrecision(new java.math.BigInteger(argumentsStringList.get(0))); colDataType.setScale(Integer.valueOf(argumentsStringList.get(1))); } } diff --git a/src/test/java/net/sf/jsqlparser/statement/create/table/TypeMetadataTest.java b/src/test/java/net/sf/jsqlparser/statement/create/table/TypeMetadataTest.java new file mode 100644 index 000000000..a779dac70 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/table/TypeMetadataTest.java @@ -0,0 +1,171 @@ +/*- + * #%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.create.table; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigInteger; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.CastExpression; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +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.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class TypeMetadataTest { + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "MYSQL | DECIMAL(65, 30) | DECIMAL | 65 | 30", + "MYSQL | NUMERIC(1, 0) | NUMERIC | 1 | 0", + "MYSQL | VARCHAR(255) | VARCHAR | 255 |", + "MYSQL | NATIONAL VARCHAR(20) | NATIONAL VARCHAR | 20 |", + "MYSQL | mediumint(9) UNSIGNED | mediumint | 9 |", + "MYSQL | ENUM('a(b)', 'c') | ENUM | |", + "POSTGRESQL | NUMERIC(1000, 1000) | NUMERIC | 1000 | 1000", + "POSTGRESQL | NUMERIC(3, 5) | NUMERIC | 3 | 5", + "POSTGRESQL | DECIMAL(10) | DECIMAL | 10 |", + "POSTGRESQL | CHARACTER VARYING(255) | CHARACTER VARYING | 255 |", + "POSTGRESQL | TIMESTAMP(6) WITH TIME ZONE | TIMESTAMP WITH TIME ZONE | 6 |", + "POSTGRESQL | TIME(0) WITHOUT TIME ZONE | TIME WITHOUT TIME ZONE | 0 |", + "POSTGRESQL | NUMERIC(10, 2)[] | NUMERIC | 10 | 2", + "POSTGRESQL | INTERVAL(6) | INTERVAL | 6 |"}) + void exposesMetadataAcrossFragmentsDdlAndPostgreSqlCasts(Dialect dialect, String typeName, + String base, Integer precision, Integer scale) throws JSQLParserException { + ColDataType fragment = parseType(typeName, dialect); + assertMetadata(fragment, base, precision, scale); + CreateTable table = (CreateTable) CCJSqlParserUtil.parse( + "CREATE TABLE type_case (v " + typeName + ")", p -> p.withDialect(dialect)); + assertMetadata(table.getColumnDefinitions().get(0).getColDataType(), base, precision, + scale); + assertMetadata(parseType(fragment.toString(), dialect), base, precision, scale); + if (dialect == Dialect.POSTGRESQL) { + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse( + "SELECT CAST(NULL AS " + typeName + ")", p -> p.withDialect(dialect)); + CastExpression cast = select.getSelectItem(0).getExpression(CastExpression.class); + assertMetadata(cast.getColDataType(), base, precision, scale); + } + } + + // MySQL BLOB/TEXT family boundaries and the signed-int boundary, verified on MySQL 8.4. + @ParameterizedTest + @ValueSource(strings = {"0", "1", "255", "256", "65535", "65536", "16777215", + "16777216", "2147483647", "2147483648", "4294967295"}) + void retainsLargeMySqlLengthsThroughBothRenderers(String length) throws JSQLParserException { + BigInteger expected = new BigInteger(length); + for (String name : List.of("BLOB", "TEXT")) { + ColDataType fragment = parseType(name + "(" + length + ")", Dialect.MYSQL); + assertEquals(name, fragment.getBaseTypeName()); + assertEquals(expected, fragment.getNumericPrecision()); + assertFalse(fragment.isMaxPrecision()); + assertFalse(fragment.toString().contains("MAX")); + assertEquals(expected.bitLength() < Integer.SIZE ? Integer.valueOf(length) : null, + fragment.getPrecision()); + CreateTable table = (CreateTable) CCJSqlParserUtil.parse( + "CREATE TABLE type_case (v " + name + "(" + length + "))", + p -> p.withDialect(Dialect.MYSQL)); + StringBuilder visitor = new StringBuilder(); + table.accept(new StatementDeParser(visitor)); + for (String rendered : List.of(table.toString(), visitor.toString())) { + CreateTable reparsed = (CreateTable) CCJSqlParserUtil.parse(rendered, + p -> p.withDialect(Dialect.MYSQL)); + ColDataType type = reparsed.getColumnDefinitions().get(0).getColDataType(); + assertEquals(expected, type.getNumericPrecision()); + assertFalse(type.isMaxPrecision()); + } + } + } + + @Test + void distinguishesMaxKeywordFromItsLegacySentinelValue() throws JSQLParserException { + ColDataType max = parseType("VARCHAR(MAX)", Dialect.SQLSERVER); + assertTrue(max.isMaxPrecision()); + assertNull(max.getNumericPrecision()); + assertEquals(Integer.MAX_VALUE, max.getPrecision()); + assertEquals("VARCHAR (MAX)", max.getDataType()); + ColDataType literal = parseType("BLOB(2147483647)", Dialect.MYSQL); + assertFalse(literal.isMaxPrecision()); + assertEquals(BigInteger.valueOf(Integer.MAX_VALUE), literal.getNumericPrecision()); + assertEquals("BLOB (2147483647)", literal.getDataType()); + assertEquals("VARCHAR (MAX)", new ColDataType("VARCHAR", Integer.MAX_VALUE, -1).toString()); + assertNull(new ColDataType("VARCHAR", -1, -1).getNumericPrecision()); + } + + @Test + void metadataSettersDoNotLeaveStaleLargeOrMaxValues() throws JSQLParserException { + ColDataType type = parseType("BLOB(4294967295)", Dialect.MYSQL); + type.setPrecision(5); + assertEquals(BigInteger.valueOf(5), type.getNumericPrecision()); + type.setPrecision(null); + assertNull(type.getNumericPrecision()); + type = parseType("VARCHAR(MAX)", Dialect.SQLSERVER); + type.setNumericPrecision(BigInteger.valueOf(2147483648L)); + assertFalse(type.isMaxPrecision()); + assertNull(type.getPrecision()); + assertEquals(BigInteger.valueOf(2147483648L), type.getNumericPrecision()); + } + + @ParameterizedTest + @ValueSource(strings = {"\"odd(type)\"", "public.\"odd(type)\"", "\"a\"\"(b)\""}) + void retainsQuotedParenthesesAndQualification(String name) throws JSQLParserException { + assertEquals(name, parseType(name, Dialect.POSTGRESQL).getBaseTypeName()); + ColDataType manual = new ColDataType(name, 10, 2); + assertEquals(name, manual.getBaseTypeName()); + assertEquals(name + " (10, 2)", manual.getDataType()); + } + + @Test + void baseNameFollowsLegacyTypeEditsWithoutStaleCache() { + ColDataType type = new ColDataType("DECIMAL", 10, 2); + type.setDataType("VARCHAR (20)"); + assertEquals("VARCHAR", type.getBaseTypeName()); + assertEquals("VARCHAR (20)", type.getDataType()); + type.setDataType(List.of("app", "\"kind(1)\"")); + assertEquals("app.\"kind(1)\"", type.getBaseTypeName()); + type.setDataType((String) null); + assertNull(type.getBaseTypeName()); + } + + @ParameterizedTest + @ValueSource(strings = {"BLOB(-1)", "BLOB(1.5)", "BLOB(4294967295", "DECIMAL(10,)"}) + void rejectsMalformedNumericParameters(String type) { + assertThrows(JSQLParserException.class, () -> parseType(type, Dialect.MYSQL)); + } + + @Test + void leavesDatabaseSpecificRangeValidationToTheDatabase() throws JSQLParserException { + // Syntactically numeric, but MySQL rejects this BLOB length as out of range. + String length = "18446744073709551616"; + assertEquals(new BigInteger(length), + parseType("BLOB(" + length + ")", Dialect.MYSQL).getNumericPrecision()); + } + + private static ColDataType parseType(String sql, Dialect dialect) throws JSQLParserException { + return CCJSqlParserUtil.parseColDataType(sql, p -> p.withDialect(dialect)); + } + + private static void assertMetadata(ColDataType type, String base, Integer precision, + Integer scale) { + assertEquals(base, type.getBaseTypeName()); + assertEquals(precision, type.getPrecision()); + assertEquals(precision == null ? null : BigInteger.valueOf(precision), + type.getNumericPrecision()); + assertEquals(scale, type.getScale()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/test/TestUtils.java b/src/test/java/net/sf/jsqlparser/test/TestUtils.java index dff34d63f..ffa71fa4c 100644 --- a/src/test/java/net/sf/jsqlparser/test/TestUtils.java +++ b/src/test/java/net/sf/jsqlparser/test/TestUtils.java @@ -287,11 +287,9 @@ protected void appendDetail(final StringBuffer buffer, final String fieldName, @Override protected boolean accept(Class clazz) { - if (includingASTNode) { - return super.accept(clazz); - } else { - return isNotANode(clazz) && super.accept(clazz); - } + // Numeric values have value semantics; do not reflect into encapsulated JDK internals. + return !Number.class.isAssignableFrom(clazz) + && (includingASTNode || isNotANode(clazz)) && super.accept(clazz); } public boolean isNotANode(Class clazz) {