From 04d616d0b3cbee127d353baa6bffba241fca9cbf Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Sat, 18 Jan 2025 18:28:35 +0100 Subject: [PATCH] feat: implement rename_table --- moto/s3tables/exceptions.py | 14 ++++++ moto/s3tables/models.py | 41 +++++++++++++++++ moto/s3tables/responses.py | 26 +++++++++-- moto/s3tables/urls.py | 1 + tests/test_s3tables/test_s3tables.py | 69 ++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 5 deletions(-) diff --git a/moto/s3tables/exceptions.py b/moto/s3tables/exceptions.py index 29598be63947..b9d15d463a2d 100644 --- a/moto/s3tables/exceptions.py +++ b/moto/s3tables/exceptions.py @@ -45,6 +45,13 @@ def __init__(self) -> None: super().__init__(self.msg) +class NothingToRename(BadRequestException): + msg = "Neither a new namespace name nor a new table name is specified." + + def __init__(self) -> None: + super().__init__(self.msg) + + class NotFoundException(JsonRESTError): code = 404 @@ -59,6 +66,13 @@ def __init__(self) -> None: super().__init__(self.msg) +class DestinationNamespaceDoesNotExist(NotFoundException): + msg = "The specified destination namespace does not exist." + + def __init__(self) -> None: + super().__init__(self.msg) + + class TableDoesNotExist(NotFoundException): msg = "The specified table does not exist." diff --git a/moto/s3tables/models.py b/moto/s3tables/models.py index eb06b05f4831..fb114e67f893 100644 --- a/moto/s3tables/models.py +++ b/moto/s3tables/models.py @@ -11,11 +11,13 @@ from moto.s3.models import FakeBucket from moto.s3tables.exceptions import ( ConflictException, + DestinationNamespaceDoesNotExist, InvalidContinuationToken, InvalidNamespaceName, InvalidTableBucketName, InvalidTableName, NotFoundException, + NothingToRename, TableAlreadyExists, TableDoesNotExist, VersionTokenMismatch, @@ -75,6 +77,7 @@ def __init__( self.partition = partition self.created_by = created_by self.format = format + self.type = type self.version_token = self._generate_version_token() self.creation_date = datetime.datetime.now(tz=datetime.timezone.utc) self.last_modified = self.creation_date @@ -122,6 +125,11 @@ def update_metadata_location( self.metadata_location = metadata_location self.version_token = self._generate_version_token() + def rename(self, new_name: str, by: str) -> None: + _validate_table_name(new_name) + self.name = new_name + self.was_modified(by) + class Namespace: def __init__(self, name: str, account_id: str, created_by: str): @@ -437,6 +445,39 @@ def update_table_metadata_location( raise TableDoesNotExist() + def rename_table( + self, + table_bucket_arn: str, + namespace: str, + name: str, + new_namespace_name: Optional[str] = None, + new_name: Optional[str] = None, + version_token: Optional[str] = None, + ) -> None: + if not new_namespace_name and not new_name: + raise NothingToRename() + destination_namespace = new_namespace_name if new_namespace_name else namespace + destination_name = new_name if new_name else name + _validate_table_name(destination_name) + + bucket = self.table_buckets.get(table_bucket_arn) + if not bucket or destination_namespace not in bucket.namespaces: + raise DestinationNamespaceDoesNotExist() + if namespace not in bucket.namespaces or ( + name not in bucket.namespaces[namespace].tables + ): + raise TableDoesNotExist() + table = bucket.namespaces[namespace].tables[name] + if version_token and not version_token == table.version_token: + raise VersionTokenMismatch() + + if destination_name in bucket.namespaces[destination_namespace].tables: + raise TableAlreadyExists() + + table = bucket.namespaces[namespace].tables.pop(name) + table.rename(new_name=destination_name, by=self.account_id) + bucket.namespaces[destination_namespace].tables[destination_name] = table + s3tables_backends = BackendDict( S3TablesBackend, diff --git a/moto/s3tables/responses.py b/moto/s3tables/responses.py index 72f781b6a749..666cba963940 100644 --- a/moto/s3tables/responses.py +++ b/moto/s3tables/responses.py @@ -188,24 +188,23 @@ def get_table(self) -> TYPE_RESPONSE: namespace=namespace, name=name, ) - return ( 200, self.default_response_headers, json.dumps( dict( name=table.name, - type="", + type=table.type, tableARN=table.arn, - namespace=namespace, + namespace=[namespace], versionToken=table.version_token, metadataLocation=table.metadata_location, warehouseLocation=table.warehouse_location, createdAt=table.creation_date.isoformat(), createdBy=table.account_id, - managedByService=None, + managedByService=table.managed_by_service, modifiedAt=table.last_modified.isoformat(), - modifiedBy="", + modifiedBy=table.modified_by, ownerAccountId=table.account_id, format=table.format, ) @@ -304,3 +303,20 @@ def update_table_metadata_location(self) -> TYPE_RESPONSE: ) ), ) + + def rename_table(self) -> TYPE_RESPONSE: + _, table_bucket_arn, namespace, name, _ = self.raw_path.lstrip("/").split("/") + table_bucket_arn = unquote(table_bucket_arn) + body = json.loads(self.body) + version_token = body.get("versionToken") + new_namespace_name = body.get("newNamespaceName") + new_name = body.get("newName") + self.s3tables_backend.rename_table( + table_bucket_arn=table_bucket_arn, + namespace=namespace, + name=name, + new_namespace_name=new_namespace_name, + new_name=new_name, + version_token=version_token, + ) + return 200, {}, "" diff --git a/moto/s3tables/urls.py b/moto/s3tables/urls.py index 2b1578f1beee..a94a7c704ebe 100644 --- a/moto/s3tables/urls.py +++ b/moto/s3tables/urls.py @@ -17,4 +17,5 @@ "{0}/tables/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)$": S3TablesResponse.dispatch, "{0}/tables/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/metadata-location$": S3TablesResponse.dispatch, "{0}/tables/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/metadata-location$": S3TablesResponse.dispatch, + "{0}/tables/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/rename$": S3TablesResponse.dispatch, } diff --git a/tests/test_s3tables/test_s3tables.py b/tests/test_s3tables/test_s3tables.py index 3cf6428e901c..9997708e45fa 100644 --- a/tests/test_s3tables/test_s3tables.py +++ b/tests/test_s3tables/test_s3tables.py @@ -337,3 +337,72 @@ def test_underlying_table_storage_does_not_support_delete_object() -> None: s3.put_object(Bucket=bucket_name, Key="test", Body=b"{}") with pytest.raises(s3.exceptions.ClientError): s3.delete_object(Bucket=bucket_name, Key="test") + + +@mock_aws +def test_rename_table() -> None: + client = boto3.client("s3tables", region_name="us-east-2") + arn = client.create_table_bucket(name="foo")["arn"] + client.create_namespace(tableBucketARN=arn, namespace=["bar"]) + resp = client.create_table( + tableBucketARN=arn, namespace="bar", name="baz", format="ICEBERG" + ) + + client.create_namespace(tableBucketARN=arn, namespace=["bar-two"]) + client.rename_table( + tableBucketARN=arn, + namespace="bar", + name="baz", + newNamespaceName="bar-two", + newName="baz-two", + versionToken=resp["versionToken"], + ) + assert ( + client.get_table(tableBucketARN=arn, namespace="bar-two", name="baz-two")[ + "name" + ] + == "baz-two" + ) + assert client.get_table(tableBucketARN=arn, namespace="bar-two", name="baz-two")[ + "namespace" + ] == ["bar-two"] + + +@mock_aws +def test_rename_table_fails_when_destination_namespace_does_not_exist() -> None: + client = boto3.client("s3tables", region_name="us-east-2") + arn = client.create_table_bucket(name="foo")["arn"] + client.create_namespace(tableBucketARN=arn, namespace=["bar"]) + resp = client.create_table( + tableBucketARN=arn, namespace="bar", name="baz", format="ICEBERG" + ) + + with pytest.raises(client.exceptions.NotFoundException) as ctx: + client.rename_table( + tableBucketARN=arn, + namespace="bar", + name="baz", + newNamespaceName="bar-two", + newName="baz-two", + versionToken=resp["versionToken"], + ) + assert ctx.match("The specified destination namespace does not exist.") + + +@mock_aws +def test_rename_table_fails_when_no_updates_are_specified() -> None: + client = boto3.client("s3tables", region_name="us-east-2") + arn = client.create_table_bucket(name="foo")["arn"] + client.create_namespace(tableBucketARN=arn, namespace=["bar"]) + resp = client.create_table( + tableBucketARN=arn, namespace="bar", name="baz", format="ICEBERG" + ) + + with pytest.raises(client.exceptions.BadRequestException) as ctx: + client.rename_table( + tableBucketARN=arn, + namespace="bar", + name="baz", + versionToken=resp["versionToken"], + ) + assert ctx.match("Neither a new namespace name nor a new table name is specified.")