Repository: apache/iceberg-go
Pull request #376: feat (catalog/rest): Add create view for rest catalog
Author: login: dttung2905
Created: 2025-04-03T22:36:46Z | Merged: 2025-04-08T16:49:03Z

Currently, I'm returning only the error instead of a iceberg view class instance. The reason is because the response return in `metadata` key is quite different from the table related operation and will require some significant work to refactor https://github.com/apache/iceberg-go/blob/main/table/metadata.go. Let me know if you want to pursue that direction :pray: 


Diff:
diff --git a/README.md b/README.md
index 9558a0081..6fc48023f 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,7 @@ $ cd iceberg-go/cmd/iceberg && go build .
 | Check Namespace Exists   |  X   |      |          |  X   |  X  |
 | Drop Namespace           |  X   |      |          |  X   |  X  |
 | Set Namespace Properties |  X   |      |          |  X   |  X  |
+| Create View              |  X   |      |          |      |     |
 | List View                |  X   |      |          |      |     |
 | Drop View                |  X   |      |          |      |     |
 | Check View Exists        |  X   |      |          |      |     |
diff --git a/catalog/catalog.go b/catalog/catalog.go
index 6b2118fbe..8777b12ef 100644
--- a/catalog/catalog.go
+++ b/catalog/catalog.go
@@ -60,6 +60,7 @@ var (
 	ErrCatalogNotFound        = errors.New("catalog type not registered")
 	ErrNamespaceNotEmpty      = errors.New("namespace is not empty")
 	ErrNoSuchView             = errors.New("view does not exist")
+	ErrViewAlreadyExists      = errors.New("view already exists")
 )
 
 type PropertiesUpdateSummary struct {
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 133ae4c42..41b96983c 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -1156,3 +1156,79 @@ func (r *Catalog) CheckViewExists(ctx context.Context, identifier table.Identifi
 
 	return true, nil
 }
+
+type viewVersion struct {
+	VersionID       int64             `json:"version-id"`
+	TimestampMs     int64             `json:"timestamp-ms"`
+	SchemaID        int               `json:"schema-id"`
+	Summary         map[string]string `json:"summary"`
+	Representations []struct {
+		Type    string `json:"type"`
+		SQL     string `json:"sql"`
+		Dialect string `json:"dialect"`
+	} `json:"representations"`
+	DefaultCatalog   string   `json:"default-catalog"`
+	DefaultNamespace []string `json:"default-namespace"`
+}
+
+type createViewRequest struct {
+	Name        string             `json:"name"`
+	Schema      *iceberg.Schema    `json:"schema"`
+	Location    string             `json:"location,omitempty"`
+	Props       iceberg.Properties `json:"properties,omitempty"`
+	SQL         string             `json:"sql"`
+	ViewVersion viewVersion        `json:"view-version"`
+}
+
+type viewResponse struct {
+	MetadataLoc string             `json:"metadata-location"`
+	RawMetadata json.RawMessage    `json:"metadata"`
+	Config      iceberg.Properties `json:"config"`
+	Metadata    table.Metadata     `json:"-"`
+}
+
+// CreateView creates a new view in the catalog.
+func (r *Catalog) CreateView(ctx context.Context, identifier table.Identifier, schema *iceberg.Schema, sql string, props iceberg.Properties) error {
+	ns, view, err := splitIdentForPath(identifier)
+	if err != nil {
+		return err
+	}
+
+	freshSchema, err := iceberg.AssignFreshSchemaIDs(schema, nil)
+	if err != nil {
+		return err
+	}
+
+	payload := createViewRequest{
+		Name:   view,
+		Schema: freshSchema,
+		SQL:    sql,
+		Props:  props,
+		ViewVersion: viewVersion{
+			VersionID:   1,
+			TimestampMs: time.Now().UnixMilli(),
+			SchemaID:    freshSchema.ID,
+			Summary:     map[string]string{"sql": sql},
+			Representations: []struct {
+				Type    string `json:"type"`
+				SQL     string `json:"sql"`
+				Dialect string `json:"dialect"`
+			}{
+				{Type: "sql", SQL: sql, Dialect: "default"},
+			},
+			DefaultCatalog:   r.name,
+			DefaultNamespace: strings.Split(ns, namespaceSeparator),
+		},
+	}
+
+	_, err = doPost[createViewRequest, viewResponse](ctx, r.baseURI, []string{"namespaces", ns, "views"}, payload,
+		r.cl, map[int]error{
+			http.StatusNotFound: catalog.ErrNoSuchNamespace,
+			http.StatusConflict: catalog.ErrViewAlreadyExists,
+		})
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/catalog/rest/rest_test.go b/catalog/rest/rest_test.go
index 93ffebfb8..a6c836f70 100644
--- a/catalog/rest/rest_test.go
+++ b/catalog/rest/rest_test.go
@@ -28,6 +28,7 @@ import (
 	"net/url"
 	"strconv"
 	"testing"
+	"time"
 
 	"github.com/apache/iceberg-go"
 	"github.com/apache/iceberg-go/catalog"
@@ -1956,3 +1957,158 @@ func TestRestCatalog(t *testing.T) {
 	suite.Run(t, new(RestCatalogSuite))
 	suite.Run(t, new(RestTLSCatalogSuite))
 }
+
+type errorResponse struct {
+	Message string `json:"message"`
+	Type    string `json:"type"`
+	Code    int    `json:"code"`
+}
+
+type createViewRequest struct {
+	Name        string             `json:"name"`
+	Schema      *iceberg.Schema    `json:"schema"`
+	SQL         string             `json:"sql"`
+	Props       iceberg.Properties `json:"properties"`
+	ViewVersion struct {
+		VersionID       int               `json:"version-id"`
+		TimestampMs     int64             `json:"timestamp-ms"`
+		SchemaID        int               `json:"schema-id"`
+		Summary         map[string]string `json:"summary"`
+		Representations []struct {
+			Type    string `json:"type"`
+			SQL     string `json:"sql"`
+			Dialect string `json:"dialect"`
+		} `json:"representations"`
+		DefaultCatalog   string   `json:"default-catalog"`
+		DefaultNamespace []string `json:"default-namespace"`
+	} `json:"view-version"`
+}
+
+type viewResponse struct {
+	MetadataLoc string             `json:"metadata-location"`
+	Config      iceberg.Properties `json:"config"`
+}
+
+func (r *RestCatalogSuite) TestCreateView200() {
+	ns := "ns"
+	view := "view"
+	identifier := table.Identifier{ns, view}
+	schema := iceberg.NewSchema(0, iceberg.NestedField{
+		ID:       1,
+		Name:     "id",
+		Type:     iceberg.PrimitiveTypes.Int32,
+		Required: true,
+	})
+	sql := "SELECT * FROM table"
+	viewVersionJSON, _ := json.Marshal(map[string]interface{}{
+		"version-id":   1,
+		"timestamp-ms": time.Now().UnixMilli(),
+		"schema-id":    schema.ID,
+		"summary":      map[string]string{"sql": sql},
+		"representations": []map[string]string{
+			{"type": "sql", "sql": sql, "dialect": "default"},
+		},
+		"default-catalog":   "default-catalog",
+		"default-namespace": []string{ns},
+	})
+	props := iceberg.Properties{
+		"comment":      "Example view created via REST catalog",
+		"owner":        "admin",
+		"view-version": string(viewVersionJSON),
+		"view-format":  "iceberg",
+		"view-sql":     sql,
+	}
+	r.mux.HandleFunc("/v1/namespaces/"+ns+"/views", func(w http.ResponseWriter, req *http.Request) {
+		r.Equal(http.MethodPost, req.Method)
+		r.Equal("application/json", req.Header.Get("Content-Type"))
+
+		var payload createViewRequest
+		err := json.NewDecoder(req.Body).Decode(&payload)
+		r.NoError(err)
+		r.Equal(view, payload.Name)
+		r.Equal(sql, payload.SQL)
+		r.Equal(schema.ID, payload.Schema.ID)
+		r.Equal(1, payload.ViewVersion.VersionID)
+		r.Equal(0, payload.ViewVersion.SchemaID)
+		r.Equal("sql", payload.ViewVersion.Representations[0].Type)
+		r.Equal(sql, payload.ViewVersion.Representations[0].SQL)
+		r.Equal("default", payload.ViewVersion.Representations[0].Dialect)
+		r.Equal("rest", payload.ViewVersion.DefaultCatalog)
+		r.Equal([]string{ns}, payload.ViewVersion.DefaultNamespace)
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		json.NewEncoder(w).Encode(viewResponse{
+			MetadataLoc: "metadata-location",
+			Config:      iceberg.Properties{},
+		})
+	})
+
+	ctlg, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL)
+	r.NoError(err)
+
+	err = ctlg.CreateView(context.Background(), identifier, schema, sql, props)
+	r.NoError(err)
+}
+
+func (r *RestCatalogSuite) TestCreateView409() {
+	ns := "ns"
+	view := "view"
+	identifier := table.Identifier{ns, view}
+	schema := iceberg.NewSchema(1, iceberg.NestedField{
+		ID:       1,
+		Name:     "id",
+		Type:     iceberg.PrimitiveTypes.Int32,
+		Required: true,
+	})
+	sql := "SELECT * FROM table"
+
+	r.mux.HandleFunc("/v1/namespaces/"+ns+"/views", func(w http.ResponseWriter, req *http.Request) {
+		r.Equal(http.MethodPost, req.Method)
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusConflict)
+		json.NewEncoder(w).Encode(errorResponse{
+			Message: "The given view already exists",
+			Type:    "AlreadyExistsException",
+			Code:    409,
+		})
+	})
+
+	ctlg, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL)
+	r.NoError(err)
+
+	err = ctlg.CreateView(context.Background(), identifier, schema, sql, nil)
+	r.Error(err)
+	r.ErrorIs(err, catalog.ErrViewAlreadyExists)
+}
+
+func (r *RestCatalogSuite) TestCreateView404() {
+	ns := "ns"
+	view := "view"
+	identifier := table.Identifier{ns, view}
+	schema := iceberg.NewSchema(1, iceberg.NestedField{
+		ID:       1,
+		Name:     "id",
+		Type:     iceberg.PrimitiveTypes.Int32,
+		Required: true,
+	})
+	sql := "SELECT * FROM table"
+
+	r.mux.HandleFunc("/v1/namespaces/"+ns+"/views", func(w http.ResponseWriter, req *http.Request) {
+		r.Equal(http.MethodPost, req.Method)
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusNotFound)
+		json.NewEncoder(w).Encode(errorResponse{
+			Message: "namespace does not exist",
+			Type:    "NoSuchNamespaceException",
+			Code:    404,
+		})
+	})
+
+	ctlg, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL)
+	r.NoError(err)
+
+	err = ctlg.CreateView(context.Background(), identifier, schema, sql, nil)
+	r.Error(err)
+	r.ErrorIs(err, catalog.ErrNoSuchNamespace)
+}


