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));
+    }
+}