Repository: apache/iceberg-go
Pull request #301 (2025-02-13 23:25:03)

docs(cmd/iceberg): Added description of CLI usage in README
Added description of CLI usage in README similar to pyiceberg.
https://py.iceberg.apache.org/cli/


Patch:
diff --git a/.github/workflows/go-integration.yml b/.github/workflows/go-integration.yml
index 7dd5e75..0060737 100644
--- a/.github/workflows/go-integration.yml
+++ b/.github/workflows/go-integration.yml
@@ -65,6 +65,7 @@ jobs:
           AWS_REGION: "us-east-1"
         run: |          
           go test -tags integration -v -run="^TestScanner" ./table
+          go test -tags integration -v ./io
 
       - name: Show debug logs
         if: ${{ failure() }}
diff --git a/README.md b/README.md
index 5303c07..90a3b20 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,9 @@ $ cd iceberg-go/cmd/iceberg && go build .
 | Check Namespace Exists   |  X   |      |          |      |  X  |
 | Drop Namespace           |  X   |      |          |  X   |  X  |
 | Set Namespace Properties |  X   |      |          |  X   |  X  |
+| List View                |  X   |      |          |      |     |
+| Drop View                |  X   |      |          |      |     |
+| Check View Exists        |  X   |      |          |      |     |
 
 ### Read/Write Data Support
 