Reviews:
user: login: zeroshade
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-07T15:02:18Z
user: login: dttung2905
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T16:27:46Z
user: login: zeroshade
body: This looks good. As a separate PR can we look into adding a test to the rest_integration_test.go file to ensure we have integration tests with the rest catalog?
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-07T16:33:42Z

Review comments:
diff_hunk: @@ -1156,3 +1156,80 @@ func (r *Catalog) CheckViewExists(ctx context.Context, identifier table.Identifi
 
 	return true, nil
 }
+
+type viewVersion struct {
+	VersionID       int64             `json:"version-id"`
+	TimestampMs     int64             `json:"timestamp-ms"`
+	SchemaID        int               `json:"schema-id"`
+	Summary         map[string]string `json:"summary"`
+	Representations []struct {
+		Type    string `json:"type"`
+		SQL     string `json:"sql"`
+		Dialect string `json:"dialect"`
+	} `json:"representations"`
+	DefaultCatalog   string   `json:"default-catalog"`
+	DefaultNamespace []string `json:"default-namespace"`
+}
+
+type createViewRequest struct {
+	Name        string             `json:"name"`
+	Schema      *iceberg.Schema    `json:"schema"`
+	Location    string             `json:"location,omitempty"`
+	Props       iceberg.Properties `json:"properties,omitempty"`
+	SQL         string             `json:"sql"`
+	ViewVersion viewVersion        `json:"view-version"`
+}
+
+type viewResponse struct {
+	MetadataLoc string             `json:"metadata-location"`
+	RawMetadata json.RawMessage    `json:"metadata"`
+	Config      iceberg.Properties `json:"config"`
+	Metadata    table.Metadata     `json:"-"`
+}
+
+// CreateView creates a new view in the catalog.
+func (r *Catalog) CreateView(ctx context.Context, identifier table.Identifier, schema *iceberg.Schema, sql string, props iceberg.Properties) error {
+	ns, view, err := splitIdentForPath(identifier)
+	if err != nil {
+		return err
+	}
+
+	freshSchema, err := iceberg.AssignFreshSchemaIDs(schema, nil)
+	if err != nil {
+		return err
+	}
+
+	payload := createViewRequest{
+		Name:   view,
+		Schema: freshSchema,
+		SQL:    sql,
+		Props:  props,
+		ViewVersion: viewVersion{
+			VersionID:   1,
+			TimestampMs: time.Now().UnixMilli(),
+			SchemaID:    freshSchema.ID,
+			Summary:     map[string]string{"sql": sql},
+			Representations: []struct {
+				Type    string `json:"type"`
+				SQL     string `json:"sql"`
+				Dialect string `json:"dialect"`
+			}{
+				{Type: "sql", SQL: sql, Dialect: "default"},
+			},
+			DefaultCatalog:   r.name,
+			DefaultNamespace: strings.Split(ns, namespaceSeparator),
+		},
+	}
+
+	_, err = doPost[createViewRequest, viewResponse](ctx, r.baseURI, []string{"namespaces", ns, "views"}, payload,
+		r.cl, map[int]error{
+			http.StatusNotFound: catalog.ErrNoSuchNamespace,
+			http.StatusConflict: catalog.ErrViewAlreadyExists,
+		})
+	if err != nil {
+		return err
+	}
+	fmt.Println("created view", identifier)
path: catalog/rest/rest.go
user: login: zeroshade
body: extraneous print
created_at: 2025-04-07T15:01:48Z
author_association: MEMBER
diff_hunk: @@ -1156,3 +1156,80 @@ func (r *Catalog) CheckViewExists(ctx context.Context, identifier table.Identifi
 
 	return true, nil
 }
+
+type viewVersion struct {
+	VersionID       int64             `json:"version-id"`
+	TimestampMs     int64             `json:"timestamp-ms"`
+	SchemaID        int               `json:"schema-id"`
+	Summary         map[string]string `json:"summary"`
+	Representations []struct {
+		Type    string `json:"type"`
+		SQL     string `json:"sql"`
+		Dialect string `json:"dialect"`
+	} `json:"representations"`
+	DefaultCatalog   string   `json:"default-catalog"`
+	DefaultNamespace []string `json:"default-namespace"`
+}
+
+type createViewRequest struct {
+	Name        string             `json:"name"`
+	Schema      *iceberg.Schema    `json:"schema"`
+	Location    string             `json:"location,omitempty"`
+	Props       iceberg.Properties `json:"properties,omitempty"`
+	SQL         string             `json:"sql"`
+	ViewVersion viewVersion        `json:"view-version"`
+}
+
+type viewResponse struct {
+	MetadataLoc string             `json:"metadata-location"`
+	RawMetadata json.RawMessage    `json:"metadata"`
+	Config      iceberg.Properties `json:"config"`
+	Metadata    table.Metadata     `json:"-"`
+}
+
+// CreateView creates a new view in the catalog.
+func (r *Catalog) CreateView(ctx context.Context, identifier table.Identifier, schema *iceberg.Schema, sql string, props iceberg.Properties) error {
+	ns, view, err := splitIdentForPath(identifier)
+	if err != nil {
+		return err
+	}
+
+	freshSchema, err := iceberg.AssignFreshSchemaIDs(schema, nil)
+	if err != nil {
+		return err
+	}
+
+	payload := createViewRequest{
+		Name:   view,
+		Schema: freshSchema,
+		SQL:    sql,
+		Props:  props,
+		ViewVersion: viewVersion{
+			VersionID:   1,
+			TimestampMs: time.Now().UnixMilli(),
+			SchemaID:    freshSchema.ID,
+			Summary:     map[string]string{"sql": sql},
+			Representations: []struct {
+				Type    string `json:"type"`
+				SQL     string `json:"sql"`
+				Dialect string `json:"dialect"`
+			}{
+				{Type: "sql", SQL: sql, Dialect: "default"},
+			},
+			DefaultCatalog:   r.name,
+			DefaultNamespace: strings.Split(ns, namespaceSeparator),
+		},
+	}
+
+	_, err = doPost[createViewRequest, viewResponse](ctx, r.baseURI, []string{"namespaces", ns, "views"}, payload,
+		r.cl, map[int]error{
+			http.StatusNotFound: catalog.ErrNoSuchNamespace,
+			http.StatusConflict: catalog.ErrViewAlreadyExists,
+		})
+	if err != nil {
path: catalog/rest/rest.go
user: login: zeroshade
body: just `return err` ?
created_at: 2025-04-07T15:02:00Z
author_association: MEMBER
diff_hunk: @@ -1156,3 +1156,80 @@ func (r *Catalog) CheckViewExists(ctx context.Context, identifier table.Identifi
 
 	return true, nil
 }
+
+type viewVersion struct {
+	VersionID       int64             `json:"version-id"`
+	TimestampMs     int64             `json:"timestamp-ms"`
+	SchemaID        int               `json:"schema-id"`
+	Summary         map[string]string `json:"summary"`
+	Representations []struct {
+		Type    string `json:"type"`
+		SQL     string `json:"sql"`
+		Dialect string `json:"dialect"`
+	} `json:"representations"`
+	DefaultCatalog   string   `json:"default-catalog"`
+	DefaultNamespace []string `json:"default-namespace"`
+}
+
+type createViewRequest struct {
+	Name        string             `json:"name"`
+	Schema      *iceberg.Schema    `json:"schema"`
+	Location    string             `json:"location,omitempty"`
+	Props       iceberg.Properties `json:"properties,omitempty"`
+	SQL         string             `json:"sql"`
+	ViewVersion viewVersion        `json:"view-version"`
+}
+
+type viewResponse struct {
+	MetadataLoc string             `json:"metadata-location"`
+	RawMetadata json.RawMessage    `json:"metadata"`
+	Config      iceberg.Properties `json:"config"`
+	Metadata    table.Metadata     `json:"-"`
+}
+
+// CreateView creates a new view in the catalog.
+func (r *Catalog) CreateView(ctx context.Context, identifier table.Identifier, schema *iceberg.Schema, sql string, props iceberg.Properties) error {
+	ns, view, err := splitIdentForPath(identifier)
+	if err != nil {
+		return err
+	}
+
+	freshSchema, err := iceberg.AssignFreshSchemaIDs(schema, nil)
+	if err != nil {
+		return err
+	}
+
+	payload := createViewRequest{
+		Name:   view,
+		Schema: freshSchema,
+		SQL:    sql,
+		Props:  props,
+		ViewVersion: viewVersion{
+			VersionID:   1,
+			TimestampMs: time.Now().UnixMilli(),
+			SchemaID:    freshSchema.ID,
+			Summary:     map[string]string{"sql": sql},
+			Representations: []struct {
+				Type    string `json:"type"`
+				SQL     string `json:"sql"`
+				Dialect string `json:"dialect"`
+			}{
+				{Type: "sql", SQL: sql, Dialect: "default"},
+			},
+			DefaultCatalog:   r.name,
+			DefaultNamespace: strings.Split(ns, namespaceSeparator),
+		},
+	}
+
+	_, err = doPost[createViewRequest, viewResponse](ctx, r.baseURI, []string{"namespaces", ns, "views"}, payload,
+		r.cl, map[int]error{
+			http.StatusNotFound: catalog.ErrNoSuchNamespace,
+			http.StatusConflict: catalog.ErrViewAlreadyExists,
+		})
+	if err != nil {
+		return err
+	}
+	fmt.Println("created view", identifier)
path: catalog/rest/rest.go
user: login: dttung2905
body: Fixed. Thank you for the quick review 
created_at: 2025-04-07T16:27:46Z
author_association: CONTRIBUTOR

PR comments:
user: login: zeroshade
created_at: 2025-04-07T15:01:09Z
body: I'd love it if you pursued implementing the View objects. Please create `view` as a separate package from table though. 
author_association: MEMBER
user: login: dttung2905
created_at: 2025-04-07T16:29:04Z
body: > I'd love it if you pursued implementing the View objects. Please create `view` as a separate package from table though.

Sure. I will try to find some time this week to implement it.
author_association: CONTRIBUTOR
user: login: dttung2905
created_at: 2025-04-07T17:15:03Z
body: > This looks good. As a separate PR can we look into adding a test to the rest_integration_test.go file to ensure we have integration tests with the rest catalog?

That makes total sense as well
author_association: CONTRIBUTOR