| # 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. | |
| """ | |
| Tests for Identifier parsing, including backtick support for database names with periods. | |
| """ | |
| import unittest | |
| from pypaimon.common.identifier import Identifier | |
| class IdentifierTest(unittest.TestCase): | |
| """Tests for Identifier.from_string().""" | |
| def test_simple_identifier(self): | |
| """Simple database.table parsing.""" | |
| identifier = Identifier.from_string("mydb.mytable") | |
| self.assertEqual(identifier.database, "mydb") | |
| self.assertEqual(identifier.object, "mytable") | |
| def test_java_compatible_split_on_first_period(self): | |
| """Java-compatible: splits on first period only, allowing periods in table name.""" | |
| identifier = Identifier.from_string("mydb.my.table.name") | |
| self.assertEqual(identifier.database, "mydb") | |
| self.assertEqual(identifier.object, "my.table.name") | |
| def test_backtick_quoted_database_name_with_period(self): | |
| """Backtick-quoted database name containing a period.""" | |
| identifier = Identifier.from_string("`db.name`.table_name") | |
| self.assertEqual(identifier.database, "db.name") | |
| self.assertEqual(identifier.object, "table_name") | |
| def test_backtick_quoted_both_parts(self): | |
| """Both database and table names backtick-quoted.""" | |
| identifier = Identifier.from_string("`db.name`.`table.name`") | |
| self.assertEqual(identifier.database, "db.name") | |
| self.assertEqual(identifier.object, "table.name") | |
| def test_backtick_quoted_database_only(self): | |
| """Only database name backtick-quoted.""" | |
| identifier = Identifier.from_string("`my.database`.simple_table") | |
| self.assertEqual(identifier.database, "my.database") | |
| self.assertEqual(identifier.object, "simple_table") | |
| def test_get_full_name(self): | |
| """get_full_name() returns database.object format.""" | |
| identifier = Identifier.create("mydb", "mytable") | |
| self.assertEqual(identifier.get_full_name(), "mydb.mytable") | |
| def test_constructor_with_branch_encodes_into_object(self): | |
| """``Identifier(db, table, branch=...)`` mirrors Java's 3-arg constructor.""" | |
| identifier = Identifier("mydb", "mytable", branch="feature") | |
| self.assertEqual(identifier.object, "mytable$branch_feature") | |
| self.assertEqual(identifier.get_full_name(), "mydb.mytable$branch_feature") | |
| self.assertEqual(identifier.get_table_name(), "mytable") | |
| self.assertEqual(identifier.get_branch_name(), "feature") | |
| self.assertEqual(identifier.get_branch_name_or_default(), "feature") | |
| self.assertFalse(identifier.is_system_table()) | |
| def test_main_branch_is_not_encoded_into_object(self): | |
| """``main`` (case-insensitive) is the default branch and is not encoded into ``object``.""" | |
| for branch in ("main", "MAIN", "Main"): | |
| identifier = Identifier("mydb", "mytable", branch=branch) | |
| self.assertEqual(identifier.object, "mytable") | |
| # Wire-equal to a no-branch identifier. | |
| self.assertEqual(identifier, Identifier("mydb", "mytable")) | |
| def test_get_branch_name_or_default_when_unset(self): | |
| """``get_branch_name_or_default`` falls back to 'main'.""" | |
| identifier = Identifier.create("mydb", "mytable") | |
| self.assertIsNone(identifier.get_branch_name()) | |
| self.assertEqual(identifier.get_branch_name_or_default(), "main") | |
| def test_unknown_database_drops_database_segment(self): | |
| """``UNKNOWN_DATABASE`` is dropped from full name (Java-compatible).""" | |
| identifier = Identifier("unknown", "mytable") | |
| self.assertEqual(identifier.get_full_name(), "mytable") | |
| def test_empty_string_raises_error(self): | |
| """Empty string should raise ValueError.""" | |
| with self.assertRaises(ValueError): | |
| Identifier.from_string("") | |
| def test_whitespace_only_raises_error(self): | |
| """Whitespace-only string should raise ValueError.""" | |
| with self.assertRaises(ValueError): | |
| Identifier.from_string(" ") | |
| def test_no_period_raises_error(self): | |
| """String without period should raise ValueError.""" | |
| with self.assertRaises(ValueError): | |
| Identifier.from_string("nodothere") | |
| def test_unclosed_backtick_raises_error(self): | |
| """Unclosed backtick should raise ValueError.""" | |
| with self.assertRaises(ValueError): | |
| Identifier.from_string("`unclosed.db.mytable") | |
| def test_invalid_backtick_format_raises_error(self): | |
| """Invalid backtick format (too many parts) should raise ValueError.""" | |
| with self.assertRaises(ValueError): | |
| Identifier.from_string("`a`.`b`.`c`") | |
| def test_is_system_table_regular_table(self): | |
| """A plain table object is not a system table.""" | |
| self.assertFalse(Identifier.create("mydb", "mytable").is_system_table()) | |
| def test_is_system_table_snapshots_suffix(self): | |
| """object name '<base>$snapshots' is a system table.""" | |
| self.assertTrue(Identifier("mydb", "orders$snapshots").is_system_table()) | |
| self.assertTrue( | |
| Identifier("mydb", "orders", system_table="snapshots").is_system_table()) | |
| def test_is_system_table_schemas_suffix(self): | |
| """object name '<base>$schemas' is a system table.""" | |
| self.assertTrue(Identifier("mydb", "orders$schemas").is_system_table()) | |
| def test_is_system_table_files_suffix(self): | |
| """object name '<base>$files' is a system table.""" | |
| self.assertTrue(Identifier("mydb", "orders$files").is_system_table()) | |
| def test_is_system_table_two_parts_with_branch_prefix(self): | |
| """A '<table>$branch_<name>' object is a branched table, NOT a system table.""" | |
| identifier = Identifier("mydb", "orders$branch_dev") | |
| self.assertFalse(identifier.is_system_table()) | |
| self.assertEqual(identifier.get_table_name(), "orders") | |
| self.assertEqual(identifier.get_branch_name(), "dev") | |
| self.assertIsNone(identifier.get_system_table_name()) | |
| def test_is_system_table_three_parts_branch_and_system(self): | |
| """A '<table>$branch_<name>$snapshots' object is a system table on a branch.""" | |
| identifier = Identifier("mydb", "orders$branch_dev$snapshots") | |
| self.assertTrue(identifier.is_system_table()) | |
| self.assertEqual(identifier.get_table_name(), "orders") | |
| self.assertEqual(identifier.get_branch_name(), "dev") | |
| self.assertEqual(identifier.get_system_table_name(), "snapshots") | |
| def test_three_parts_without_branch_prefix_raises(self): | |
| """A '<table>$<x>$<y>' object without branch prefix is invalid (single '$' allowed).""" | |
| identifier = Identifier("mydb", "orders$schemas$snapshots") | |
| with self.assertRaises(ValueError): | |
| identifier.is_system_table() | |
| def test_constructor_with_branch_and_system_table(self): | |
| """``Identifier(db, table, branch=..., system_table=...)`` mirrors Java's 4-arg constructor.""" | |
| identifier = Identifier( | |
| "mydb", "orders", branch="dev", system_table="snapshots" | |
| ) | |
| self.assertEqual(identifier.object, "orders$branch_dev$snapshots") | |
| self.assertEqual(identifier.get_table_name(), "orders") | |
| self.assertEqual(identifier.get_branch_name(), "dev") | |
| self.assertEqual(identifier.get_system_table_name(), "snapshots") | |
| def test_constructor_with_system_table_only(self): | |
| """Constructor with system_table but no branch.""" | |
| identifier = Identifier("mydb", "orders", system_table="files") | |
| self.assertEqual(identifier.object, "orders$files") | |
| self.assertEqual(identifier.get_table_name(), "orders") | |
| self.assertIsNone(identifier.get_branch_name()) | |
| self.assertEqual(identifier.get_system_table_name(), "files") | |
| def test_create_is_two_arg_alias(self): | |
| """``Identifier.create(db, object)`` is a 2-arg alias of the JSON constructor (matches Java).""" | |
| identifier = Identifier.create("db", "orders$snapshots") | |
| self.assertEqual(identifier.object, "orders$snapshots") | |
| self.assertEqual(identifier.get_table_name(), "orders") | |
| self.assertEqual(identifier.get_system_table_name(), "snapshots") | |
| self.assertTrue(identifier.is_system_table()) | |
| def test_constructor_forms_are_wire_equivalent(self): | |
| """The encoding constructor and the @JsonCreator form produce wire-equal identifiers.""" | |
| encoded = Identifier( | |
| "mydb", "orders", branch="dev", system_table="snapshots" | |
| ) | |
| from_wire = Identifier("mydb", "orders$branch_dev$snapshots") | |
| self.assertEqual(encoded, from_wire) | |
| self.assertEqual(hash(encoded), hash(from_wire)) | |
| def test_branch_property_reads_decoded_branch(self): | |
| """``identifier.branch`` is a read-only alias for ``get_branch_name()``.""" | |
| self.assertEqual(Identifier("db", "tbl", branch="dev").branch, "dev") | |
| self.assertEqual(Identifier("db", "tbl$branch_dev").branch, "dev") | |
| self.assertIsNone(Identifier("db", "tbl").branch) | |
| def test_equality_and_hash_ignore_cached_fields(self): | |
| """Equality and hash depend only on (database, object), matching Java JSON shape.""" | |
| a = Identifier("mydb", "orders$branch_dev$snapshots") | |
| b = Identifier( | |
| "mydb", "orders", branch="dev", system_table="snapshots" | |
| ) | |
| self.assertEqual(a, b) | |
| self.assertEqual(hash(a), hash(b)) | |
| class IdentifierBackwardCompatibilityTest(unittest.TestCase): | |
| """Locks in that the public surface from before this PR keeps working. | |
| Pre-PR ``Identifier`` exposed three signatures that this PR's | |
| encoding-into-object refactor risked breaking. They must continue to | |
| run without raising; semantics may shift to the wire-correct form | |
| (branches encoded into ``object``), but no caller should hit a | |
| TypeError / AttributeError on an unchanged call site. | |
| """ | |
| def test_constructor_branch_kwarg_still_accepted(self): | |
| # ``Identifier(db, obj, branch=...)`` was the old dataclass init | |
| # signature. It must still construct without error; the branch is | |
| # now encoded into ``object`` so the wire is Java-compatible. | |
| identifier = Identifier(database="db", object="tbl", branch="feature") | |
| self.assertEqual(identifier.object, "tbl$branch_feature") | |
| self.assertEqual(identifier.get_branch_name(), "feature") | |
| def test_constructor_branch_kwarg_none_is_accepted(self): | |
| # Explicit ``branch=None`` (e.g. from JSON deserialization paths) | |
| # must remain a no-op rather than triggering the encoding path. | |
| identifier = Identifier("db", "tbl", branch=None) | |
| self.assertEqual(identifier.object, "tbl") | |
| self.assertIsNone(identifier.get_branch_name()) | |
| def test_branch_attribute_read(self): | |
| # Old code that read ``identifier.branch`` keeps reading the | |
| # branch name (now decoded from ``object``). | |
| self.assertEqual(Identifier("db", "tbl", branch="dev").branch, "dev") | |
| self.assertEqual(Identifier("db", "tbl$branch_dev").branch, "dev") | |
| self.assertIsNone(Identifier("db", "tbl").branch) | |
| def test_branch_attribute_write(self): | |
| # Old code that assigned to ``identifier.branch`` keeps working. | |
| # The setter re-encodes ``object`` so the wire is consistent. | |
| identifier = Identifier("db", "tbl") | |
| identifier.branch = "feature" | |
| self.assertEqual(identifier.branch, "feature") | |
| self.assertEqual(identifier.object, "tbl$branch_feature") | |
| self.assertEqual(identifier.get_branch_name(), "feature") | |
| # Clearing back to None drops the encoded branch segment. | |
| identifier.branch = None | |
| self.assertEqual(identifier.object, "tbl") | |
| self.assertIsNone(identifier.branch) | |
| def test_create_two_arg_form_with_dollar_object(self): | |
| # ``Identifier.create(db, "tbl$snapshots")`` was the documented | |
| # way to construct system-table identifiers pre-PR. It must keep | |
| # producing an equivalent system-table identifier. | |
| identifier = Identifier.create("db", "orders$snapshots") | |
| self.assertEqual(identifier.object, "orders$snapshots") | |
| self.assertTrue(identifier.is_system_table()) | |
| self.assertEqual(identifier.get_table_name(), "orders") | |
| self.assertEqual(identifier.get_system_table_name(), "snapshots") | |
| if __name__ == '__main__': | |
| unittest.main() |