diff --git a/src/main/java/net/sf/jsqlparser/schema/Column.java b/src/main/java/net/sf/jsqlparser/schema/Column.java index 33240ac52a..3bb06de241 100644 --- a/src/main/java/net/sf/jsqlparser/schema/Column.java +++ b/src/main/java/net/sf/jsqlparser/schema/Column.java @@ -49,10 +49,14 @@ public Column(List nameParts) { } public Column(List nameParts, List delimiters) { - this( - nameParts.size() > 1 ? new Table(nameParts.subList(0, nameParts.size() - 1), - delimiters.subList(0, delimiters.size() - 1)) : null, - nameParts.get(nameParts.size() - 1)); + if (nameParts.size() > 1) { + setTable(new Table(nameParts.subList(0, nameParts.size() - 1), + delimiters.subList(0, delimiters.size() - 1))); + // The final component is already separated from the table name. + setName(nameParts.get(nameParts.size() - 1), false); + } else { + setColumnName(nameParts.get(0)); + } setTableDelimiter(delimiters.isEmpty() ? "." : delimiters.get(delimiters.size() - 1)); } @@ -149,36 +153,25 @@ public void setColumnName(String name) { } public void setName(String name, boolean splitNamesOnDelimiter) { - if (MultiPartName.isQuoted(name) && name.contains(".") && splitNamesOnDelimiter) { - String[] parts = MultiPartName.unquote(name).split("\\."); - switch (parts.length) { - case 3: - this.table = new Table("\"" + parts[0] + "\".\"" + parts[1] + "\""); - this.columnName = "\"" + parts[2] + "\""; - break; - case 2: - this.table = new Table("\"" + parts[0] + "\""); - this.columnName = "\"" + parts[1] + "\""; - break; - case 1: - this.columnName = "\"" + parts[0] + "\""; - break; - default: - throw new RuntimeException("Invalid column name: " + name); + if (name.contains(".") && splitNamesOnDelimiter) { + List parts = splitName(name); + if (parts.size() == 1 && MultiPartName.isQuoted(name)) { + parts.clear(); + for (String identifier : MultiPartName.unquote(name).split("\\.")) { + parts.add("\"" + identifier + "\""); + } } - } else if (name.contains(".") && splitNamesOnDelimiter) { - String[] parts = MultiPartName.unquote(name).split("\\."); - switch (parts.length) { + switch (parts.size()) { case 3: - this.table = new Table(parts[0] + "." + parts[1]); - this.columnName = parts[2]; + this.table = new Table(parts.subList(0, 2)); + this.columnName = parts.get(2); break; case 2: - this.table = new Table(parts[0]); - this.columnName = parts[1]; + this.table = new Table(parts.get(0), false); + this.columnName = parts.get(1); break; case 1: - this.columnName = parts[0]; + this.columnName = parts.get(0); break; default: throw new RuntimeException("Invalid column name: " + name); @@ -188,6 +181,34 @@ public void setName(String name, boolean splitNamesOnDelimiter) { } } + private static List splitName(String name) { + List parts = new ArrayList<>(); + int start = 0; + char quote = 0; + for (int i = 0; i < name.length(); i++) { + char ch = name.charAt(i); + if (quote != 0) { + if (ch == quote) { + if (i + 1 < name.length() && name.charAt(i + 1) == quote) { + i++; + } else { + quote = 0; + } + } + } else if (ch == '"' || ch == '`' || ch == '[') { + quote = ch == '[' ? ']' : ch; + } else if (ch == '.') { + parts.add(name.substring(start, i)); + start = i + 1; + } + } + parts.add(name.substring(start)); + while (!parts.isEmpty() && parts.get(parts.size() - 1).isEmpty()) { + parts.remove(parts.size() - 1); + } + return parts; + } + public String getTableDelimiter() { return tableDelimiter; } diff --git a/src/test/java/net/sf/jsqlparser/schema/ColumnTest.java b/src/test/java/net/sf/jsqlparser/schema/ColumnTest.java index 4c8469429e..c5db20f74c 100644 --- a/src/test/java/net/sf/jsqlparser/schema/ColumnTest.java +++ b/src/test/java/net/sf/jsqlparser/schema/ColumnTest.java @@ -9,17 +9,34 @@ */ package net.sf.jsqlparser.schema; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.select.PlainSelect; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.stream.Stream; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static net.sf.jsqlparser.test.TestUtils.assertStatementCanBeDeparsedAs; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; /** * * @author tw */ +@ResourceLock(Resources.SYSTEM_PROPERTIES) public class ColumnTest { @Test @@ -54,4 +71,202 @@ public void testConstructorNamePartsAndDelimiters() { assertThat(table.getNamePartDelimiters()).containsExactly(".", ":"); } + @ParameterizedTest + @MethodSource("qualifiedQuotedNames") + void testQualifiedQuotedNames(String name, List tableParts, String columnName) { + assertColumn(new Column(name), name, tableParts, columnName); + Column column = new Column(); + column.setName(name, true); + assertColumn(column, name, tableParts, columnName); + assertColumn(new Column().withColumnName(name), name, tableParts, columnName); + } + + @ParameterizedTest + @MethodSource("separatedQuotedNames") + void testQuotedNameParts(String name, List tableParts, String columnName) { + List nameParts = new ArrayList<>(tableParts); + Collections.reverse(nameParts); + nameParts.add(columnName); + assertColumn(new Column(nameParts), name, tableParts, columnName); + assertColumn(new Column(nameParts, Collections.nCopies(nameParts.size() - 1, ".")), + name, tableParts, columnName); + } + + @ParameterizedTest + @MethodSource("parsedQualifiedQuotedNames") + void testParsedQualifiedQuotedNames(String name, List tableParts, String columnName) + throws JSQLParserException { + String sql = "SELECT " + name + " FROM t1"; + PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withSquareBracketQuotation(true)); + assertColumn(select.getSelectItem(0).getExpression(Column.class), name, tableParts, + columnName); + PlainSelect reparsed = (PlainSelect) CCJSqlParserUtil.parse(select.toString(), + parser -> parser.withSquareBracketQuotation(true)); + assertColumn(reparsed.getSelectItem(0).getExpression(Column.class), name, tableParts, + columnName); + } + + @ParameterizedTest + @MethodSource("wholeQuotedNames") + void testWholeQuotedDottedNames(String name, String expected, List tableParts, + String columnName) throws JSQLParserException { + assertColumn(new Column(name), expected, tableParts, columnName); + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse("SELECT " + name + " FROM t1", + parser -> parser.withSquareBracketQuotation(true)); + assertColumn(select.getSelectItem(0).getExpression(Column.class), expected, tableParts, + columnName); + assertStatementCanBeDeparsedAs(select, "SELECT " + expected + " FROM t1"); + } + + @ParameterizedTest + @ValueSource(strings = {"id", "`id`", "\"id\"", "[id]"}) + void testUnqualifiedNames(String name) { + Column column = new Column(name); + assertEquals(name, column.getColumnName()); + assertEquals(name, column.toString()); + assertEquals("id", column.getUnquotedColumnName()); + assertNull(column.getTable()); + } + + @ParameterizedTest + @MethodSource("legacyQualifiedTableNames") + void testLegacyQualifiedTableNames(String tableName, String expected, + List tableParts) { + assertColumn(new Column(List.of(tableName, "id")), expected, tableParts, "id"); + assertColumn(new Column(List.of(tableName, "id"), List.of(".")), expected, + tableParts, "id"); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testBigQueryQualifiedTableColumn(boolean explicitDialect) throws JSQLParserException { + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse( + "SELECT `project.dataset.table`.id FROM `project.dataset.table`", parser -> { + if (explicitDialect) { + parser.withDialect(Dialect.BIGQUERY); + } + }); + Column column = select.getSelectItem(0).getExpression(Column.class); + assertColumn(column, "\"project\".\"dataset\".\"table\".id", + List.of("\"table\"", "\"dataset\"", "\"project\""), "id"); + assertEquals("project", column.getUnquotedCatalogName()); + assertEquals("dataset", column.getUnquotedSchemaName()); + assertEquals("table", column.getUnquotedTableName()); + assertEquals(select.getFromItem(Table.class).getNameParts(), + column.getTable().getNameParts()); + assertStatementCanBeDeparsedAs(select, + "SELECT \"project\".\"dataset\".\"table\".id FROM \"project\".\"dataset\".\"table\""); + } + + @ParameterizedTest + @ValueSource(strings = {"t1.`id`", "`t1.id`", "\"t1.id\"", "[t1.id]", "s.t1.id"}) + void testExplicitlyDisabledSplitting(String name) { + Column column = new Column(); + column.setName(name, false); + assertEquals(name, column.getColumnName()); + assertEquals(name, column.toString()); + assertNull(column.getTable()); + } + + @ParameterizedTest + @ValueSource(strings = {"false", "OFF", "0"}) + void testSystemPropertyDisablesSplitting(String value) { + String previous = System.getProperty("SPLIT_NAMES_ON_DELIMITER"); + try { + System.setProperty("SPLIT_NAMES_ON_DELIMITER", value); + for (String name : List.of("t1.`id`", "`t1.id`", "\"t1.id\"", "[t1.id]", "s.t1.id")) { + Column column = new Column(name); + assertEquals(name, column.getColumnName()); + assertEquals(name, column.toString()); + assertNull(column.getTable()); + } + } finally { + if (previous == null) { + System.clearProperty("SPLIT_NAMES_ON_DELIMITER"); + } else { + System.setProperty("SPLIT_NAMES_ON_DELIMITER", previous); + } + } + } + + private static void assertColumn(Column column, String name, List tableParts, + String columnName) { + assertEquals(name, column.toString()); + assertEquals(name, column.getFullyQualifiedName()); + assertEquals(columnName, column.getColumnName()); + assertThat(column.getTable().getNameParts()).containsExactlyElementsOf(tableParts); + assertEquals(tableParts.get(0), column.getTableName()); + assertEquals(tableParts.size() > 1 ? tableParts.get(1) : null, column.getSchemaName()); + assertEquals(tableParts.size() > 2 ? tableParts.get(2) : null, column.getCatalogName()); + } + + static Stream qualifiedQuotedNames() { + return Stream.of( + Arguments.of("t1.`id`", List.of("t1"), "`id`"), + Arguments.of("t1.\"id\"", List.of("t1"), "\"id\""), + Arguments.of("t1.[id]", List.of("t1"), "[id]"), + Arguments.of("`t1`.id", List.of("`t1`"), "id"), + Arguments.of("\"t1\".id", List.of("\"t1\""), "id"), + Arguments.of("[t1].id", List.of("[t1]"), "id"), + Arguments.of("`t1`.`id`", List.of("`t1`"), "`id`"), + Arguments.of("\"t1\".\"id\"", List.of("\"t1\""), "\"id\""), + Arguments.of("[t1].[id]", List.of("[t1]"), "[id]"), + Arguments.of("s.t1.`id`", List.of("t1", "s"), "`id`"), + Arguments.of("`s`.`t1`.`id`", List.of("`t1`", "`s`"), "`id`"), + Arguments.of("\"s\".\"t1\".\"id\"", List.of("\"t1\"", "\"s\""), "\"id\""), + Arguments.of("[s].[t1].[id]", List.of("[t1]", "[s]"), "[id]"), + Arguments.of("[s].`t1`.\"id\"", List.of("`t1`", "[s]"), "\"id\""), + Arguments.of("t1.`i.d`", List.of("t1"), "`i.d`"), + Arguments.of("t1.`i``.d`", List.of("t1"), "`i``.d`"), + Arguments.of("t1.\"i\"\".d\"", List.of("t1"), "\"i\"\".d\""), + Arguments.of("t1.[i]].d]", List.of("t1"), "[i]].d]"), + Arguments.of("`t.1`.id", List.of("`t.1`"), "id"), + Arguments.of("`s.1`.`t.1`.`i.d`", List.of("`t.1`", "`s.1`"), "`i.d`"), + Arguments.of("\"s.1\".\"t.1\".\"i.d\"", List.of("\"t.1\"", "\"s.1\""), "\"i.d\""), + Arguments.of("[s.1].[t.1].[i.d]", List.of("[t.1]", "[s.1]"), "[i.d]"), + Arguments.of("`t``.1`.`i``.d`", List.of("`t``.1`"), "`i``.d`"), + Arguments.of("\"t\"\".1\".\"i\"\".d\"", List.of("\"t\"\".1\""), "\"i\"\".d\""), + Arguments.of("[t]].1].[i]].d]", List.of("[t]].1]"), "[i]].d]"), + Arguments.of("`t\".1`.\"i`.d\"", List.of("`t\".1`"), "\"i`.d\""), + Arguments.of("t1.id", List.of("t1"), "id"), + Arguments.of("s.t1.id", List.of("t1", "s"), "id")); + } + + static Stream separatedQuotedNames() { + // A single table token containing dots keeps the legacy BigQuery interpretation. + return qualifiedQuotedNames() + .filter(arguments -> { + List tableParts = (List) arguments.get()[1]; + return tableParts.size() > 1 || !((String) tableParts.get(0)).contains("."); + }); + } + + static Stream parsedQualifiedQuotedNames() { + return separatedQuotedNames() + .filter(arguments -> !((String) arguments.get()[0]).contains("]]")); + } + + static Stream legacyQualifiedTableNames() { + return Stream.of( + Arguments.of("schema.table", "schema.table.id", List.of("table", "schema")), + Arguments.of("`schema.table`", "\"schema\".\"table\".id", + List.of("\"table\"", "\"schema\"")), + Arguments.of("`project.dataset.table`", "\"project\".\"dataset\".\"table\".id", + List.of("\"table\"", "\"dataset\"", "\"project\""))); + } + + static Stream wholeQuotedNames() { + return Stream.of( + Arguments.of("`t1.id`", "\"t1\".\"id\"", List.of("\"t1\""), "\"id\""), + Arguments.of("\"t1.id\"", "\"t1\".\"id\"", List.of("\"t1\""), "\"id\""), + Arguments.of("[t1.id]", "\"t1\".\"id\"", List.of("\"t1\""), "\"id\""), + Arguments.of("`s.t1.id`", "\"s\".\"t1\".\"id\"", List.of("\"t1\"", "\"s\""), + "\"id\""), + Arguments.of("\"s.t1.id\"", "\"s\".\"t1\".\"id\"", List.of("\"t1\"", "\"s\""), + "\"id\""), + Arguments.of("[s.t1.id]", "\"s\".\"t1\".\"id\"", List.of("\"t1\"", "\"s\""), + "\"id\"")); + } + }