package iceberg import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "path" "sort" "strings" "testing" "github.com/apache/iceberg-go" "github.com/apache/iceberg-go/table" "github.com/apache/iceberg-go/view" "github.com/gorilla/mux" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" "google.golang.org/grpc" ) // memFiler is a minimal in-memory SeaweedFilerClient backing the view handlers // end-to-end through the s3tables manager. Only the entry operations the create // path touches are implemented; anything else panics so a missing dependency is // loud rather than silently passing. type memFiler struct { filer_pb.SeaweedFilerClient entries map[string]*filer_pb.Entry // failFileCreate, when set, makes CreateEntry fail for non-directory entries, // simulating a metadata-file write error. failFileCreate error } func newMemFiler() *memFiler { return &memFiler{entries: map[string]*filer_pb.Entry{}} } func (m *memFiler) WithFilerClient(_ bool, fn func(client filer_pb.SeaweedFilerClient) error) error { return fn(m) } func (m *memFiler) seed(p string, entry *filer_pb.Entry) { m.entries[p] = entry } func (m *memFiler) LookupDirectoryEntry(_ context.Context, in *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) { entry, ok := m.entries[path.Join(in.Directory, in.Name)] if !ok { return nil, filer_pb.ErrNotFound } return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil } func (m *memFiler) CreateEntry(_ context.Context, in *filer_pb.CreateEntryRequest, _ ...grpc.CallOption) (*filer_pb.CreateEntryResponse, error) { if m.failFileCreate != nil && !in.Entry.IsDirectory { return nil, m.failFileCreate } m.entries[path.Join(in.Directory, in.Entry.Name)] = in.Entry return &filer_pb.CreateEntryResponse{}, nil } func (m *memFiler) DeleteEntry(_ context.Context, in *filer_pb.DeleteEntryRequest, _ ...grpc.CallOption) (*filer_pb.DeleteEntryResponse, error) { prefix := path.Join(in.Directory, in.Name) for p := range m.entries { if p == prefix || strings.HasPrefix(p, prefix+"/") { delete(m.entries, p) } } return &filer_pb.DeleteEntryResponse{}, nil } func (m *memFiler) UpdateEntry(_ context.Context, in *filer_pb.UpdateEntryRequest, _ ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) { m.entries[path.Join(in.Directory, in.Entry.Name)] = in.Entry return &filer_pb.UpdateEntryResponse{}, nil } func (m *memFiler) ListEntries(_ context.Context, in *filer_pb.ListEntriesRequest, _ ...grpc.CallOption) (filer_pb.SeaweedFiler_ListEntriesClient, error) { var names []string for p := range m.entries { if path.Dir(p) == in.Directory { names = append(names, p) } } sort.Strings(names) stream := &memListEntries{} for _, name := range names { stream.entries = append(stream.entries, m.entries[name]) } return stream, nil } type memListEntries struct { grpc.ClientStream entries []*filer_pb.Entry } func (m *memListEntries) Recv() (*filer_pb.ListEntriesResponse, error) { if len(m.entries) == 0 { return nil, io.EOF } entry := m.entries[0] m.entries = m.entries[1:] return &filer_pb.ListEntriesResponse{Entry: entry}, nil } func newCreateViewRequest(t *testing.T, namespace, name, sql string) *http.Request { t.Helper() schema := iceberg.NewSchemaWithIdentifiers(0, nil, iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int32, Required: true}, ) ver, err := view.NewVersionFromSQL(1, schema.ID, sql, table.Identifier{namespace}) if err != nil { t.Fatal(err) } body, err := json.Marshal(CreateViewRequest{Name: name, Schema: schema, ViewVersion: ver}) if err != nil { t.Fatal(err) } r := httptest.NewRequest(http.MethodPost, "/v1/namespaces/"+namespace+"/views", bytes.NewReader(body)) r = mux.SetURLVars(r, map[string]string{"namespace": namespace}) r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), s3_constants.AccountAdminId)) return r } // seedNamespace registers a bucket and namespace owned by owner so the s3tables // existence and auth-context lookups pass. func seedNamespace(fc *memFiler, bucket, namespace, owner string) { fc.seed(s3tables.GetTableBucketPath(bucket), &filer_pb.Entry{Name: bucket, IsDirectory: true}) meta, _ := json.Marshal(map[string]any{"namespace": []string{namespace}, "ownerAccountId": owner}) fc.seed(s3tables.GetNamespacePath(bucket, namespace), &filer_pb.Entry{ Name: namespace, IsDirectory: true, Extended: map[string][]byte{s3tables.ExtendedKeyMetadata: meta}, }) } func TestCreateViewMissingNamespaceReturns404(t *testing.T) { fc := newMemFiler() s := NewServer(fc, nil) w := httptest.NewRecorder() s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1")) if w.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusNotFound, w.Body.String()) } var errResp ErrorResponse if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil { t.Fatalf("unmarshal error body: %v", err) } if errResp.Error.Type != "NoSuchNamespaceException" { t.Fatalf("error type = %q, want NoSuchNamespaceException", errResp.Error.Type) } } func TestCreateViewTagsEntryAsView(t *testing.T) { const bucket = "warehouse" fc := newMemFiler() seedNamespace(fc, bucket, "ns", s3_constants.AccountAdminId) s := NewServer(fc, nil) w := httptest.NewRecorder() s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1")) if w.Code != http.StatusOK { t.Fatalf("create status = %d, want 200 (body: %s)", w.Code, w.Body.String()) } entry, ok := fc.entries[s3tables.GetTablePath(bucket, "ns", "v")] if !ok { t.Fatalf("view entry not created") } if got := string(entry.Extended[s3tables.ExtendedKeyEntryType]); got != s3tables.EntryTypeView { t.Fatalf("entryType = %q, want %q", got, s3tables.EntryTypeView) } if _, ok := entry.Extended[s3tables.ExtendedKeyMetadata]; !ok { t.Fatalf("view entry missing metadata attribute") } } func TestCreateViewDuplicateDoesNotClobberMetadata(t *testing.T) { const bucket = "warehouse" fc := newMemFiler() seedNamespace(fc, bucket, "ns", s3_constants.AccountAdminId) s := NewServer(fc, nil) w := httptest.NewRecorder() s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1")) if w.Code != http.StatusOK { t.Fatalf("first create status = %d, want 200 (body: %s)", w.Code, w.Body.String()) } metadataKey := path.Join(s3tables.GetTablePath(bucket, "ns", "v"), "metadata", "v1.metadata.json") first, ok := fc.entries[metadataKey] if !ok { t.Fatalf("metadata file not written at %s", metadataKey) } original := append([]byte(nil), first.Content...) // Re-create the same view with different SQL: the existence pre-check must // short-circuit so the persisted v1.metadata.json stays byte-for-byte intact. w = httptest.NewRecorder() s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 2")) if w.Code != http.StatusOK { t.Fatalf("duplicate create status = %d, want 200 (body: %s)", w.Code, w.Body.String()) } if got := fc.entries[metadataKey].Content; !bytes.Equal(got, original) { t.Fatalf("duplicate create clobbered stored metadata:\n got %s\nwant %s", got, original) } } func TestCreateViewRollsBackEntryWhenMetadataWriteFails(t *testing.T) { const bucket = "warehouse" fc := newMemFiler() seedNamespace(fc, bucket, "ns", s3_constants.AccountAdminId) fc.failFileCreate = errors.New("disk full") s := NewServer(fc, nil) w := httptest.NewRecorder() s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1")) if w.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusInternalServerError, w.Body.String()) } // The registered view must be rolled back so it doesn't linger pointing at // metadata that was never written. if _, ok := fc.entries[s3tables.GetTablePath(bucket, "ns", "v")]; ok { t.Fatalf("view entry left behind after metadata write failure") } }