@@ -82,6 +85,44 @@ $ cd iceberg-go/cmd/iceberg && go build .
 * Plan to add [Apache Arrow](https://pkg.go.dev/github.com/apache/arrow-go/) support eventually.
 * Data can currently be read as an Arrow Table or as a stream of Arrow record batches.
 
+
+### CLI Usage
+Run `go build ./cmd/iceberg` from the root of this repository to build the CLI executable, alternately you can run `go install github.com/apache/iceberg-go/cmd/iceberg` to install it to the `bin` directory of your `GOPATH`.
+
+The `iceberg` CLI usage is very similar to [pyiceberg CLI](https://py.iceberg.apache.org/cli/) \
+You can pass the catalog URI with `--uri` argument.
+
+Example:
+You can start the Iceberg REST API docker image which runs on default in port `8181`
+```
+docker pull tabulario/iceberg-rest:latest
+docker run -p 8181:8181 tabulario/iceberg-rest:latest
+```
+and run the `iceberg` CLI pointing to the REST API server.
+
+```
+ ./iceberg --uri http://0.0.0.0:8181 list
+┌─────┐
+| IDs |
+| --- |
+└─────┘
+```
+**Create Namespace**
+```
+./iceberg --uri http://0.0.0.0:8181 create namespace taxitrips
+```
+
+**List Namespace**
+```
+ ./iceberg --uri http://0.0.0.0:8181 list
+┌───────────┐
+| IDs       |
+| --------- |
+| taxitrips |
+└───────────┘
+
+
+```
 # Get in Touch
 
 - [Iceberg community](https://iceberg.apache.org/community/)
diff --git a/catalog/catalog.go b/catalog/catalog.go
index db14dab..6e9a98b 100644
--- a/catalog/catalog.go
+++ b/catalog/catalog.go
@@ -57,6 +57,7 @@ var (
 	ErrTableAlreadyExists     = errors.New("table already exists")
 	ErrCatalogNotFound        = errors.New("catalog type not registered")
 	ErrNamespaceNotEmpty      = errors.New("namespace is not empty")
+	ErrNoSuchView             = errors.New("view does not exist")
 )
 
 type PropertiesUpdateSummary struct {
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index e0d20e8..d097c9a 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -27,9 +27,11 @@ import (
 	"fmt"
 	"hash"
 	"io"
+	"iter"
 	"maps"
 	"net/http"
 	"net/url"
+	"strconv"
 	"strings"
 	"time"
 
@@ -48,6 +50,10 @@ var (
 )
 
 const (
+	pageSizeKey contextKey = "page_size"
+
+	defaultPageSize = 20
+
 	keyOauthToken        = "token"
 	keyWarehouseLocation = "warehouse"
 	keyMetadataLocation  = "metadata_location"
@@ -98,6 +104,8 @@ type errorResponse struct {
 	wrapping error
 }
 
+type contextKey string
+
 func (e errorResponse) Unwrap() error { return e.wrapping }
 func (e errorResponse) Error() string {
 	return e.Type + ": " + e.Message
@@ -989,3 +997,99 @@ func (r *Catalog) CheckTableExists(ctx context.Context, identifier table.Identif
 	}
 	return true, nil
 }
+
+func (r *Catalog) ListViews(ctx context.Context, namespace table.Identifier) iter.Seq2[table.Identifier, error] {
+	return func(yield func(table.Identifier, error) bool) {
+		pageSize := r.getPageSize(ctx)
+		var pageToken string
+
+		for {
+			views, nextPageToken, err := r.listViewsPage(ctx, namespace, pageToken, pageSize)
+			if err != nil {
+				yield(table.Identifier{}, err)
+				return
+			}
+			for _, view := range views {
+				if !yield(view, nil) {
+					return
+				}
+			}
+			if nextPageToken == "" {
+				return
+			}
+			pageToken = nextPageToken
+		}
+	}
+}
+
+func (r *Catalog) listViewsPage(ctx context.Context, namespace table.Identifier, pageToken string, pageSize int) ([]table.Identifier, string, error) {
+	if err := checkValidNamespace(namespace); err != nil {
+		return nil, "", err
+	}
+	ns := strings.Join(namespace, namespaceSeparator)
+	uri := r.baseURI.JoinPath("namespaces", ns, "views")
+
+	v := url.Values{}
+	if pageSize >= 0 {
+		v.Set("page-size", strconv.Itoa(pageSize))
+	}
+	if pageToken != "" {
+		v.Set("page-token", pageToken)
+	}
+
+	uri.RawQuery = v.Encode()
+	type resp struct {
+		Identifiers   []identifier `json:"identifiers"`
+		NextPageToken string       `json:"next-page-token,omitempty"`
+	}
+
+	rsp, err := doGet[resp](ctx, uri, []string{}, r.cl, map[int]error{http.StatusNotFound: catalog.ErrNoSuchNamespace})
+	if err != nil {
+		return nil, "", err
+	}
+
+	out := make([]table.Identifier, len(rsp.Identifiers))
+	for i, id := range rsp.Identifiers {
+		out[i] = append(id.Namespace, id.Name)
+	}
+	return out, rsp.NextPageToken, nil
+}
+
+func (r *Catalog) getPageSize(ctx context.Context) int {
+	if pageSize, ok := ctx.Value(pageSizeKey).(int); ok {
+		return pageSize
+	}
+	return defaultPageSize
+}
+
+func (r *Catalog) SetPageSize(ctx context.Context, sz int) context.Context {
+	return context.WithValue(ctx, pageSizeKey, sz)
+}
+
+func (r *Catalog) DropView(ctx context.Context, identifier table.Identifier) error {
+	ns, view, err := splitIdentForPath(identifier)
+	if err != nil {
+		return err
+	}
+
+	_, err = doDelete[struct{}](ctx, r.baseURI, []string{"namespaces", ns, "views", view}, r.cl,
+		map[int]error{http.StatusNotFound: catalog.ErrNoSuchView})
+	return err
+}
+
+func (r *Catalog) CheckViewExists(ctx context.Context, identifier table.Identifier) (bool, error) {
+	ns, view, err := splitIdentForPath(identifier)
+	if err != nil {
+		return false, err
+	}
+
+	err = doHead(ctx, r.baseURI, []string{"namespaces", ns, "views", view},
+		r.cl, map[int]error{http.StatusNotFound: catalog.ErrNoSuchView})
+	if err != nil {
+		if errors.Is(err, catalog.ErrNoSuchView) {
+			return false, nil
+		}
+		return false, err
+	}
+	return true, nil
+}
diff --git a/io/blob.go b/io/blob.go
index 3cbaee0..42bb3c1 100644
--- a/io/blob.go
+++ b/io/blob.go
@@ -84,10 +84,11 @@ func (bfs *blobFileIO) preprocess(key string) string {
 		key = after
 	}
 
-	return strings.TrimPrefix(key, bfs.bucketName)
+	return strings.TrimPrefix(key, bfs.bucketName+"/")
 }
 
-func (bfs *blobFileIO) Open(path string) (fs.File, error) {
+func (bfs *blobFileIO) Open(path string) (File, error) {
+	path = bfs.preprocess(path)
 	if !fs.ValidPath(path) {
 		return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrInvalid}
 	}
@@ -105,6 +106,7 @@ func (bfs *blobFileIO) Open(path string) (fs.File, error) {
 }
 
 func (bfs *blobFileIO) Remove(name string) error {
+	name = bfs.preprocess(name)
 	return bfs.Bucket.Delete(bfs.ctx, name)
 }
 
@@ -112,6 +114,11 @@ func (bfs *blobFileIO) Create(name string) (FileWriter, error) {
 	return bfs.NewWriter(bfs.ctx, name, true, nil)
 }
 
+func (bfs *blobFileIO) WriteFile(name string, content []byte) error {
+	name = bfs.preprocess(name)
+	return bfs.Bucket.WriteAll(bfs.ctx, name, content, nil)
+}
+
 // NewWriter returns a Writer that writes to the blob stored at path.
 // A nil WriterOptions is treated the same as the zero value.
 //
@@ -121,10 +128,10 @@ func (bfs *blobFileIO) Create(name string) (FileWriter, error) {
 // The caller must call Close on the returned Writer, even if the write is
 // aborted.
 func (io *blobFileIO) NewWriter(ctx context.Context, path string, overwrite bool, opts *blob.WriterOptions) (w *blobWriteFile, err error) {
+	path = io.preprocess(path)
 	if !fs.ValidPath(path) {
 		return nil, &fs.PathError{Op: "new writer", Path: path, Err: fs.ErrInvalid}
 	}
-	path = io.preprocess(path)
 
 	if !overwrite {
 		if exists, err := io.Bucket.Exists(ctx, path); exists {
@@ -145,8 +152,7 @@ func (io *blobFileIO) NewWriter(ctx context.Context, path string, overwrite bool
 }
 
 func createBlobFS(ctx context.Context, bucket *blob.Bucket, bucketName string) IO {
-	iofs := &blobFileIO{Bucket: bucket, bucketName: bucketName, ctx: ctx}
-	return FSPreProcName(iofs, iofs.preprocess)
+	return &blobFileIO{Bucket: bucket, bucketName: bucketName, ctx: ctx}
 }
 
 type blobWriteFile struct {
diff --git a/io/io.go b/io/io.go
index 82d2c75..277f06e 100644
--- a/io/io.go
+++ b/io/io.go
@@ -138,6 +138,7 @@ func FSPreProcName(fsys fs.FS, fn func(string) string) IO {
 	if _, ok := fsys.(fs.ReadFileFS); ok {
 		return readFileFS{ioFS{fsys, fn}}
 	}
+
 	return ioFS{fsys, fn}
 }
 


Test patch:
diff --git a/catalog/rest/rest_test.go b/catalog/rest/rest_test.go
index a92a38d..32d96ad 100644
--- a/catalog/rest/rest_test.go
+++ b/catalog/rest/rest_test.go
@@ -26,6 +26,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"strconv"
 	"testing"
 
 	"github.com/apache/iceberg-go"
@@ -1330,6 +1331,339 @@ func (r *RestCatalogSuite) TestRegisterTable409() {
 	r.ErrorContains(err, "The given table already exists")
 }
 
+func (r *RestCatalogSuite) TestListViews200() {
+	customPageSize := 100
+	namespace := "accounting"
+	r.mux.HandleFunc("/v1/namespaces/"+namespace+"/views", func(w http.ResponseWriter, req *http.Request) {
+		r.Require().Equal(http.MethodGet, req.Method)
+
+		for k, v := range TestHeaders {
+			r.Equal(v, req.Header.Values(k))
+		}
+
+		pageToken := req.URL.Query().Get("page-token")
+		pageSize := req.URL.Query().Get("page-size")
+		r.Equal("", pageToken)
+		r.Equal(strconv.Itoa(customPageSize), pageSize)
+
+		json.NewEncoder(w).Encode(map[string]any{
+			"identifiers": []any{
+				map[string]any{
+					"namespace": []string{"accounting", "tax"},
+					"name":      "paid",
+				},
+				map[string]any{
+					"namespace": []string{"accounting", "tax"},
+					"name":      "owed",
+				},
+			},
+		})
+	})
+
+	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
+	r.Require().NoError(err)
+	// Passing in a custom page size through context
+	ctx := cat.SetPageSize(context.Background(), customPageSize)
+
+	var lastErr error
+	views := make([]table.Identifier, 0)
+	iter := cat.ListViews(ctx, catalog.ToIdentifier(namespace))
+
+	for view, err := range iter {
+		views = append(views, view)
+		if err != nil {
+			lastErr = err
+			r.FailNow("unexpected error:", err)
+		}
+	}
+
+	r.Equal([]table.Identifier{
+		{"accounting", "tax", "paid"},
+		{"accounting", "tax", "owed"},
+	}, views)
+	r.Require().NoError(lastErr)
+}
+
+func (r *RestCatalogSuite) TestListViewsPagination() {
+	defaultPageSize := 20
+	namespace := "accounting"
+	r.mux.HandleFunc("/v1/namespaces/"+namespace+"/views", func(w http.ResponseWriter, req *http.Request) {
+		r.Require().Equal(http.MethodGet, req.Method)
+
+		for k, v := range TestHeaders {
+			r.Equal(v, req.Header.Values(k))
+		}
+
+		pageToken := req.URL.Query().Get("page-token")
+		pageSize := req.URL.Query().Get("page-size")
+		r.Equal(strconv.Itoa(defaultPageSize), pageSize)
+
+		var response map[string]any
+		if pageToken == "" {
+			response = map[string]any{
+				"identifiers": []any{
+					map[string]any{
+						"namespace": []string{"accounting", "tax"},
+						"name":      "paid1",
+					},
+					map[string]any{
+						"namespace": []string{"accounting", "tax"},
+						"name":      "paid2",
+					},
+				},
+				"next-page-token": "token1",
+			}
+		} else if pageToken == "token1" {
+			r.Equal("token1", pageToken)
+			response = map[string]any{
+				"identifiers": []any{
+					map[string]any{
+						"namespace": []string{"accounting", "tax"},
+						"name":      "pending1",
+					},
+					map[string]any{
+						"namespace": []string{"accounting", "tax"},
+						"name":      "pending2",
+					},
+				},
+				"next-page-token": "token2",
+			}
+		} else {
+			r.Equal("token2", pageToken)
+			response = map[string]any{
+				"identifiers": []any{
+					map[string]any{
+						"namespace": []string{"accounting", "tax"},
+						"name":      "owned",
+					},
+				},
+			}
+		}
+
+		json.NewEncoder(w).Encode(response)
+	})
+
+	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
+	r.Require().NoError(err)
+
+	var lastErr error
+	views := make([]table.Identifier, 0)
+	iter := cat.ListViews(context.Background(), catalog.ToIdentifier(namespace))
+	for view, err := range iter {
+		views = append(views, view)
+		if err != nil {
+			lastErr = err
+			r.FailNow("unexpected error:", err)
+		}
+	}
+
+	r.Equal([]table.Identifier{
+		{"accounting", "tax", "paid1"},
+		{"accounting", "tax", "paid2"},
+		{"accounting", "tax", "pending1"},
+		{"accounting", "tax", "pending2"},
+		{"accounting", "tax", "owned"},
+	}, views)
+	r.Require().NoError(lastErr)
+}
+
+func (r *RestCatalogSuite) TestListViewsPaginationErrorOnSubsequentPage() {
+	namespace := "accounting"
+	r.mux.HandleFunc("/v1/namespaces/"+namespace+"/views", func(w http.ResponseWriter, req *http.Request) {
+		r.Require().Equal(http.MethodGet, req.Method)
+
+		for k, v := range TestHeaders {
+			r.Equal(v, req.Header.Values(k))
+		}
+
+		pageToken := req.URL.Query().Get("page-token")
+
+		// First page succeeds
+		if pageToken == "" {
+			json.NewEncoder(w).Encode(map[string]any{
+				"identifiers": []any{
+					map[string]any{
+						"namespace": []string{"accounting", "tax"},
+						"name":      "paid1",
+					},
+					map[string]any{
+						"namespace": []string{"accounting", "tax"},
+						"name":      "paid2",
+					},
+				},
+				"next-page-token": "token1",
+			})
+			return
+		}
+
+		// Second page fails with an error
+		if pageToken == "token1" {
+			w.WriteHeader(http.StatusNotFound)
+			json.NewEncoder(w).Encode(map[string]any{
+				"error": map[string]any{
+					"message": "Token expired or invalid",
+					"type":    "NoSuchPageTokenException",
+					"code":    404,
+				},
+			})
+			return
+		}
+
+		r.FailNow("unexpected page token:", pageToken)
+	})
+
+	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
+	r.Require().NoError(err)
+
+	views := make([]table.Identifier, 0)
+	var lastErr error
+	iter := cat.ListViews(context.Background(), catalog.ToIdentifier(namespace))
+	for view, err := range iter {
+		if err != nil {
+			lastErr = err
+			break
+		}
+		views = append(views, view)
+	}
+
+	// Check that we got the views from the first page
+	r.Equal([]table.Identifier{
+		{"accounting", "tax", "paid1"},
+		{"accounting", "tax", "paid2"},
+	}, views)
+
+	// Check that we got the error from the second page
+	r.Error(lastErr)
+	r.ErrorContains(lastErr, "Token expired or invalid")
+}
+
+func (r *RestCatalogSuite) TestListViews404() {
+	namespace := "nonexistent"
+	r.mux.HandleFunc("/v1/namespaces/"+namespace+"/views", func(w http.ResponseWriter, req *http.Request) {
+		r.Require().Equal(http.MethodGet, req.Method)
+
+		for k, v := range TestHeaders {
+			r.Equal(v, req.Header.Values(k))
+		}
+
+		w.WriteHeader(http.StatusNotFound)
+		json.NewEncoder(w).Encode(map[string]any{
+			"error": map[string]any{
+				"message": "The given namespace does not exist",
+				"type":    "NoSuchNamespaceException",
+				"code":    404,
+			},
+		})
+	})
+
+	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
+	r.Require().NoError(err)
+
+	var lastErr error
+	iter := cat.ListViews(context.Background(), catalog.ToIdentifier(namespace))
+	views := make([]table.Identifier, 0)
+	for view, err := range iter {
+		views = append(views, view)
+		if err != nil {
+			lastErr = err
+		}
+	}
+	r.ErrorIs(lastErr, catalog.ErrNoSuchNamespace)
+	r.ErrorContains(lastErr, "The given namespace does not exist")
+}
+
+func (r *RestCatalogSuite) TestDropView204() {
+	r.mux.HandleFunc("/v1/namespaces/fokko/views/fokko2", func(w http.ResponseWriter, req *http.Request) {
+		r.Require().Equal(http.MethodDelete, req.Method)
+
+		for k, v := range TestHeaders {
+			r.Equal(v, req.Header.Values(k))
+		}
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
+	r.Require().NoError(err)
+
+	err = cat.DropView(context.Background(), catalog.ToIdentifier("fokko", "fokko2"))
+	r.NoError(err)
+}
+
+func (r *RestCatalogSuite) TestDropView404() {
+	r.mux.HandleFunc("/v1/namespaces/fokko/views/nonexistent", func(w http.ResponseWriter, req *http.Request) {
+		r.Require().Equal(http.MethodDelete, req.Method)
+
+		for k, v := range TestHeaders {
+			r.Equal(v, req.Header.Values(k))
+		}
+
+		w.WriteHeader(http.StatusNotFound)
+		errorResponse := map[string]interface{}{
+			"error": map[string]interface{}{
+				"message": "The given view does not exist",
+				"type":    "NoSuchViewException",
+				"code":    404,
+			},
+		}
+		json.NewEncoder(w).Encode(errorResponse)
+	})
+
+	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
+	r.Require().NoError(err)
+
+	err = cat.DropView(context.Background(), catalog.ToIdentifier("fokko", "nonexistent"))
+	r.Error(err)
+	r.ErrorIs(err, catalog.ErrNoSuchView)
+	r.ErrorContains(err, "The given view does not exist")
+}
+
+func (r *RestCatalogSuite) TestCheckViewExists204() {
+	r.mux.HandleFunc("/v1/namespaces/fokko/views/fokko2", func(w http.ResponseWriter, req *http.Request) {
+		r.Require().Equal(http.MethodHead, req.Method)
+
+		for k, v := range TestHeaders {
+			r.Equal(v, req.Header.Values(k))
+		}
+		w.WriteHeader(http.StatusNoContent)
+	})
+	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
+	r.Require().NoError(err)
+
+	exists, err := cat.CheckViewExists(context.Background(), catalog.ToIdentifier("fokko", "fokko2"))
+	r.Require().NoError(err)
+	r.Require().True(exists)
+}
+
+func (r *RestCatalogSuite) TestCheckViewExists404() {
+	r.mux.HandleFunc("/v1/namespaces/fokko/views/nonexistent", func(w http.ResponseWriter, req *http.Request) {
+		r.Require().Equal(http.MethodHead, req.Method)
+
+		for k, v := range TestHeaders {
+			r.Equal(v, req.Header.Values(k))
+		}
+
+		w.WriteHeader(http.StatusNotFound)
+		err := json.NewEncoder(w).Encode(map[string]any{
+			"error": map[string]any{
+				"message": "The given view does not exist",
+				"type":    "NoSuchViewException",
+				"code":    404,
+			},
+		})
+		if err != nil {
+			return
+		}
+	})
+
+	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
+	r.Require().NoError(err)
+
+	exists, err := cat.CheckViewExists(context.Background(), catalog.ToIdentifier("fokko", "nonexistent"))
+	r.Require().NoError(err)
+	r.False(exists)
+}
+
 type RestTLSCatalogSuite struct {
 	suite.Suite
 
diff --git a/io/s3_integration_test.go b/io/s3_integration_test.go
new file mode 100644
index 0000000..95434a4
--- /dev/null
+++ b/io/s3_integration_test.go
@@ -0,0 +1,61 @@
+// 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.
+
+//go:build integration
+
+package io_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apache/iceberg-go"
+	"github.com/apache/iceberg-go/catalog"
+	sqlcat "github.com/apache/iceberg-go/catalog/sql"
+	"github.com/apache/iceberg-go/io"
+	"github.com/stretchr/testify/require"
+	"github.com/uptrace/bun/driver/sqliteshim"
+)
+
+func TestMinioWarehouse(t *testing.T) {
+	cat, err := catalog.Load(context.Background(), "default", iceberg.Properties{
+		"uri":                ":memory:",
+		sqlcat.DriverKey:     sqliteshim.ShimName,
+		sqlcat.DialectKey:    string(sqlcat.SQLite),
+		"type":               "sql",
+		"warehouse":          "s3a://warehouse/iceberg/",
+		io.S3Region:          "local",
+		io.S3AccessKeyID:     "admin",
+		io.S3SecretAccessKey: "password",
+		// endpoint is passed via AWS_S3_ENDPOINT env var
+	})
+	require.NoError(t, err)
+
+	require.NotNil(t, cat)
+
+	c := cat.(*sqlcat.Catalog)
+	ctx := context.Background()
+	require.NoError(t, c.CreateNamespace(ctx, catalog.ToIdentifier("iceberg-test-2"), nil))
+
+	tbl, err := c.CreateTable(ctx,
+		catalog.ToIdentifier("iceberg-test-2", "test-table-2"),
+		iceberg.NewSchema(0, iceberg.NestedField{
+			Name: "id", Type: iceberg.PrimitiveTypes.Int32, Required: true, ID: 1,
+		}), catalog.WithLocation("s3a://warehouse/iceberg/iceberg-test-2/test-table-2"))
+	require.NoError(t, err)
+	require.NotNil(t, tbl)
+}