diff --git a/clients/client-python/gravitino/api/column.py b/clients/client-python/gravitino/api/column.py new file mode 100644 index 00000000000..41dc38dec3d --- /dev/null +++ b/clients/client-python/gravitino/api/column.py @@ -0,0 +1,224 @@ +# 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. + + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Optional + +from gravitino.api.expressions.expression import Expression +from gravitino.api.expressions.function_expression import FunctionExpression +from gravitino.api.tag.supports_tags import SupportsTags +from gravitino.api.types.type import Type +from gravitino.exceptions.base import UnsupportedOperationException +from gravitino.utils.precondition import Precondition + + +class Column(ABC): + """An interface representing a column of a `Table`. + + It defines basic properties of a column, such as name and data type. + + Catalog implementation needs to implement it. They should consume it in APIs like + `TableCatalog.createTable(NameIdentifier, List[Column], str, Dict)`, and report it in + `Table.columns()` a default value and a generation expression. + """ + + DEFAULT_VALUE_NOT_SET = Expression.EMPTY_EXPRESSION + """A default value that indicates the default value is not set. This is used in `default_value()`""" + + DEFAULT_VALUE_OF_CURRENT_TIMESTAMP: Expression = FunctionExpression.of( + "current_timestamp" + ) + """ + A default value that indicates the default value will be set to the current timestamp. + This is used in `default_value()` + """ + + @abstractmethod + def name(self) -> str: + """Get the name of this column. + + Returns: + str: The name of this column. + """ + pass + + @abstractmethod + def data_type(self) -> Type: + """Get the name of this column. + + Returns: + Type: The data type of this column. + """ + pass + + @abstractmethod + def comment(self) -> Optional[str]: + """Get the comment of this column. + + Returns: + Optional[str]: The comment of this column, `None` if not specified. + """ + pass + + @abstractmethod + def nullable(self) -> bool: + """Indicate if this column may produce null values. + + Returns: + bool: `True` if this column may produce null values. Default is `True`. + """ + return True + + @abstractmethod + def auto_increment(self) -> bool: + """Indicate if this column is an auto-increment column. + + Returns: + bool: `True` if this column is an auto-increment column. Default is `False`. + """ + return False + + @abstractmethod + def default_value(self) -> Expression: + """Get the default value of this column + + Returns: + Expression: + The default value of this column, `Column.DEFAULT_VALUE_NOT_SET` if not specified. + """ + pass + + def supports_tags(self) -> SupportsTags: + """Return the `SupportsTags` if the column supports tag operations. + + Returns: + SupportsTags: the `SupportsTags` if the column supports tag operations. + + Raises: + UnsupportedOperationException: if the column does not support tag operations. + """ + raise UnsupportedOperationException("Column does not support tag operations.") + + @staticmethod + def of( + name: str, + data_type: Type, + comment: Optional[str] = None, + nullable: bool = True, + auto_increment: bool = False, + default_value: Optional[Expression] = None, + ) -> ColumnImpl: + """Create a `Column` instance. + + Args: + name (str): + The name of the column. + data_type (Type): + The data type of the column. + comment (Optional[str], optional): + The comment of the column. Defaults to `None`. + nullable (bool, optional): + `True` if the column may produce null values. Defaults to `True`. + auto_increment (bool, optional): + `True` if the column is an auto-increment column. Defaults to `False`. + default_value (Optional[Expression], optional): + The default value of this column, `Column.DEFAULT_VALUE_NOT_SET` if `None`. Defaults to `None`. + + Returns: + ColumnImpl: A `Column` instance. + """ + return ColumnImpl( + name=name, + data_type=data_type, + comment=comment, + nullable=nullable, + auto_increment=auto_increment, + default_value=( + Column.DEFAULT_VALUE_NOT_SET if default_value is None else default_value + ), + ) + + +class ColumnImpl(Column): + """The implementation of `Column` for users to use API.""" + + def __init__( + self, + name: str, + data_type: Type, + comment: Optional[str], + nullable: bool, + auto_increment: bool, + default_value: Optional[Expression], + ): + Precondition.check_string_not_empty(name, "Column name cannot be null") + Precondition.check_argument( + data_type is not None, "Column data type cannot be null" + ) + self._name = name + self._data_type = data_type + self._comment = comment + self._nullable = nullable + self._auto_increment = auto_increment + self._default_value = default_value + + def name(self) -> str: + return self._name + + def data_type(self) -> Type: + return self._data_type + + def comment(self) -> Optional[str]: + return self._comment + + def nullable(self) -> bool: + return self._nullable + + def auto_increment(self) -> bool: + return self._auto_increment + + def default_value(self) -> Expression: + return self._default_value + + def __eq__(self, other: ColumnImpl) -> bool: + if not isinstance(other, ColumnImpl): + return False + return all( + [ + self._name == other.name(), + self._data_type == other.data_type(), + self._comment == other.comment(), + self._nullable == other.nullable(), + self._auto_increment == other.auto_increment(), + self._default_value == other.default_value(), + ] + ) + + def __hash__(self): + return hash( + ( + self._name, + self._data_type, + self._comment, + self._nullable, + self._auto_increment, + tuple(self._default_value), + ) + ) diff --git a/clients/client-python/gravitino/api/tag/__init__.py b/clients/client-python/gravitino/api/tag/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/clients/client-python/gravitino/api/tag/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/clients/client-python/gravitino/api/tag/supports_tags.py b/clients/client-python/gravitino/api/tag/supports_tags.py new file mode 100644 index 00000000000..89b405f8900 --- /dev/null +++ b/clients/client-python/gravitino/api/tag/supports_tags.py @@ -0,0 +1,87 @@ +# 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. + + +from abc import ABC, abstractmethod +from typing import List + +from gravitino.api.tag.tag import Tag + + +class SupportsTags(ABC): + """Interface for supporting getting or associate tags to objects. + + This interface will be mixed with metadata objects to provide tag operations. + """ + + @abstractmethod + def list_tags(self) -> List[str]: + """List all the tag names for the specific object. + + Returns: + List[str]: The list of tag names. + """ + pass + + @abstractmethod + def list_tags_info(self) -> List[Tag]: + """List all the tags with details for the specific object. + + Returns: + List[Tag]: The list of tags. + """ + pass + + @abstractmethod + def get_tag(self, name: str) -> Tag: + """Get a tag by its name for the specific object. + + Args: + name (str): The name of the tag. + + Raises: + NoSuchTagException: If the tag does not associate with the object. + + Returns: + Tag: The tag. + """ + pass + + @abstractmethod + def associate_tags( + self, tags_to_add: List[str], tags_to_remove: List[str] + ) -> List[str]: + """Associate tags to the specific object. + + The `tags_to_add` will be added to the object, and the `tags_to_remove` will be removed from the object. + + Note that: + 1. Adding or removing tags that are not existed will be ignored. + 2. If the same name tag is in both `tags_to_add` and `tags_to_remove`, it will be ignored. + 3. If the tag is already associated with the object, it will raise `TagAlreadyAssociatedException`. + + Args: + tags_to_add (List[str]): The arrays of tag name to be added to the object. + tags_to_remove (List[str]): The array of tag name to be removed from the object. + + Raises: + TagAlreadyAssociatedException: If the tag is already associated with the object. + + Returns: + List[str]: The array of tag names that are associated with the object. + """ + pass diff --git a/clients/client-python/gravitino/api/tag/tag.py b/clients/client-python/gravitino/api/tag/tag.py new file mode 100644 index 00000000000..fb5cdd268a6 --- /dev/null +++ b/clients/client-python/gravitino/api/tag/tag.py @@ -0,0 +1,119 @@ +# 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. + + +from abc import ABC, abstractmethod +from typing import ClassVar, Dict, List, Optional + +from gravitino.api.auditable import Auditable +from gravitino.api.metadata_object import MetadataObject +from gravitino.exceptions.base import UnsupportedOperationException + + +class AssociatedObjects(ABC): + """The interface of the associated objects of the tag.""" + + @abstractmethod + def count(self) -> int: + """Get the number of associated objects. + + Returns: + int: The number of associated objects. + """ + objects = self.objects() + return 0 if objects is None else len(objects) + + @abstractmethod + def objects(self) -> Optional[List[MetadataObject]]: + """Get the associated objects. + + Returns: + Optional[List[MetadataObject]]: The list of objects that are associated with this tag.. + """ + pass + + +class Tag(Auditable): + """The interface of a tag. + + A tag is a label that can be attached to a catalog, schema, table, fileset, topic, + or column. It can be used to categorize, classify, or annotate these objects. + """ + + PROPERTY_COLOR: ClassVar[str] = "color" + """ + A reserved property to specify the color of the tag. The color is a string of hex code that + represents the color of the tag. The color is used to visually distinguish the tag from other + tags. + """ + + @abstractmethod + def name(self) -> str: + """Get the name of the tag. + + Returns: + str: The name of the tag. + """ + pass + + @abstractmethod + def comment(self) -> str: + """Get the comment of the tag. + + Returns: + str: The comment of the tag. + """ + pass + + @abstractmethod + def properties(self) -> Dict[str, str]: + """Get the properties of the tag. + + Returns: + Dict[str, str]: The properties of the tag. + """ + pass + + @abstractmethod + def inherited(self) -> Optional[bool]: + """Check if the tag is inherited from a parent object or not. + + If the tag is inherited, it will return `True`, if it is owned by the object itself, it will return `False`. + + **Note**. The return value is optional, only when the tag is associated with an object, and called from the + object, the return value will be present. Otherwise, it will be empty. + + Returns: + Optional[bool]: + True if the tag is inherited, false if it is owned by the object itself. Empty if the + tag is not associated with any object. + """ + pass + + @abstractmethod + def associatedObjects(self) -> AssociatedObjects: + """The associated objects of the tag. + + Raises: + UnsupportedOperationException: The associatedObjects method is not supported. + + Returns: + AssociatedObjects: The associated objects of the tag. + """ + raise UnsupportedOperationException( + "The associatedObjects method is not supported." + ) diff --git a/clients/client-python/gravitino/exceptions/base.py b/clients/client-python/gravitino/exceptions/base.py index e06bcc1b704..f7423812149 100644 --- a/clients/client-python/gravitino/exceptions/base.py +++ b/clients/client-python/gravitino/exceptions/base.py @@ -159,3 +159,11 @@ class BadRequestException(GravitinoRuntimeException): class IllegalStateException(GravitinoRuntimeException): """An exception thrown when the state is invalid.""" + + +class NoSuchTagException(NotFoundException): + """An exception thrown when a tag with specified name is not existed.""" + + +class TagAlreadyExistsException(AlreadyExistsException): + """An exception thrown when a tag with specified name already associated to a metadata object.""" diff --git a/clients/client-python/tests/unittests/test_column.py b/clients/client-python/tests/unittests/test_column.py new file mode 100644 index 00000000000..2f1a275c26e --- /dev/null +++ b/clients/client-python/tests/unittests/test_column.py @@ -0,0 +1,105 @@ +import unittest +from unittest.mock import Mock + +from gravitino.api.column import Column, ColumnImpl +from gravitino.api.expressions.expression import Expression +from gravitino.api.expressions.function_expression import FunctionExpression +from gravitino.api.types.type import Type +from gravitino.exceptions.base import ( + IllegalArgumentException, + UnsupportedOperationException, +) + + +class TestColumn(unittest.TestCase): + def setUp(self): + # Create mock Type for testing + self.mock_type = Mock(spec=Type) + + def test_column_factory_method(self): + """Test the Column.of() factory method.""" + + column = Column.of("test_column", self.mock_type) + + self.assertIsInstance(column, ColumnImpl) + self.assertEqual("test_column", column.name()) + self.assertEqual(self.mock_type, column.data_type()) + self.assertIsNone(column.comment()) + self.assertTrue(column.nullable()) + self.assertFalse(column.auto_increment()) + self.assertEqual(Column.DEFAULT_VALUE_NOT_SET, column.default_value()) + + def test_column_factory_with_all_params(self): + """Test the Column.of() factory method with all parameters.""" + + default_value = Mock(spec=Expression) + column = Column.of( + name="test_column", + data_type=self.mock_type, + comment="Test comment", + nullable=False, + auto_increment=True, + default_value=default_value, + ) + + self.assertEqual("test_column", column.name()) + self.assertEqual(self.mock_type, column.data_type()) + self.assertEqual("Test comment", column.comment()) + self.assertFalse(column.nullable()) + self.assertTrue(column.auto_increment()) + self.assertEqual(default_value, column.default_value()) + + def test_column_equality(self): + """Test equality comparison.""" + default_value = Mock(spec=Expression) + + col1 = Column.of("test", self.mock_type, "comment", False, True, default_value) + col2 = Column.of("test", self.mock_type, "comment", False, True, default_value) + col3 = Column.of("different", self.mock_type) + + self.assertEqual(col1, col2) + self.assertNotEqual(col1, col3) + self.assertNotEqual(col1, "not_a_column") + + def test_column_hash(self): + """Test hash implementation. + + Same columns should have same hash. + """ + col1 = Column.of("test", self.mock_type, "comment", False, True) + col2 = Column.of("test", self.mock_type, "comment", False, True) + col3 = Column.of("different", self.mock_type) + + self.assertEqual(hash(col1), hash(col2)) + self.assertNotEqual(hash(col1), hash(col3)) + + def test_supports_tags_raises_exception(self): + """Test that supports_tags raises `UnsupportedOperationException`.""" + + column = Column.of("test", self.mock_type) + + with self.assertRaises(UnsupportedOperationException): + column.supports_tags() + + def test_default_value_constants(self): + """Test default value constants.""" + + self.assertEqual(Expression.EMPTY_EXPRESSION, Column.DEFAULT_VALUE_NOT_SET) + self.assertIsInstance( + Column.DEFAULT_VALUE_OF_CURRENT_TIMESTAMP, FunctionExpression + ) + + def test_empty_name_validation(self): + """Test validation for empty name to raise `IllegalArgumentException`.""" + + with self.assertRaises(IllegalArgumentException): + Column.of("", self.mock_type) + + with self.assertRaises(IllegalArgumentException): + Column.of(" ", self.mock_type) + + def test_none_data_type_validation(self): + """Test validation for None data type to raise `IllegalArgumentException`.""" + + with self.assertRaises(IllegalArgumentException): + Column.of("test", None)