Repository: apache/iceberg-go
Pull request #382 (2025-04-07 04:58:23)

fix(table): for add partition spec constant
`addPartitionSpecAction` should be `add-spec` and not `add-partition-spec`.


Patch:
diff --git a/README.md b/README.md
index 9558a00..6fc4802 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 6b2118f..f2b842d 100644
--- a/catalog/catalog.go
+++ b/catalog/catalog.go
@@ -36,7 +36,6 @@ import (
 	"strings"
 
 	"github.com/apache/iceberg-go"
-	"github.com/apache/iceberg-go/catalog/internal"
 	iceinternal "github.com/apache/iceberg-go/internal"
 	"github.com/apache/iceberg-go/table"
 )
@@ -60,6 +59,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 {
@@ -68,6 +68,14 @@ type PropertiesUpdateSummary struct {
 	Missing []string `json:"missing"`
 }
 
+// CreateTableCfg represents the configuration used for CreateTable operations
+type CreateTableCfg struct {
+	Location      string
+	PartitionSpec *iceberg.PartitionSpec
+	SortOrder     table.SortOrder
+	Properties    iceberg.Properties
+}
+
 // Catalog for iceberg table operations like create, drop, load, list and others.
 type Catalog interface {
 	// CatalogType returns the type of the catalog.
@@ -128,28 +136,28 @@ func NamespaceFromIdent(ident table.Identifier) table.Identifier {
 	return ident[:len(ident)-1]
 }
 
-type CreateTableOpt func(*internal.CreateTableCfg)
+type CreateTableOpt func(*CreateTableCfg)
 
 func WithLocation(location string) CreateTableOpt {
-	return func(cfg *internal.CreateTableCfg) {
+	return func(cfg *CreateTableCfg) {
 		cfg.Location = strings.TrimRight(location, "/")
 	}
 }
 
 func WithPartitionSpec(spec *iceberg.PartitionSpec) CreateTableOpt {
-	return func(cfg *internal.CreateTableCfg) {
+	return func(cfg *CreateTableCfg) {
 		cfg.PartitionSpec = spec
 	}
 }
 
 func WithSortOrder(order table.SortOrder) CreateTableOpt {
-	return func(cfg *internal.CreateTableCfg) {
+	return func(cfg *CreateTableCfg) {
 		cfg.SortOrder = order
 	}
 }
 
 func WithProperties(props iceberg.Properties) CreateTableOpt {
-	return func(cfg *internal.CreateTableCfg) {
+	return func(cfg *CreateTableCfg) {
 		cfg.Properties = props
 	}
 }
diff --git a/catalog/glue/glue.go b/catalog/glue/glue.go
index 8e2bc13..1313a1c 100644
--- a/catalog/glue/glue.go
+++ b/catalog/glue/glue.go
@@ -27,7 +27,6 @@ import (
 
 	"github.com/apache/iceberg-go"
 	"github.com/apache/iceberg-go/catalog"
-	"github.com/apache/iceberg-go/catalog/internal"
 	"github.com/apache/iceberg-go/io"
 	"github.com/apache/iceberg-go/table"
 	"github.com/apache/iceberg-go/utils"
@@ -236,7 +235,7 @@ func (c *Catalog) CreateTable(ctx context.Context, identifier table.Identifier,
 	if err != nil {
 		return nil, err
 	}
-	var cfg internal.CreateTableCfg
+	var cfg catalog.CreateTableCfg
 	for _, opt := range opts {
 		opt(&cfg)
 	}
diff --git a/catalog/internal/utils.go b/catalog/internal/utils.go
index 8ae00a2..204f7ea 100644
--- a/catalog/internal/utils.go
+++ b/catalog/internal/utils.go
@@ -32,13 +32,6 @@ import (
 	"github.com/google/uuid"
 )
 
-type CreateTableCfg struct {
-	Location      string
-	PartitionSpec *iceberg.PartitionSpec
-	SortOrder     table.SortOrder
-	Properties    iceberg.Properties
-}
-
 func GetMetadataLoc(location string, newVersion uint) string {
 	return fmt.Sprintf("%s/metadata/%05d-%s.metadata.json",
 		location, newVersion, uuid.New().String())
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 133ae4c..f6e66e7 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -37,7 +37,6 @@ import (
 
 	"github.com/apache/iceberg-go"
 	"github.com/apache/iceberg-go/catalog"
-	"github.com/apache/iceberg-go/catalog/internal"
 	iceio "github.com/apache/iceberg-go/io"
 	"github.com/apache/iceberg-go/table"
 	"github.com/aws/aws-sdk-go-v2/aws"
@@ -735,7 +734,7 @@ func (r *Catalog) CreateTable(ctx context.Context, identifier table.Identifier,
 		return nil, err
 	}
 
-	var cfg internal.CreateTableCfg
+	var cfg catalog.CreateTableCfg
 	for _, o := range opts {
 		o(&cfg)
 	}
@@ -1156,3 +1155,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/sql/sql.go b/catalog/sql/sql.go
index 34593c0..20afc3a 100644
--- a/catalog/sql/sql.go
+++ b/catalog/sql/sql.go
@@ -292,7 +292,7 @@ func checkValidNamespace(ident table.Identifier) error {
 }
 
 func (c *Catalog) CreateTable(ctx context.Context, ident table.Identifier, sc *iceberg.Schema, opts ...catalog.CreateTableOpt) (*table.Table, error) {
-	var cfg internal.CreateTableCfg
+	var cfg catalog.CreateTableCfg
 	for _, opt := range opts {
 		opt(&cfg)
 	}
diff --git a/table/metadata.go b/table/metadata.go
index 44986d5..bf53f29 100644
--- a/table/metadata.go
+++ b/table/metadata.go
@@ -37,11 +37,6 @@ import (
 const (
 	partitionFieldStartID       = 1000
 	supportedTableFormatVersion = 2
-
-	addPartionSpecAction = "add-partition-spec"
-	addSchemaAction      = "add-schema"
-	addSnapshotAction    = "add-snapshot"
-	addSortOrderAction   = "add-sort-order"
 )
 
 func generateSnapshotID() int64 {
@@ -377,7 +372,7 @@ func (b *MetadataBuilder) SetCurrentSchemaID(currentSchemaID int) (*MetadataBuil
 			return s.ID
 		})
 		if !slices.ContainsFunc(b.updates, func(u Update) bool {
-			return u.Action() == addSchemaAction && u.(*addSchemaUpdate).Schema.ID == currentSchemaID
+			return u.Action() == updateAddSchema && u.(*addSchemaUpdate).Schema.ID == currentSchemaID
 		}) {
 			return nil, errors.New("can't set current schema to last added schema, no schema has been added")
 		}
@@ -404,7 +399,7 @@ func (b *MetadataBuilder) SetDefaultSortOrderID(defaultSortOrderID int) (*Metada
 			return s.OrderID
 		})
 		if !slices.ContainsFunc(b.updates, func(u Update) bool {
-			return u.Action() == addSortOrderAction && u.(*addSortOrderUpdate).SortOrder.OrderID == defaultSortOrderID
+			return u.Action() == updateSortOrder && u.(*addSortOrderUpdate).SortOrder.OrderID == defaultSortOrderID
 		}) {
 			return nil, errors.New("can't set default sort order to last added with no added sort orders")
 		}
@@ -430,7 +425,7 @@ func (b *MetadataBuilder) SetDefaultSpecID(defaultSpecID int) (*MetadataBuilder,
 			return s.ID()
 		})
 		if !slices.ContainsFunc(b.updates, func(u Update) bool {
-			return u.Action() == addPartionSpecAction && u.(*addPartitionSpecUpdate).Spec.ID() == defaultSpecID
+			return u.Action() == updateSpec && u.(*addPartitionSpecUpdate).Spec.ID() == defaultSpecID
 		}) {
 			return nil, errors.New("can't set default spec to last added with no added partition specs")
 		}
@@ -569,7 +564,7 @@ func (b *MetadataBuilder) SetSnapshotRef(
 	}
 
 	isAddedSnapshot := slices.ContainsFunc(b.updates, func(u Update) bool {
-		return u.Action() == addSnapshotAction && u.(*addSnapshotUpdate).Snapshot.SnapshotID == snapshotID
+		return u.Action() == updateSnapshot && u.(*addSnapshotUpdate).Snapshot.SnapshotID == snapshotID
 	})
 	if isAddedSnapshot {
 		b.lastUpdatedMS = snapshot.TimestampMs
@@ -588,7 +583,7 @@ func (b *MetadataBuilder) SetSnapshotRef(
 	}
 
 	if slices.ContainsFunc(b.updates, func(u Update) bool {
-		return u.Action() == addSnapshotAction && u.(*addSnapshotUpdate).Snapshot.SnapshotID == snapshotID
+		return u.Action() == updateSnapshot && u.(*addSnapshotUpdate).Snapshot.SnapshotID == snapshotID
 	}) {
 		b.lastUpdatedMS = snapshot.TimestampMs
 	}
diff --git a/table/updates.go b/table/updates.go
index 4225a40..a2e5228 100644
--- a/table/updates.go
+++ b/table/updates.go
@@ -27,6 +27,7 @@ import (
 const (
 	updateSpec             = "add-spec"
 	updateAddSchema        = "add-schema"
+	updateSnapshot         = "add-snapshot"
 	updateSortOrder        = "add-sort-order"
 	updateAssignUUID       = "assign-uuid"
 	updateDefaultSpec      = "set-default-spec"
@@ -231,7 +232,7 @@ type addSnapshotUpdate struct {
 // NewAddSnapshotUpdate creates a new update that adds the given snapshot to the table metadata.
 func NewAddSnapshotUpdate(snapshot *Snapshot) Update {
 	return &addSnapshotUpdate{
-		baseUpdate: baseUpdate{ActionName: "add-snapshot"},
+		baseUpdate: baseUpdate{ActionName: updateSnapshot},
 		Snapshot:   snapshot,
 	}
 }


Test patch:
diff --git a/catalog/rest/rest_test.go b/catalog/rest/rest_test.go
index 93ffebf..a6c836f 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)
+}
diff --git a/table/metadata_internal_test.go b/table/metadata_internal_test.go
index 9e33678..dcbfacd 100644
--- a/table/metadata_internal_test.go
+++ b/table/metadata_internal_test.go
@@ -749,3 +749,17 @@ func TestMetadataV2Serialize(t *testing.T) {
 		"default-sort-order-id":0
 	}`, string(data))
 }
+
+func TestMetadataBuilderSetDefaultSpecIDLastPartition(t *testing.T) {
+	builder, err := NewMetadataBuilder()
+	assert.NoError(t, err)
+
+	partitionSpec := iceberg.NewPartitionSpecID(0)
+	_, err = builder.AddPartitionSpec(&partitionSpec, false)
+	assert.NoError(t, err)
+
+	_, err = builder.SetDefaultSpecID(-1)
+	assert.NoError(t, err)
+
+	assert.Equal(t, 0, builder.defaultSpecID)
+}