You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by am...@apache.org on 2024/12/24 19:54:37 UTC
(ignite-3) 01/02: Introduce QualifiedName.
This is an automated email from the ASF dual-hosted git repository.
amashenkov pushed a commit to branch ignite-24036
in repository https://gitbox.apache.org/repos/asf/ignite-3.git
commit 8d6dc6b626713542cab46613256b1a17efdc2ec0
Author: amashenkov <am...@apache.org>
AuthorDate: Tue Dec 24 22:36:01 2024 +0300
Introduce QualifiedName.
---
.../org/apache/ignite/table/QualifiedName.java | 300 +++++++++++++++++++++
.../org/apache/ignite/table/QualifiedNameTest.java | 131 +++++++++
2 files changed, 431 insertions(+)
diff --git a/modules/api/src/main/java/org/apache/ignite/table/QualifiedName.java b/modules/api/src/main/java/org/apache/ignite/table/QualifiedName.java
new file mode 100644
index 0000000000..c2e132185f
--- /dev/null
+++ b/modules/api/src/main/java/org/apache/ignite/table/QualifiedName.java
@@ -0,0 +1,300 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.table;
+
+import java.util.Objects;
+import org.apache.ignite.lang.util.IgniteNameUtils;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Class represents a catalog object name (table, index and etc.) and provides factory methods.
+ *
+ * <p>Qualified name is a pair of schema name and object name. Schema name is optional and can be null, which means that it will be
+ * resolved in lazy manner against default schema. Default schema depends on the context, where the name will be used. If no context is
+ * available (e.g. client or embedded KeyValue API), then "PUBLIC" will be used as default schema name.
+ *
+ * <p>Both schema name and object name can be case sensitive or insensitive respecting SQL-parser style quotation.
+ * Unquoted name will be cast to upper case. E.g. "tbl0" - is equivalent to "TBL0", "\"Tbl0\"" - "Tbl0", etc.
+ */
+public class QualifiedName {
+ /**
+ * Parses given simple or canonical name, and returns the object qualified name.
+ */
+ public static QualifiedName parse(String simpleOrCanonicalName) {
+ notNullOrEmpty(simpleOrCanonicalName);
+
+ Tokenizer tokenizer = new Tokenizer(simpleOrCanonicalName);
+
+ String schemaName = null;
+ String objectName = tokenizer.nextToken();
+
+ if (tokenizer.hasNext()) {
+ // Canonical name
+ schemaName = objectName;
+ objectName = tokenizer.nextToken();
+ }
+
+ if (tokenizer.hasNext()) {
+ throw new IllegalArgumentException("Canonical name format mismatch: " + simpleOrCanonicalName);
+ }
+
+ nullOrNonEmpty(schemaName);
+ notNullOrEmpty(objectName);
+
+ return new QualifiedName(schemaName, objectName);
+ }
+
+ /**
+ * Normalize schemaName and objectName, and returns the object qualified name.
+ */
+ public static QualifiedName of(@Nullable String schemaName, String objectName) {
+ String normalizedSchemaName = schemaName == null ? null : parseIdentifier(schemaName);
+ String normalizedObjectName = parseIdentifier(objectName);
+
+ nullOrNonEmpty(normalizedSchemaName);
+ notNullOrEmpty(normalizedObjectName);
+
+ return new QualifiedName(normalizedSchemaName, normalizedObjectName);
+ }
+
+ public static String parseIdentifier(String name) {
+ if (name == null || name.isEmpty()) {
+ return name;
+ }
+
+ var tokenizer = new Tokenizer(name);
+
+ String parsedName = tokenizer.nextToken();
+
+ if (tokenizer.hasNext()) {
+ throw new IllegalArgumentException("Non-qualified identifier expected: " + name);
+ }
+
+ return parsedName;
+ }
+
+ private static void notNullOrEmpty(@Nullable String name) {
+ if (name == null || name.isEmpty()) {
+ throw new IllegalArgumentException("Object name can't be null or empty.");
+ }
+ }
+
+ private static void nullOrNonEmpty(@Nullable String schemaName) {
+ if (schemaName != null && schemaName.isEmpty()) {
+ throw new IllegalArgumentException("Schema name can't be empty.");
+ }
+ }
+
+ /**
+ * Resolves (maybe lazily) given simple name against default schema.
+ */
+ public static QualifiedName fromSimple(String simpleName) {
+ return of(null, simpleName);
+ }
+
+ private final @Nullable String schemaName;
+ private final String objectName;
+
+ /**
+ * Constructs a qualified name.
+ *
+ * @param schemaName Normalized schema name.
+ * @param objectName Normalized object name.
+ */
+ private QualifiedName(@Nullable String schemaName, String objectName) {
+ this.schemaName = schemaName;
+ this.objectName = objectName;
+ }
+
+ /** Returns normalized schema name. */
+ public @Nullable String schemaName() {
+ return schemaName;
+ }
+
+ /** Returns normalized object name. */
+ public String name() {
+ return objectName;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) {
+ return true;
+ }
+ if (object == null || getClass() != object.getClass()) {
+ return false;
+ }
+ QualifiedName that = (QualifiedName) object;
+ return Objects.equals(schemaName, that.schemaName) && Objects.equals(objectName, that.objectName);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public int hashCode() {
+ return Objects.hash(schemaName, objectName);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public String toString() {
+ String simpleName = IgniteNameUtils.quoteIfNeeded(objectName);
+
+ return schemaName == null ? simpleName : IgniteNameUtils.quoteIfNeeded(schemaName) + '.' + simpleName;
+ }
+
+
+ /**
+ * Identifier chain tokenizer.
+ *
+ * <p>Splits provided identifier chain (complex identifier like PUBLIC.MY_TABLE) into its component parts.
+ *
+ * <p>This tokenizer is not SQL compliant, but it is ok since it used to retrieve an object only. The sole purpose of this tokenizer
+ * is to split the chain into parts by a dot considering the quotation.
+ */
+ private static class Tokenizer {
+ private int currentPosition;
+ private final String source;
+
+ /**
+ * Creates a tokenizer for given string source.
+ *
+ * @param source Source string to split.
+ */
+ Tokenizer(String source) {
+ this.source = source;
+ }
+
+ /** Returns {@code true} if at least one token is available. */
+ boolean hasNext() {
+ return !isEol();
+ }
+
+ /** Returns next token. */
+ @Nullable String nextToken() {
+ if (!hasNext()) {
+ return null;
+ }
+
+ boolean quoted = currentChar() == '"';
+
+ if (quoted) {
+ currentPosition++;
+ }
+
+ int start = currentPosition;
+ StringBuilder sb = new StringBuilder();
+
+ if (!quoted && !isEol()) {
+ if (identifierStart(currentPosition)) {
+ currentPosition++;
+ } else {
+ throwMalformedNameException();
+ }
+ }
+
+ for (; !isEol(); currentPosition++) {
+ char c = currentChar();
+
+ if (c == '"') {
+ if (!quoted) {
+ throwMalformedNameException();
+ }
+
+ if (hasNextChar() && nextChar() == '"') { // quote is escaped
+ sb.append(source, start, currentPosition + 1);
+
+ start = currentPosition + 2;
+ currentPosition += 1;
+
+ continue;
+ } else if (!hasNextChar() || nextChar() == '.') {
+ // looks like we just found a closing quote
+ sb.append(source, start, currentPosition);
+
+ currentPosition += 2;
+
+ return sb.toString();
+ }
+
+ throwMalformedNameException();
+ } else if (c == '.') {
+ if (quoted) {
+ continue;
+ }
+
+ sb.append(source, start, currentPosition);
+
+ currentPosition++;
+
+ return sb.toString().toUpperCase();
+ } else if (!quoted && !identifierStart(currentPosition) && !identifierExtend(currentPosition)) {
+ throwMalformedNameException();
+ }
+ }
+
+ if (quoted) {
+ // seems like there is no closing quote
+ throwMalformedNameException();
+ }
+
+ return source.substring(start).toUpperCase();
+ }
+
+ /* An <identifier start> is any character in the Unicode General Category classes “Lu”, “Ll”, “Lt”, “Lm”, “Lo”, or “Nl”. */
+ private boolean identifierStart(int position) {
+ return Character.isAlphabetic(Character.codePointAt(source, position));
+ }
+
+ /* An <identifier extend> is U+00B7, or any character in the Unicode General Category classes “Mn”, “Mc”, “Nd”, “Pc”, or “Cf”.*/
+ private boolean identifierExtend(int position) {
+ if (source.charAt(position) == '\u00B7') {
+ /* “Middle Dot” character */
+ return true;
+ }
+
+ int codePoint = Character.codePointAt(source, position);
+
+ return ((((1 << Character.NON_SPACING_MARK)
+ | (1 << Character.COMBINING_SPACING_MARK)
+ | (1 << Character.DECIMAL_DIGIT_NUMBER)
+ | (1 << Character.CONNECTOR_PUNCTUATION)
+ | (1 << Character.FORMAT)) >> Character.getType(codePoint)) & 1) != 0;
+ }
+
+ private boolean isEol() {
+ return currentPosition >= source.length();
+ }
+
+ private char currentChar() {
+ return source.charAt(currentPosition);
+ }
+
+ private boolean hasNextChar() {
+ return currentPosition + 1 < source.length();
+ }
+
+ private char nextChar() {
+ return source.charAt(currentPosition + 1);
+ }
+
+ private void throwMalformedNameException() {
+ throw new IllegalArgumentException("Malformed name [name=" + source + ", pos=" + currentPosition + ']');
+ }
+ }
+}
diff --git a/modules/api/src/test/java/org/apache/ignite/table/QualifiedNameTest.java b/modules/api/src/test/java/org/apache/ignite/table/QualifiedNameTest.java
new file mode 100644
index 0000000000..3cbf06be0c
--- /dev/null
+++ b/modules/api/src/test/java/org/apache/ignite/table/QualifiedNameTest.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.table;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.anyOf;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.ignite.lang.util.IgniteNameUtils;
+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;
+
+/**
+ * Test cases to verify {@link QualifiedName}.
+ */
+public class QualifiedNameTest {
+ @ParameterizedTest
+ @CsvSource({
+ "foo, FOO", "fOo, FOO", "FOO, FOO", "f23, F23", // Uppercased
+ "\"FOO\", FOO", "\"foo\", foo", "\"fOo\", fOo", // Quoted
+ "\"f.f\", f.f", "\"f\"\"f\", f\"f", "\"f\"\"bar\"\"f\", f\"bar\"f", // Escaped
+ })
+ public void validSimpleNames(String source, String expected) {
+ QualifiedName simple = QualifiedName.fromSimple(source);
+ QualifiedName parsed = QualifiedName.parse(source);
+
+ assertThat(simple.name(), equalTo(expected));
+ assertThat(parsed.name(), equalTo(expected));
+
+ assertNull(simple.schemaName());
+ assertNull(parsed.schemaName());
+
+ assertEquals(parsed, simple);
+
+ // TODO: Fix an ambiguity, when the name starts with non-letter symbol should be wither quoted or uppercased.
+ // assertThat(parsed.toString(), equalTo(expected));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "foo.bar, FOO, BAR", "fOo.bAr, FOO, BAR", "FOO.BAR, FOO, BAR", "foo.b23, FOO, B23", "f23.bar, F23, BAR",// Uppercased
+ "\"FOO\".\"BAR\", FOO, BAR", "\"foo\".\"bar\", foo, bar", "\"fOo\".\"bAr\", fOo, bAr", "\"fOo\".bAr, fOo, BAR",
+ "fOo.\"bAr\", FOO, bAr", // Quoted
+ "\"foo.bar\".baz, foo.bar, BAZ", "foo.\"bar.baz\", FOO, bar.baz",
+ "\"foo.\"\"bar\"\"\".baz, foo.\"bar\", BAZ", "foo.\"bar.\"\"baz\"\"\", FOO, bar.\"baz\"" // Escaped
+ })
+ public void validCanonicalNames(String source, String schemaName, String objectName) {
+ QualifiedName parsed = QualifiedName.parse(source);
+
+ assertThat(parsed.schemaName(), equalTo(schemaName));
+ assertThat(parsed.name(), equalTo(objectName));
+
+ // TODO: Fix an ambiguity, when the name starts with non-letter symbol should be wither quoted or uppercased.
+ // assertThat(parsed.toString(), equalTo(quoteIfNeeded(schemaName) + '.' + quoteIfNeeded(objectName)));
+ }
+
+
+ @Test
+ public void parseSequenceOfSpaces() {
+ QualifiedName simple = QualifiedName.fromSimple("\" \"");
+ QualifiedName parsed = QualifiedName.parse("\" \"");
+
+ assertThat(simple.name(), equalTo(" "));
+ assertThat(parsed.name(), equalTo(" "));
+
+ assertNull(simple.schemaName());
+ assertNull(parsed.schemaName());
+
+ assertEquals(parsed, simple);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "f.f", "f f", "f\"f", "f\"\"f", "\"foo", "\"fo\"o\"", ".f", "f.", ".", "", "1o0, 1O0", "@#$, @#$",
+ })
+ public void malformedSimpleNames(String source) {
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> QualifiedName.fromSimple(source));
+
+ assertThat(ex.getMessage(), is(anyOf(
+ equalTo("Object name can't be null or empty."),
+ equalTo("Non-qualified identifier expected: " + source),
+ containsString("Malformed name [name=" + source))));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "f f", "f\"f", "f\"\"f", "\"foo", "\"fo\"o\"", ".f", "f.", ".",
+ "@#$.bar", "foo.@#$", "@#$", "1oo.bar", "foo.1ar", "1oo",
+ })
+ public void malformedNames(String source) {
+ IllegalArgumentException ex1 = assertThrows(IllegalArgumentException.class, () -> QualifiedName.parse(source));
+
+ assertThat(ex1.getMessage(), is(anyOf(
+ equalTo("Schema name can't be empty."),
+ equalTo("Object name can't be null or empty."),
+ containsString("Malformed name [name=" + source))));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "foo, \"foo\"", "fOo, \"fOo\"", "FOO, FOO", "\"FOO\", \"FOO\"", "1o0, \"1o0\"", "@#$, \"@#$\"",
+ "\"foo\", \"foo\"", "\"fOo\", \"fOo\"", "\"f.f\", \"f.f\"",
+ "foo\"bar\", \"foo\"\"bar\"\"\"", "\"foo\"\"bar\"\"\", \"foo\"\"bar\"\"\"",
+ "foo\"bar, \"foo\"\"bar\"", "\"foo\"\"bar\", \"foo\"\"bar\""
+ })
+ public void quoteIfNeeded(String source, String expected) {
+ assertThat(IgniteNameUtils.quoteIfNeeded(source), equalTo(expected));
+ }
+}