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 c41c713ec..77a95f4cc 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 @@ -47,6 +47,7 @@ public enum NationalCharacterType { private Integer scale; private List typeModifiers; private NationalCharacterType nationalCharacterType; + private XmlTypeModifier xmlTypeModifier; public ColDataType() { // empty constructor @@ -182,6 +183,19 @@ public boolean isNational() { return nationalCharacterType != null; } + public XmlTypeModifier getXmlTypeModifier() { + return xmlTypeModifier; + } + + public void setXmlTypeModifier(XmlTypeModifier xmlTypeModifier) { + this.xmlTypeModifier = xmlTypeModifier; + } + + public ColDataType withXmlTypeModifier(XmlTypeModifier xmlTypeModifier) { + setXmlTypeModifier(xmlTypeModifier); + return this; + } + /** * 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 @@ -220,6 +234,7 @@ public String toString() { } return dataType + (intervalQualifier != null ? " " + intervalQualifier.toString() : "") + + (xmlTypeModifier != null ? " " + xmlTypeModifier : "") + (argumentsStringList != null ? " " + PlainSelect.getStringList(argumentsStringList, true, true) : "") @@ -330,12 +345,16 @@ public final boolean equals(Object o) { && signedness == that.signedness && zerofill == that.zerofill && Objects.equals(typeModifiers, that.typeModifiers) + && Objects.equals(xmlTypeModifier, that.xmlTypeModifier) && nationalCharacterType == that.nationalCharacterType; } @Override public int hashCode() { - int result = dataType.hashCode(); + // Use the same per-code-point case folding as String.equalsIgnoreCase, including Unicode. + int result = dataType.codePoints() + .map(c -> Character.toLowerCase(Character.toUpperCase(c))) + .reduce(0, (hash, c) -> 31 * hash + c); result = 31 * result + Objects.hashCode(argumentsStringList); result = 31 * result + Objects.hashCode(characterSet); result = 31 * result + Objects.hashCode(intervalQualifier); @@ -344,6 +363,7 @@ public int hashCode() { result = 31 * result + Boolean.hashCode(zerofill); result = 31 * result + Objects.hashCode(typeModifiers); result = 31 * result + Objects.hashCode(nationalCharacterType); + result = 31 * result + Objects.hashCode(xmlTypeModifier); return result; } } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/XmlTypeModifier.java b/src/main/java/net/sf/jsqlparser/statement/create/table/XmlTypeModifier.java new file mode 100644 index 000000000..61d0a7af8 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/XmlTypeModifier.java @@ -0,0 +1,72 @@ +/*- + * #%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 java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** A SQL Server XML schema collection reference, with an optional CONTENT/DOCUMENT facet. */ +public final class XmlTypeModifier implements Serializable { + public enum Kind { + CONTENT, DOCUMENT + } + + private final Kind kind; + private final List schemaCollection; + + public XmlTypeModifier(Kind kind, List schemaCollection) { + if (schemaCollection == null || schemaCollection.isEmpty() || schemaCollection.size() > 2 + || schemaCollection.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException( + "An XML schema collection needs a name and optional schema"); + } + this.kind = kind; + this.schemaCollection = Collections.unmodifiableList(new ArrayList<>(schemaCollection)); + } + + /** Null preserves an omitted facet, which SQL Server interprets as CONTENT. */ + public Kind getKind() { + return kind; + } + + public Kind getEffectiveKind() { + return kind == null ? Kind.CONTENT : kind; + } + + /** Name parts retain their original identifier quoting. */ + public List getSchemaCollection() { + return schemaCollection; + } + + @Override + public String toString() { + return "(" + (kind == null ? "" : kind + " ") + String.join(".", schemaCollection) + ")"; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof XmlTypeModifier)) { + return false; + } + XmlTypeModifier that = (XmlTypeModifier) other; + return kind == that.kind && schemaCollection.equals(that.schemaCollection); + } + + @Override + public int hashCode() { + return Objects.hash(kind, schemaCollection); + } +} diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index ebb26f492..d083760fe 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1205,6 +1205,11 @@ public class CCJSqlParser extends AbstractJSqlParser { return token.image != null && keyword.equalsIgnoreCase(token.image); } + private boolean isXmlTypeName(String name) { + return "XML".equalsIgnoreCase(name) || "[XML]".equalsIgnoreCase(name) + || "\"XML\"".equalsIgnoreCase(name); + } + private boolean isMySqlTableOptionAhead() { int kind = getToken(1).kind; if (kind == K_ENGINE || kind == K_COLLATE || kind == K_COMMENT @@ -12668,9 +12673,15 @@ ColDataType ColDataType(): Token nationalType = null; Token varying = null; ColDataType.TypeModifier typeModifier = null; + XmlTypeModifier xmlTypeModifier = null; } { ( + LOOKAHEAD({ isXmlTypeName(getToken(1).image) && "(".equals(getToken(2).image) }) + ( tk= | tk= ) + { colDataType.setDataType(tk.image); } + xmlTypeModifier = XmlTypeModifier() { colDataType.setXmlTypeModifier(xmlTypeModifier); } + | ( "(" @@ -12788,6 +12799,8 @@ ColDataType ColDataType(): [ LOOKAHEAD(2) (tk= | tk=) { colDataType.setCharacterSet(tk.image); } ] { + requireDdlSyntax(colDataType.getXmlTypeModifier() == null || argumentsStringList.isEmpty(), + "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). @@ -12806,6 +12819,25 @@ ColDataType ColDataType(): } } +XmlTypeModifier XmlTypeModifier(): +{ + XmlTypeModifier.Kind kind = null; + List names = new ArrayList(); + String name; + Token token; +} +{ + "(" + [ LOOKAHEAD({ (isKeywordAhead("CONTENT") || isKeywordAhead("DOCUMENT")) + && !".".equals(getToken(2).image) && !")".equals(getToken(2).image) }) + token= { kind = XmlTypeModifier.Kind.valueOf(token.image.toUpperCase(Locale.ROOT)); } + ] + name=RelObjectName() { names.add(name); } + [ "." name=RelObjectNameExt() { names.add(name); } ] + ")" + { return new XmlTypeModifier(kind, names); } +} + ColDataType.TypeModifier MySqlTypeModifier(): { ColDataType.TypeModifier modifier; diff --git a/src/test/java/net/sf/jsqlparser/statement/create/table/SqlServerXmlTypeTest.java b/src/test/java/net/sf/jsqlparser/statement/create/table/SqlServerXmlTypeTest.java new file mode 100644 index 000000000..5c065ed80 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/table/SqlServerXmlTypeTest.java @@ -0,0 +1,112 @@ +/*- + * #%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.*; + +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.test.TestUtils; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class SqlServerXmlTypeTest { + @ParameterizedTest + @ValueSource(strings = { + "XML(CONTENT dbo.OrderSchema)", + "XML(DOCUMENT dbo.OrderSchema)", + "XML(dbo.OrderSchema)", + "XML(OrderSchema)", + "[xml](CONTENT [Person].[AdditionalContactInfoSchemaCollection])", + "\"xml\"(DOCUMENT \"Person\".\"Order Schema\")", + "XML(CONTENT)", + "XML(DOCUMENT.Collection)", + "XML([schema.with.dot].[collection.with.dot])" + }) + void roundTripTypedXml(String typeSql) throws JSQLParserException { + CreateTable table = (CreateTable) TestUtils.assertSqlCanBeParsedAndDeparsed( + "CREATE TABLE dbo.t (payload " + typeSql + " NULL)", true, + parser -> parser.withDialect(Dialect.SQLSERVER)); + ColDataType type = table.getColumnDefinitions().get(0).getColDataType(); + assertNotNull(type.getXmlTypeModifier()); + assertNull(type.getArgumentsStringList()); + assertNull(type.getPrecision()); + assertNull(type.getScale()); + assertEquals(List.of("dbo.t"), new TablesNamesFinder().getTableList(table)); + } + + @ParameterizedTest + @ValueSource(strings = { + "ALTER TABLE t ADD payload XML(CONTENT dbo.OrderSchema)", + "ALTER TABLE t ALTER COLUMN payload XML(DOCUMENT dbo.OrderSchema)", + "DECLARE @payload XML(CONTENT dbo.OrderSchema)", + "CREATE TABLE t (untyped XML, n DECIMAL(10, 2), s VARCHAR(20))" + }) + void reuseTypeParsingAcrossStatements(String sql) throws JSQLParserException { + TestUtils.assertSqlCanBeParsedAndDeparsed(sql, true, + parser -> parser.withDialect(Dialect.SQLSERVER)); + } + + @Test + void reproduceIssue1567() throws JSQLParserException { + TestUtils.assertSqlCanBeParsedAndDeparsed("CREATE TABLE [Person].[Person] (" + + "[BusinessEntityID] [int] NOT NULL, [PersonType] [nchar](2) NOT NULL, " + + "[NameStyle] [dbo].[NameStyle] NOT NULL, [Title] [nvarchar](8) NULL, " + + "[FirstName] [dbo].[Name] NOT NULL, [MiddleName] [dbo].[Name] NULL, " + + "[LastName] [dbo].[Name] NOT NULL, [Suffix] [nvarchar](10) NULL, " + + "[EmailPromotion] [int] NOT NULL, " + + "[AdditionalContactInfo] [xml](CONTENT [Person].[AdditionalContactInfoSchemaCollection]) NULL, " + + "[Demographics] [xml](CONTENT [Person].[IndividualSurveySchemaCollection]) NULL, " + + "[rowguid] [uniqueidentifier] ROWGUIDCOL NOT NULL, [ModifiedDate] [datetime] NOT NULL)", + true, parser -> parser.withDialect(Dialect.SQLSERVER)); + } + + @ParameterizedTest + @ValueSource(strings = {"XML()", "XML(10)", "XML(OTHER dbo.c)", + "XML(CONTENT dbo.c, dbo.d)", "XML(CONTENT db.dbo.c)", "XML(CONTENT dbo.c"}) + void rejectInvalidModifier(String typeSql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse("CREATE TABLE t (x " + typeSql + ")", + parser -> parser.withDialect(Dialect.SQLSERVER) + .withUnsupportedStatements(false))); + } + + @Test + void structuredValuesAndEquality() { + XmlTypeModifier omitted = new XmlTypeModifier(null, List.of("dbo", "c")); + assertNull(omitted.getKind()); + assertEquals(XmlTypeModifier.Kind.CONTENT, omitted.getEffectiveKind()); + assertThrows(UnsupportedOperationException.class, + () -> omitted.getSchemaCollection().clear()); + ColDataType lower = new ColDataType("xml").withXmlTypeModifier(omitted); + ColDataType upper = new ColDataType("XML").withXmlTypeModifier( + new XmlTypeModifier(null, List.of("dbo", "c"))); + assertEquals(lower, upper); + assertEquals(lower.hashCode(), upper.hashCode()); + assertNotEquals(lower, new ColDataType("xml")); + assertNotEquals(omitted, + new XmlTypeModifier(XmlTypeModifier.Kind.CONTENT, List.of("dbo", "c"))); + assertNotEquals(omitted, new XmlTypeModifier(null, List.of("dbo", "other"))); + lower.setXmlTypeModifier(null); + assertEquals("xml", lower.toString()); + } + + @Test + void caseInsensitiveTypeHashAlsoHandlesUnicode() { + ColDataType dotted = new ColDataType("\u0130"); + ColDataType plain = new ColDataType("i"); + assertEquals(dotted, plain); + assertEquals(dotted.hashCode(), plain.hashCode()); + } +}