diff --git a/weed/admin/dash/s3tables_management.go b/weed/admin/dash/s3tables_management.go
index be9ff84b0..0c38c58a0 100644
--- a/weed/admin/dash/s3tables_management.go
+++ b/weed/admin/dash/s3tables_management.go
@@ -63,6 +63,19 @@ type tableBucketMetadata struct {
const s3TablesAdminListLimit = 1000
+func parseNamespaceInput(namespace string) ([]string, error) {
+ return s3tables.ParseNamespace(namespace)
+}
+
+func (s *AdminServer) parseNamespaceFromGin(c *gin.Context, namespace string) ([]string, bool) {
+ parts, err := parseNamespaceInput(namespace)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid namespace: " + err.Error()})
+ return nil, false
+ }
+ return parts, true
+}
+
func newS3TablesManager() *s3tables.Manager {
manager := s3tables.NewManager()
manager.SetAccountID(s3_constants.AccountAdminId)
@@ -158,7 +171,11 @@ func (s *AdminServer) GetS3TablesTablesData(ctx context.Context, bucketArn, name
var resp s3tables.ListTablesResponse
var ns []string
if namespace != "" {
- ns = []string{namespace}
+ parts, err := parseNamespaceInput(namespace)
+ if err != nil {
+ return S3TablesTablesData{}, err
+ }
+ ns = parts
}
req := &s3tables.ListTablesRequest{TableBucketARN: bucketArn, Namespace: ns, MaxTables: s3TablesAdminListLimit}
if err := s.executeS3TablesOperation(ctx, "ListTables", req, &resp); err != nil {
@@ -257,9 +274,13 @@ func (s *AdminServer) GetIcebergTablesData(ctx context.Context, catalogName, buc
// GetIcebergTableDetailsData returns Iceberg table metadata and snapshot information.
func (s *AdminServer) GetIcebergTableDetailsData(ctx context.Context, catalogName, bucketArn, namespace, tableName string) (IcebergTableDetailsData, error) {
var resp s3tables.GetTableResponse
+ namespaceParts, err := parseNamespaceInput(namespace)
+ if err != nil {
+ return IcebergTableDetailsData{}, err
+ }
req := &s3tables.GetTableRequest{
TableBucketARN: bucketArn,
- Namespace: []string{namespace},
+ Namespace: namespaceParts,
Name: tableName,
}
if err := s.executeS3TablesOperation(ctx, "GetTable", req, &resp); err != nil {
@@ -686,7 +707,11 @@ func (s *AdminServer) CreateS3TablesNamespace(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket_arn and name are required"})
return
}
- createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Name}}
+ namespaceParts, ok := s.parseNamespaceFromGin(c, req.Name)
+ if !ok {
+ return
+ }
+ createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts}
var resp s3tables.CreateNamespaceResponse
if err := s.executeS3TablesOperation(c.Request.Context(), "CreateNamespace", createReq, &resp); err != nil {
writeS3TablesError(c, err)
@@ -705,7 +730,11 @@ func (s *AdminServer) DeleteS3TablesNamespace(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket and name query parameters are required"})
return
}
- req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}}
+ namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
+ if !ok {
+ return
+ }
+ req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: namespaceParts}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteNamespace", req, nil); err != nil {
writeS3TablesError(c, err)
return
@@ -745,6 +774,10 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket_arn, namespace, and name are required"})
return
}
+ namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace)
+ if !ok {
+ return
+ }
format := req.Format
if format == "" {
format = "ICEBERG"
@@ -757,7 +790,7 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
}
createReq := &s3tables.CreateTableRequest{
TableBucketARN: req.BucketARN,
- Namespace: []string{req.Namespace},
+ Namespace: namespaceParts,
Name: req.Name,
Format: format,
Tags: req.Tags,
@@ -780,7 +813,11 @@ func (s *AdminServer) DeleteS3TablesTable(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
return
}
- req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name, VersionToken: version}
+ namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
+ if !ok {
+ return
+ }
+ req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name, VersionToken: version}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTable", req, nil); err != nil {
writeS3TablesError(c, err)
return
@@ -853,7 +890,11 @@ func (s *AdminServer) PutS3TablesTablePolicy(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket_arn, namespace, name, and policy are required"})
return
}
- putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Namespace}, Name: req.Name, ResourcePolicy: req.Policy}
+ namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace)
+ if !ok {
+ return
+ }
+ putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts, Name: req.Name, ResourcePolicy: req.Policy}
if err := s.executeS3TablesOperation(c.Request.Context(), "PutTablePolicy", putReq, nil); err != nil {
writeS3TablesError(c, err)
return
@@ -869,7 +910,11 @@ func (s *AdminServer) GetS3TablesTablePolicy(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
return
}
- getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name}
+ namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
+ if !ok {
+ return
+ }
+ getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
var resp s3tables.GetTablePolicyResponse
if err := s.executeS3TablesOperation(c.Request.Context(), "GetTablePolicy", getReq, &resp); err != nil {
writeS3TablesError(c, err)
@@ -886,7 +931,11 @@ func (s *AdminServer) DeleteS3TablesTablePolicy(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
return
}
- deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name}
+ namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
+ if !ok {
+ return
+ }
+ deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil {
writeS3TablesError(c, err)
return
diff --git a/weed/admin/static/js/s3tables.js b/weed/admin/static/js/s3tables.js
index 81fa004b9..3d5816bb2 100644
--- a/weed/admin/static/js/s3tables.js
+++ b/weed/admin/static/js/s3tables.js
@@ -725,20 +725,27 @@ function s3TablesNamespaceNameError(name) {
if (name.includes('/')) {
return "namespace name cannot contain '/'";
}
- if (!isLowercaseLetterOrDigit(name[0])) {
- return 'Namespace name must start with a letter or digit';
- }
- if (!isLowercaseLetterOrDigit(name[name.length - 1])) {
- return 'Namespace name must end with a letter or digit';
- }
- for (const ch of name) {
- if (isLowercaseLetterOrDigit(ch) || ch === '_') {
- continue;
+
+ const parts = name.split('.');
+ for (const part of parts) {
+ if (!part) {
+ return 'namespace levels cannot be empty';
+ }
+ if (!isLowercaseLetterOrDigit(part[0])) {
+ return 'Namespace name must start with a letter or digit';
+ }
+ if (!isLowercaseLetterOrDigit(part[part.length - 1])) {
+ return 'Namespace name must end with a letter or digit';
+ }
+ for (const ch of part) {
+ if (isLowercaseLetterOrDigit(ch) || ch === '_') {
+ continue;
+ }
+ return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
+ }
+ if (part.startsWith('aws')) {
+ return "namespace name cannot start with reserved prefix 'aws'";
}
- return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
- }
- if (name.startsWith('aws')) {
- return "namespace name cannot start with reserved prefix 'aws'";
}
return '';
}
diff --git a/weed/admin/view/app/iceberg_namespaces.templ b/weed/admin/view/app/iceberg_namespaces.templ
index ebfbef94d..0b78577a9 100644
--- a/weed/admin/view/app/iceberg_namespaces.templ
+++ b/weed/admin/view/app/iceberg_namespaces.templ
@@ -195,7 +195,7 @@ templ IcebergNamespaces(data dash.IcebergNamespacesData) {
-
Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.
+
Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/s3tables_namespaces.templ b/weed/admin/view/app/s3tables_namespaces.templ
index c61132177..4c203ea19 100644
--- a/weed/admin/view/app/s3tables_namespaces.templ
+++ b/weed/admin/view/app/s3tables_namespaces.templ
@@ -154,14 +154,14 @@ templ S3TablesNamespaces(data dash.S3TablesNamespacesData) {
Are you sure you want to delete the namespace ?
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/s3api/s3api_tables.go b/weed/s3api/s3api_tables.go
index b93d5879f..2b5d352b9 100644
--- a/weed/s3api/s3api_tables.go
+++ b/weed/s3api/s3api_tables.go
@@ -231,11 +231,23 @@ func parseOptionalNamespace(r *http.Request, name string) []string {
if value == "" {
return nil
}
- if _, err := s3tables.ValidateNamespace([]string{value}); err != nil {
+ parts, err := s3tables.ParseNamespace(value)
+ if err != nil {
glog.V(1).Infof("invalid namespace value for %s: %q: %v", name, value, err)
return nil
}
- return []string{value}
+ return parts
+}
+
+func parseRequiredNamespacePathParam(r *http.Request, name string) ([]string, error) {
+ value, err := getDecodedPathParam(r, name)
+ if err != nil {
+ return nil, err
+ }
+ if value == "" {
+ return nil, fmt.Errorf("%s is required", name)
+ }
+ return s3tables.ParseNamespace(value)
}
// parseTagKeys handles tag key parsing from query parameters.
@@ -352,19 +364,13 @@ func buildGetNamespaceRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
- namespace, err := getDecodedPathParam(r, "namespace")
+ namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
- if namespace == "" {
- return nil, fmt.Errorf("namespace is required")
- }
- if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
- return nil, err
- }
return &s3tables.GetNamespaceRequest{
TableBucketARN: tableBucketARN,
- Namespace: []string{namespace},
+ Namespace: namespace,
}, nil
}
@@ -373,19 +379,13 @@ func buildDeleteNamespaceRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
- namespace, err := getDecodedPathParam(r, "namespace")
+ namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
- if namespace == "" {
- return nil, fmt.Errorf("namespace is required")
- }
- if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
- return nil, err
- }
return &s3tables.DeleteNamespaceRequest{
TableBucketARN: tableBucketARN,
- Namespace: []string{namespace},
+ Namespace: namespace,
}, nil
}
@@ -398,18 +398,12 @@ func buildCreateTableRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
- namespace, err := getDecodedPathParam(r, "namespace")
+ namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
- if namespace == "" {
- return nil, fmt.Errorf("namespace is required")
- }
- if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
- return nil, err
- }
req.TableBucketARN = tableBucketARN
- req.Namespace = []string{namespace}
+ req.Namespace = namespace
return &req, nil
}
@@ -453,16 +447,10 @@ func buildDeleteTableRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
- namespace, err := getDecodedPathParam(r, "namespace")
+ namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
- if namespace == "" {
- return nil, fmt.Errorf("namespace is required")
- }
- if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
- return nil, err
- }
name, err := getDecodedPathParam(r, "name")
if err != nil {
return nil, err
@@ -475,7 +463,7 @@ func buildDeleteTableRequest(r *http.Request) (interface{}, error) {
}
return &s3tables.DeleteTableRequest{
TableBucketARN: tableBucketARN,
- Namespace: []string{namespace},
+ Namespace: namespace,
Name: name,
VersionToken: r.URL.Query().Get("versionToken"),
}, nil
@@ -490,16 +478,10 @@ func buildPutTablePolicyRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
- namespace, err := getDecodedPathParam(r, "namespace")
+ namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
- if namespace == "" {
- return nil, fmt.Errorf("namespace is required")
- }
- if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
- return nil, err
- }
name, err := getDecodedPathParam(r, "name")
if err != nil {
return nil, err
@@ -511,7 +493,7 @@ func buildPutTablePolicyRequest(r *http.Request) (interface{}, error) {
return nil, err
}
req.TableBucketARN = tableBucketARN
- req.Namespace = []string{namespace}
+ req.Namespace = namespace
req.Name = name
return &req, nil
}
@@ -521,16 +503,10 @@ func buildGetTablePolicyRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
- namespace, err := getDecodedPathParam(r, "namespace")
+ namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
- if namespace == "" {
- return nil, fmt.Errorf("namespace is required")
- }
- if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
- return nil, err
- }
name, err := getDecodedPathParam(r, "name")
if err != nil {
return nil, err
@@ -543,7 +519,7 @@ func buildGetTablePolicyRequest(r *http.Request) (interface{}, error) {
}
return &s3tables.GetTablePolicyRequest{
TableBucketARN: tableBucketARN,
- Namespace: []string{namespace},
+ Namespace: namespace,
Name: name,
}, nil
}
@@ -553,16 +529,10 @@ func buildDeleteTablePolicyRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
- namespace, err := getDecodedPathParam(r, "namespace")
+ namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
- if namespace == "" {
- return nil, fmt.Errorf("namespace is required")
- }
- if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
- return nil, err
- }
name, err := getDecodedPathParam(r, "name")
if err != nil {
return nil, err
@@ -575,7 +545,7 @@ func buildDeleteTablePolicyRequest(r *http.Request) (interface{}, error) {
}
return &s3tables.DeleteTablePolicyRequest{
TableBucketARN: tableBucketARN,
- Namespace: []string{namespace},
+ Namespace: namespace,
Name: name,
}, nil
}
diff --git a/weed/s3api/s3tables/handler_table.go b/weed/s3api/s3tables/handler_table.go
index 77e6691fd..48b9b78c0 100644
--- a/weed/s3api/s3tables/handler_table.go
+++ b/weed/s3api/s3tables/handler_table.go
@@ -408,7 +408,7 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
resp := &GetTableResponse{
Name: metadata.Name,
TableARN: tableARN,
- Namespace: []string{metadata.Namespace},
+ Namespace: expandNamespace(metadata.Namespace),
Format: metadata.Format,
CreatedAt: metadata.CreatedAt,
ModifiedAt: metadata.ModifiedAt,
@@ -683,7 +683,7 @@ func (h *S3TablesHandler) listTablesWithClient(r *http.Request, client filer_pb.
tables = append(tables, TableSummary{
Name: entry.Entry.Name,
TableARN: tableARN,
- Namespace: []string{namespaceName},
+ Namespace: expandNamespace(namespaceName),
CreatedAt: metadata.CreatedAt,
ModifiedAt: metadata.ModifiedAt,
})
diff --git a/weed/s3api/s3tables/utils.go b/weed/s3api/s3tables/utils.go
index f6be5f530..a969cba3b 100644
--- a/weed/s3api/s3tables/utils.go
+++ b/weed/s3api/s3tables/utils.go
@@ -15,7 +15,7 @@ import (
const (
bucketNamePatternStr = `[a-z0-9-]+`
- tableNamespacePatternStr = `[a-z0-9_]+`
+ tableNamespacePatternStr = `[a-z0-9_.]+`
tableNamePatternStr = `[a-z0-9_]+`
)
@@ -54,7 +54,6 @@ func ParseBucketNameFromARN(arn string) (string, error) {
// parseTableFromARN extracts bucket name, namespace, and table name from ARN
// ARN format: arn:aws:s3tables:{region}:{account}:bucket/{bucket-name}/table/{namespace}/{table-name}
func parseTableFromARN(arn string) (bucketName, namespace, tableName string, err error) {
- // Updated regex to align with namespace validation (single-segment)
matches := tableARNPattern.FindStringSubmatch(arn)
if len(matches) != 4 {
return "", "", "", fmt.Errorf("invalid table ARN: %s", arn)
@@ -66,9 +65,7 @@ func parseTableFromARN(arn string) (bucketName, namespace, tableName string, err
return "", "", "", fmt.Errorf("invalid bucket name in ARN: %v", err)
}
- // Namespace is already constrained by the regex; validate it directly.
- namespace = matches[2]
- _, err = validateNamespace([]string{namespace})
+ namespace, err = validateNamespace([]string{matches[2]})
if err != nil {
return "", "", "", fmt.Errorf("invalid namespace in ARN: %v", err)
}
@@ -326,35 +323,27 @@ func splitPath(p string) (dir, name string) {
return
}
-// validateNamespace validates that the namespace provided is supported (single-level)
-func validateNamespace(namespace []string) (string, error) {
- if len(namespace) == 0 {
- return "", fmt.Errorf("namespace is required")
- }
- if len(namespace) > 1 {
- return "", fmt.Errorf("multi-level namespaces are not supported")
- }
- name := namespace[0]
+func validateNamespacePart(name string) error {
if len(name) < 1 || len(name) > 255 {
- return "", fmt.Errorf("namespace name must be between 1 and 255 characters")
+ return fmt.Errorf("namespace name must be between 1 and 255 characters")
}
// Prevent path traversal and multi-segment paths
if name == "." || name == ".." {
- return "", fmt.Errorf("namespace name cannot be '.' or '..'")
+ return fmt.Errorf("namespace name cannot be '.' or '..'")
}
if strings.Contains(name, "/") {
- return "", fmt.Errorf("namespace name cannot contain '/'")
+ return fmt.Errorf("namespace name cannot contain '/'")
}
// Must start and end with a letter or digit
start := name[0]
end := name[len(name)-1]
if !((start >= 'a' && start <= 'z') || (start >= '0' && start <= '9')) {
- return "", fmt.Errorf("namespace name must start with a letter or digit")
+ return fmt.Errorf("namespace name must start with a letter or digit")
}
if !((end >= 'a' && end <= 'z') || (end >= '0' && end <= '9')) {
- return "", fmt.Errorf("namespace name must end with a letter or digit")
+ return fmt.Errorf("namespace name must end with a letter or digit")
}
// Allowed characters: a-z, 0-9, _
@@ -362,15 +351,46 @@ func validateNamespace(namespace []string) (string, error) {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' {
continue
}
- return "", fmt.Errorf("invalid namespace name: only 'a-z', '0-9', and '_' are allowed")
+ return fmt.Errorf("invalid namespace name: only 'a-z', '0-9', and '_' are allowed")
}
// Reserved prefix
if strings.HasPrefix(name, "aws") {
- return "", fmt.Errorf("namespace name cannot start with reserved prefix 'aws'")
+ return fmt.Errorf("namespace name cannot start with reserved prefix 'aws'")
}
- return name, nil
+ return nil
+}
+
+func normalizeNamespace(namespace []string) ([]string, error) {
+ if len(namespace) == 0 {
+ return nil, fmt.Errorf("namespace is required")
+ }
+
+ parts := namespace
+ if len(namespace) == 1 {
+ parts = strings.Split(namespace[0], ".")
+ }
+
+ normalized := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if err := validateNamespacePart(part); err != nil {
+ return nil, err
+ }
+ normalized = append(normalized, part)
+ }
+ return normalized, nil
+}
+
+// validateNamespace validates namespace identifiers and returns an internal namespace key.
+// A single dotted namespace value is interpreted as multi-level namespace for compatibility
+// with path-style APIs, for example "analytics.daily" => ["analytics", "daily"].
+func validateNamespace(namespace []string) (string, error) {
+ parts, err := normalizeNamespace(namespace)
+ if err != nil {
+ return "", err
+ }
+ return flattenNamespace(parts), nil
}
// ValidateNamespace is a wrapper to validate namespace for other packages.
@@ -378,6 +398,11 @@ func ValidateNamespace(namespace []string) (string, error) {
return validateNamespace(namespace)
}
+// ParseNamespace parses a namespace string into namespace parts.
+func ParseNamespace(namespace string) ([]string, error) {
+ return normalizeNamespace([]string{namespace})
+}
+
// validateTableName validates a table name
func validateTableName(name string) (string, error) {
if len(name) < 1 || len(name) > 255 {
@@ -415,3 +440,14 @@ func flattenNamespace(namespace []string) string {
}
return strings.Join(namespace, ".")
}
+
+func expandNamespace(namespace string) []string {
+ if namespace == "" {
+ return nil
+ }
+ parts, err := ParseNamespace(namespace)
+ if err != nil {
+ return []string{namespace}
+ }
+ return parts
+}
diff --git a/weed/s3api/s3tables/utils_namespace_test.go b/weed/s3api/s3tables/utils_namespace_test.go
new file mode 100644
index 000000000..1081d024c
--- /dev/null
+++ b/weed/s3api/s3tables/utils_namespace_test.go
@@ -0,0 +1,126 @@
+package s3tables
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestValidateNamespaceSupportsMultiLevel(t *testing.T) {
+ got, err := validateNamespace([]string{"analytics", "daily"})
+ if err != nil {
+ t.Fatalf("validateNamespace returned error: %v", err)
+ }
+ if got != "analytics.daily" {
+ t.Fatalf("validateNamespace = %q, want %q", got, "analytics.daily")
+ }
+}
+
+func TestValidateNamespaceSupportsDottedInput(t *testing.T) {
+ got, err := validateNamespace([]string{"analytics.daily"})
+ if err != nil {
+ t.Fatalf("validateNamespace returned error: %v", err)
+ }
+ if got != "analytics.daily" {
+ t.Fatalf("validateNamespace = %q, want %q", got, "analytics.daily")
+ }
+}
+
+func TestValidateNamespaceRejectsEmptyDottedSegment(t *testing.T) {
+ _, err := validateNamespace([]string{"analytics..daily"})
+ if err == nil {
+ t.Fatalf("expected validateNamespace to fail for empty dotted segment")
+ }
+}
+
+func TestParseNamespace(t *testing.T) {
+ tests := []struct {
+ name string
+ namespace string
+ want []string
+ wantErr bool
+ }{
+ {
+ name: "single level",
+ namespace: "analytics",
+ want: []string{"analytics"},
+ },
+ {
+ name: "multi level dotted",
+ namespace: "analytics.daily",
+ want: []string{"analytics", "daily"},
+ },
+ {
+ name: "invalid reserved prefix",
+ namespace: "analytics.awsprod",
+ wantErr: true,
+ },
+ {
+ name: "invalid empty segment",
+ namespace: "analytics..daily",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := ParseNamespace(tt.namespace)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("ParseNamespace(%q) expected error", tt.namespace)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("ParseNamespace(%q) unexpected error: %v", tt.namespace, err)
+ }
+ if len(got) != len(tt.want) {
+ t.Fatalf("ParseNamespace(%q) = %v, want %v", tt.namespace, got, tt.want)
+ }
+ for i := range got {
+ if got[i] != tt.want[i] {
+ t.Fatalf("ParseNamespace(%q) = %v, want %v", tt.namespace, got, tt.want)
+ }
+ }
+ })
+ }
+}
+
+func TestParseTableFromARNWithMultiLevelNamespace(t *testing.T) {
+ arn := "arn:aws:s3tables:us-east-1:123456789012:bucket/testbucket/table/analytics.daily/events"
+ bucket, namespace, table, err := parseTableFromARN(arn)
+ if err != nil {
+ t.Fatalf("parseTableFromARN returned error: %v", err)
+ }
+ if bucket != "testbucket" {
+ t.Fatalf("bucket = %q, want %q", bucket, "testbucket")
+ }
+ if namespace != "analytics.daily" {
+ t.Fatalf("namespace = %q, want %q", namespace, "analytics.daily")
+ }
+ if table != "events" {
+ t.Fatalf("table = %q, want %q", table, "events")
+ }
+}
+
+func TestBuildTableARNWithDottedNamespace(t *testing.T) {
+ arn, err := BuildTableARN("us-east-1", "123456789012", "testbucket", "analytics.daily", "events")
+ if err != nil {
+ t.Fatalf("BuildTableARN returned error: %v", err)
+ }
+ if !strings.Contains(arn, "/table/analytics.daily/events") {
+ t.Fatalf("BuildTableARN returned %q, missing normalized namespace/table path", arn)
+ }
+}
+
+func TestExpandNamespace(t *testing.T) {
+ got := expandNamespace("analytics.daily")
+ want := []string{"analytics", "daily"}
+ if len(got) != len(want) {
+ t.Fatalf("expandNamespace = %v, want %v", got, want)
+ }
+ for i := range got {
+ if got[i] != want[i] {
+ t.Fatalf("expandNamespace = %v, want %v", got, want)
+ }
+ }
+}