diff --git a/server/cmd/db-migrations/common.go b/server/cmd/db-migrations/common.go new file mode 100644 index 0000000000..fea1f5f88d --- /dev/null +++ b/server/cmd/db-migrations/common.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// Entity is a generic interface for types that can be batch updated +type Entity interface { + GetID() primitive.ObjectID +} + +// BatchUpdate processes documents from a MongoDB collection in batches and applies the given update function to each document +func BatchUpdate[T Entity](ctx context.Context, col *mongo.Collection, filter bson.M, batchSize int, fn func(item T) (T, error)) (int, error) { + opts := options.Find(). + SetBatchSize(int32(batchSize)). + SetNoCursorTimeout(true) + + cursor, err := col.Find(ctx, filter, opts) + if err != nil { + return 0, fmt.Errorf("failed to query collection: %w", err) + } + defer cursor.Close(ctx) + + // Process documents in batches + batch := make([]mongo.WriteModel, 0, batchSize) + processed := 0 + startTime := time.Now() + + for cursor.Next(ctx) { + if ctx.Err() != nil { + return 0, ctx.Err() + } + + var item T + if err := cursor.Decode(&item); err != nil { + return 0, fmt.Errorf("failed to decode document: %w", err) + } + + // Apply the process function to the document + updatedItem, err := fn(item) + if err != nil { + return 0, fmt.Errorf("failed to process document: %w", err) + } + + // Create an update model + update := mongo.NewUpdateOneModel(). + SetFilter(bson.M{"_id": item.GetID()}). + SetUpdate(bson.M{"$set": updatedItem}) + + batch = append(batch, update) + processed++ + + // If we've reached the batch size, execute the bulk write + if len(batch) >= batchSize { + if err := executeBatch(ctx, col, batch); err != nil { + return 0, err + } + + // Log progress + elapsed := time.Since(startTime) + rate := float64(processed) / elapsed.Seconds() + fmt.Printf("Processed %d documents (%.2f docs/sec)\n", processed, rate) + + // Clear the batch + batch = batch[:0] + } + } + + // Process any remaining documents in the final batch + if len(batch) > 0 { + if err := executeBatch(ctx, col, batch); err != nil { + return 0, err + } + } + + // Check for any cursor errors + if err := cursor.Err(); err != nil { + return 0, fmt.Errorf("cursor error: %w", err) + } + + // Log final stats + elapsed := time.Since(startTime) + rate := float64(processed) / elapsed.Seconds() + fmt.Printf("Completed processing %d documents in %v (%.2f docs/sec)\n", processed, elapsed, rate) + + return processed, nil +} + +// executeBatch performs the bulk write operation for a batch of updates +func executeBatch(ctx context.Context, collection *mongo.Collection, batch []mongo.WriteModel) error { + // Define options for the bulk write operation + opts := options.BulkWrite().SetOrdered(false) + + // Execute the bulk write + res, err := collection.BulkWrite(ctx, batch, opts) + if err != nil { + return fmt.Errorf("bulk write failed: %w", err) + } + fmt.Printf("Bulk write result: size=%d matched=%d, modified=%d, upserted=%d\n", len(batch), res.MatchedCount, res.ModifiedCount, res.UpsertedCount) + + return nil +} diff --git a/server/cmd/db-migrations/item_migration.go b/server/cmd/db-migrations/item_migration.go new file mode 100644 index 0000000000..4b53b14082 --- /dev/null +++ b/server/cmd/db-migrations/item_migration.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "fmt" + + "github.com/samber/lo" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type ItemDocument struct { + ID string `bson:"_id,omitempty"` + Fields []ItemFieldDocument + Assets []string `bson:"assets,omitempty"` +} + +func (d ItemDocument) GetID() primitive.ObjectID { + id, err := primitive.ObjectIDFromHex(d.ID) + if err != nil { + fmt.Printf("failed to parse id: %v\n", d.ID) + return primitive.NilObjectID + } + return id +} + +type ItemFieldDocument struct { + F string `bson:"f,omitempty"` + V ValueDocument `bson:"v,omitempty"` + Field string `bson:"schemafield,omitempty"` // compat + ValueType string `bson:"valuetype,omitempty"` // compat + Value any `bson:"value,omitempty"` // compat +} + +type ValueDocument struct { + T string `bson:"t"` + V any `bson:"v"` +} + +func ItemMigration(ctx context.Context, dbURL, dbName string, wetRun bool) error { + testID := "" + + client, err := mongo.Connect(ctx, options.Client().ApplyURI(dbURL)) + if err != nil { + return fmt.Errorf("db: failed to init client err: %w", err) + } + col := client.Database(dbName).Collection("item") + + filter := bson.M{} + + if testID != "" { + filter = bson.M{ + "$and": []bson.M{ + {"id": testID}, + filter, + }, + } + count, err := col.CountDocuments(ctx, filter) + if err != nil { + return fmt.Errorf("failed to count docs: %w", err) + } + fmt.Printf("test mode: filter on item id '%s' is applyed, %d items selected.\n", testID, count) + } + + if !wetRun { + fmt.Printf("dry run\n") + count, err := col.CountDocuments(ctx, filter) + if err != nil { + return fmt.Errorf("failed to count docs: %w", err) + } + fmt.Printf("%d docs will be updated\n", count) + return nil + } + + _, err = BatchUpdate(ctx, col, filter, 1000, updateItem) + if err != nil { + return fmt.Errorf("failed to apply batches: %w", err) + } + + fmt.Printf("done.\n") + return nil +} + +func updateItem(item ItemDocument) (ItemDocument, error) { + updatedItem := ItemDocument{} + var assets []string + for _, f := range item.Fields { + if f.Field != "" { + f.F = f.Field + } + if f.ValueType != "" { + f.V.T = f.ValueType + } + if f.Value != nil { + f.V.V = f.Value + } + + // value should be an array, the value is always treated as multiple in db + if f.V.V != nil { + _, ok := f.V.V.(bson.A) + if !ok { + f.V.V = bson.A{f.V.V} + } + } + + // migrate old value: date to datetime + if f.V.T == "date" { + f.V.T = "datetime" + } + updatedItem.Fields = append(updatedItem.Fields, ItemFieldDocument{ + F: f.F, + V: ValueDocument{ + T: f.V.T, + V: f.V.V, + }, + }) + if f.V.T == "asset" && f.V.V != nil { + pa, ok := f.V.V.(bson.A) + if ok { + aa := lo.FilterMap(pa, func(v any, _ int) (string, bool) { + s, ok := v.(string) + if !ok { + fmt.Printf("failed to parse asset: asset=%v item=%v\n", v, item.ID) + } + return s, ok && s != "" + }) + assets = append(assets, aa...) + continue + } + fmt.Printf("failed to parse assets: assets=%v item=%v\n", f.V.V, item.ID) + } + } + if len(assets) > 0 { + updatedItem.Assets = lo.Uniq(assets) + } + + return updatedItem, nil +} diff --git a/server/cmd/db-migrations/item_migration_test.go b/server/cmd/db-migrations/item_migration_test.go new file mode 100644 index 0000000000..7f86654b17 --- /dev/null +++ b/server/cmd/db-migrations/item_migration_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/bson" +) + +func Test_updateItem(t *testing.T) { + tests := []struct { + name string + args ItemDocument + want ItemDocument + wantErr error + }{ + { + name: "date to datetime", + args: ItemDocument{ + Fields: []ItemFieldDocument{ + { + F: "id1", + V: ValueDocument{ + T: "date", + V: bson.A{"2021-01-01"}, + }, + }, + }, + }, + want: ItemDocument{ + Fields: []ItemFieldDocument{ + { + F: "id1", + V: ValueDocument{ + T: "datetime", + V: bson.A{"2021-01-01"}, + }, + }, + }, + }, + wantErr: nil, + }, + { + name: "value should be stored as array", + args: ItemDocument{ + Fields: []ItemFieldDocument{ + { + F: "id1", + V: ValueDocument{ + T: "number", + V: 123, + }, + }, + }, + }, + want: ItemDocument{ + Fields: []ItemFieldDocument{ + { + F: "id1", + V: ValueDocument{ + T: "number", + V: bson.A{123}, + }, + }, + }, + }, + wantErr: nil, + }, + { + name: "old structure to new structure", + args: ItemDocument{ + Fields: []ItemFieldDocument{ + { + Field: "id1", + ValueType: "number", + Value: 123, + F: "", + V: ValueDocument{ + T: "", + V: nil, + }, + }, + }, + }, + want: ItemDocument{ + Fields: []ItemFieldDocument{ + { + F: "id1", + V: ValueDocument{ + T: "number", + V: bson.A{123}, + }, + }, + }, + }, + wantErr: nil, + }, + { + name: "assets ids to flat array", + args: ItemDocument{ + Fields: []ItemFieldDocument{ + { + F: "id1", + V: ValueDocument{ + T: "asset", + V: "aid1", + }, + }, + { + F: "id2", + V: ValueDocument{ + T: "asset", + V: bson.A{"aid2", "aid3"}, + }, + }, + }, + }, + want: ItemDocument{ + Fields: []ItemFieldDocument{ + { + F: "id1", + V: ValueDocument{ + T: "asset", + V: bson.A{"aid1"}, + }, + }, + { + F: "id2", + V: ValueDocument{ + T: "asset", + V: bson.A{"aid2", "aid3"}, + }, + }, + }, + Assets: []string{"aid1", "aid2", "aid3"}, + }, + wantErr: nil, + }, + { + name: "assets ids to flat array with duplicate", + args: ItemDocument{ + Fields: []ItemFieldDocument{ + { + F: "id1", + V: ValueDocument{ + T: "asset", + V: "aid1", + }, + }, + { + F: "id2", + V: ValueDocument{ + T: "asset", + V: bson.A{"aid1", "aid2", "aid3"}, + }, + }, + }, + }, + want: ItemDocument{ + Fields: []ItemFieldDocument{ + { + F: "id1", + V: ValueDocument{ + T: "asset", + V: bson.A{"aid1"}, + }, + }, + { + F: "id2", + V: ValueDocument{ + T: "asset", + V: bson.A{"aid1", "aid2", "aid3"}, + }, + }, + }, + Assets: []string{"aid1", "aid2", "aid3"}, + }, + wantErr: nil, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := updateItem(tt.args) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + return + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/server/cmd/db-migrations/main.go b/server/cmd/db-migrations/main.go index 004bde31dc..93972cbd4b 100644 --- a/server/cmd/db-migrations/main.go +++ b/server/cmd/db-migrations/main.go @@ -13,6 +13,7 @@ type command = func(ctx context.Context, dbURL, dbName string, wetRun bool) erro var commands = map[string]command{ "ref-field-schema": RefFieldSchema, + "item-migration": ItemMigration, } func main() { diff --git a/server/e2e/integration_item_import_test.go b/server/e2e/integration_item_import_test.go index 02e047f96c..06fa343c90 100644 --- a/server/e2e/integration_item_import_test.go +++ b/server/e2e/integration_item_import_test.go @@ -208,14 +208,15 @@ func TestIntegrationModelImportJSONWithGeoJsonInput(t *testing.T) { func TestIntegrationModelImportJSONWithJsonInput1(t *testing.T) { e := StartServer(t, &app.Config{}, true, baseSeederUser) - // region strategy="insert" and mutateSchema=false pId, _ := createProject(e, wId.String(), "test", "test", "test-1") mId, _ := createModel(e, pId, "test", "test", "test-1") createFieldOfEachType(t, e, mId) // 3 items with predefined fields - jsonContent := `[{"text": "test1", "bool": true, "number": 1.1},{"text": "test2", "bool": false, "number": 2},{"text": "test3", "bool": null, "number": null}]` + jsonContent := `[{"text": "test1", "bool": true, "number": 1.1, "text2": null},{"text": "test2", "bool": false, "number": 2},{"text": "test3", "bool": null, "number": null}]` aId := uploadAsset(e, pId, "./test1.json", jsonContent).Object().Value("id").String().Raw() + + // region strategy="insert" and mutateSchema=false res := IntegrationModelImportJSON(e, mId, aId, "json", "insert", false, nil) res.Object().IsEqual(map[string]any{ "modelId": mId, @@ -265,9 +266,10 @@ func TestIntegrationModelImportJSONWithJsonInput1(t *testing.T) { "updatedCount": 0, "ignoredCount": 0, }) - res.Object().Value("newFields").Array().Length().IsEqual(3) + res.Object().Value("newFields").Array().Length().IsEqual(4) // insure the same order of fields - res.Path("$.newFields[:].type").Array().IsEqual([]string{"text", "bool", "number"}) + res.Path("$.newFields[:].key").Array().IsEqual([]string{"text", "bool", "number", "text2"}) + res.Path("$.newFields[:].type").Array().IsEqual([]string{"text", "bool", "number", "text"}) obj = e.GET("/api/models/{modelId}/items", mId). // WithHeader("authorization", "Bearer "+secret). diff --git a/server/internal/adapter/publicapi/controller.go b/server/internal/adapter/publicapi/controller.go index c64e133b53..47c2417fc2 100644 --- a/server/internal/adapter/publicapi/controller.go +++ b/server/internal/adapter/publicapi/controller.go @@ -30,7 +30,7 @@ func NewController(project repo.Project, usecases *interfaces.Container, aur ass } func (c *Controller) checkProject(ctx context.Context, prj string) (*project.Project, error) { - pr, err := c.project.FindByPublicName(ctx, prj) + pr, err := c.project.FindByIDOrAlias(ctx, project.IDOrAlias(prj)) if err != nil { if errors.Is(err, rerror.ErrNotFound) { return nil, rerror.ErrNotFound diff --git a/server/internal/infrastructure/memory/project.go b/server/internal/infrastructure/memory/project.go index 4d92522c40..cc37052f56 100644 --- a/server/internal/infrastructure/memory/project.go +++ b/server/internal/infrastructure/memory/project.go @@ -110,26 +110,26 @@ func (r *Project) FindByIDOrAlias(_ context.Context, q project.IDOrAlias) (*proj return nil, rerror.ErrNotFound } -func (r *Project) FindByPublicName(_ context.Context, name string) (*project.Project, error) { +func (r *Project) IsAliasAvailable(_ context.Context, name string) (bool, error) { if r.err != nil { - return nil, r.err + return false, r.err } if name == "" { - return nil, nil + return false, nil } + // no need to filter by workspace, because alias is unique across all workspaces p := r.data.Find(func(_ id.ProjectID, v *project.Project) bool { - return v.Alias() == name && r.f.CanRead(v.Workspace()) + return v.Alias() == name }) if p != nil { - return p, nil + return false, nil } - return nil, rerror.ErrNotFound + return true, nil } - func (r *Project) FindByPublicAPIToken(ctx context.Context, token string) (*project.Project, error) { if r.err != nil { return nil, r.err diff --git a/server/internal/infrastructure/memory/project_test.go b/server/internal/infrastructure/memory/project_test.go index c99b948a75..71acfe6a76 100644 --- a/server/internal/infrastructure/memory/project_test.go +++ b/server/internal/infrastructure/memory/project_test.go @@ -444,7 +444,7 @@ func TestProjectRepo_FindByIDs(t *testing.T) { } } -func TestProjectRepo_FindByPublicName(t *testing.T) { +func TestProjectRepo_IsAliasAvailable(t *testing.T) { mocknow := time.Now().Truncate(time.Millisecond).UTC() tid1 := accountdomain.NewWorkspaceID() id1 := id.NewProjectID() @@ -468,7 +468,7 @@ func TestProjectRepo_FindByPublicName(t *testing.T) { seeds project.List arg string filter *repo.WorkspaceFilter - want *project.Project + want bool wantErr error mockErr bool }{ @@ -477,8 +477,8 @@ func TestProjectRepo_FindByPublicName(t *testing.T) { seeds: project.List{}, arg: "xyz123", filter: nil, - want: nil, - wantErr: rerror.ErrNotFound, + want: true, + wantErr: nil, }, { name: "Not found", @@ -487,8 +487,8 @@ func TestProjectRepo_FindByPublicName(t *testing.T) { }, arg: "xyz123", filter: nil, - want: nil, - wantErr: rerror.ErrNotFound, + want: true, + wantErr: nil, }, { name: "public Found", @@ -497,16 +497,16 @@ func TestProjectRepo_FindByPublicName(t *testing.T) { }, arg: "xyz123", filter: nil, - want: p1, + want: false, wantErr: nil, }, { - name: "linited Found", + name: "limited Found", seeds: project.List{ p2, }, arg: "xyz321", - want: p2, + want: false, filter: nil, wantErr: nil, }, @@ -519,11 +519,11 @@ func TestProjectRepo_FindByPublicName(t *testing.T) { }, arg: "xyz123", filter: nil, - want: p1, + want: false, wantErr: nil, }, { - name: "Filtered should not Found", + name: "Filtered should Found", seeds: project.List{ p1, project.New().NewID().Workspace(accountdomain.NewWorkspaceID()).MustBuild(), @@ -531,8 +531,8 @@ func TestProjectRepo_FindByPublicName(t *testing.T) { }, arg: "xyz123", filter: &repo.WorkspaceFilter{Readable: []accountdomain.WorkspaceID{accountdomain.NewWorkspaceID()}, Writable: []accountdomain.WorkspaceID{}}, - want: nil, - wantErr: rerror.ErrNotFound, + want: false, + wantErr: nil, }, { name: "Filtered should Found", @@ -543,7 +543,7 @@ func TestProjectRepo_FindByPublicName(t *testing.T) { }, arg: "xyz123", filter: &repo.WorkspaceFilter{Readable: []accountdomain.WorkspaceID{tid1}, Writable: []accountdomain.WorkspaceID{}}, - want: p1, + want: false, wantErr: nil, }, { @@ -573,7 +573,7 @@ func TestProjectRepo_FindByPublicName(t *testing.T) { r = r.Filtered(*tc.filter) } - got, err := r.FindByPublicName(ctx, tc.arg) + got, err := r.IsAliasAvailable(ctx, tc.arg) if tc.wantErr != nil { assert.ErrorIs(t, err, tc.wantErr) return diff --git a/server/internal/infrastructure/mongo/mongodoc/item.go b/server/internal/infrastructure/mongo/mongodoc/item.go index bc948356cf..e3645c8404 100644 --- a/server/internal/infrastructure/mongo/mongodoc/item.go +++ b/server/internal/infrastructure/mongo/mongodoc/item.go @@ -34,9 +34,6 @@ type ItemDocument struct { type ItemFieldDocument struct { F string `bson:"f,omitempty"` V ValueDocument `bson:"v,omitempty"` - Field string `bson:"schemafield,omitempty"` // compat - ValueType string `bson:"valuetype,omitempty"` // compat - Value any `bson:"value,omitempty"` // compat ItemGroup *string } @@ -114,23 +111,10 @@ func (d *ItemDocument) Model() (*item.Item, error) { } fields, err := util.TryMap(d.Fields, func(f ItemFieldDocument) (*item.Field, error) { - // compat - if f.Field != "" { - f.F = f.Field - } - sf, err := item.FieldIDFrom(f.F) if err != nil { return nil, err } - - // compat - if f.ValueType != "" { - f.Value = ValueDocument{ - T: f.ValueType, - V: f.Value, - } - } ig := id.ItemGroupIDFromRef(f.ItemGroup) return item.NewField(sf, f.V.MultipleValue(), ig), nil }) diff --git a/server/internal/infrastructure/mongo/mongodoc/value.go b/server/internal/infrastructure/mongo/mongodoc/value.go index 6e971754ff..9a32acd7d6 100644 --- a/server/internal/infrastructure/mongo/mongodoc/value.go +++ b/server/internal/infrastructure/mongo/mongodoc/value.go @@ -1,34 +1,12 @@ package mongodoc import ( - "reflect" - "github.com/reearth/reearth-cms/server/pkg/value" ) type ValueDocument struct { T string `bson:"t"` - V any `bson:"v"` -} - -func NewValue(v *value.Value) *ValueDocument { - if v == nil { - return nil - } - return &ValueDocument{ - T: string(v.Type()), - V: v.Interface(), - } -} - -func NewOptionalValue(v *value.Optional) *ValueDocument { - if v == nil { - return nil - } - return &ValueDocument{ - T: string(v.Type()), - V: v.Value().Interface(), - } + V []any `bson:"v"` // value stored in db as multiple } func NewMultipleValue(v *value.Multiple) *ValueDocument { @@ -41,44 +19,11 @@ func NewMultipleValue(v *value.Multiple) *ValueDocument { } } -func (d *ValueDocument) Value() *value.Value { - if d == nil { - return nil - } - - // compat - if d.T == "date" { - d.T = string(value.TypeDateTime) - } - - return value.New(value.Type(d.T), d.V) -} - -func (d *ValueDocument) OptionalValue() *value.Optional { - if d == nil { - return nil - } - return value.OptionalFrom(d.Value()) -} - func (d *ValueDocument) MultipleValue() *value.Multiple { if d == nil { return nil } - if d.T == "date" { - d.T = string(value.TypeDateTime) - } - t := value.Type(d.T) - return value.NewMultiple(t, unpackArray(d.V)) -} - -func unpackArray(s any) []any { - v := reflect.ValueOf(s) - r := make([]any, v.Len()) - for i := 0; i < v.Len(); i++ { - r[i] = v.Index(i).Interface() - } - return r + return value.NewMultiple(t, d.V) } diff --git a/server/internal/infrastructure/mongo/mongodoc/value_test.go b/server/internal/infrastructure/mongo/mongodoc/value_test.go index 4507cb2268..f92c15cef1 100644 --- a/server/internal/infrastructure/mongo/mongodoc/value_test.go +++ b/server/internal/infrastructure/mongo/mongodoc/value_test.go @@ -7,25 +7,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewValue(t *testing.T) { - assert.Nil(t, NewValue(nil)) - assert.Equal(t, &ValueDocument{ - T: "bool", - V: true, - }, NewValue(value.TypeBool.Value(true))) -} - -func TestNewOptionalValue(t *testing.T) { - assert.Nil(t, NewOptionalValue(nil)) - assert.Equal(t, &ValueDocument{ - T: "bool", - }, NewOptionalValue(value.TypeBool.None())) - assert.Equal(t, &ValueDocument{ - T: "bool", - V: true, - }, NewOptionalValue(value.TypeBool.Value(true).Some())) -} - func TestNewMultipleValue(t *testing.T) { assert.Nil(t, NewMultipleValue(nil)) assert.Equal(t, &ValueDocument{ diff --git a/server/internal/infrastructure/mongo/project.go b/server/internal/infrastructure/mongo/project.go index 97b8962377..fa3dc0a114 100644 --- a/server/internal/infrastructure/mongo/project.go +++ b/server/internal/infrastructure/mongo/project.go @@ -17,7 +17,7 @@ import ( var ( // TODO: the `publication.token` should be unique, this should be fixed in the future - projectIndexes = []string{"workspace", "publication.token"} + projectIndexes = []string{"workspace"} projectUniqueIndexes = []string{"id"} ) @@ -31,7 +31,21 @@ func NewProject(client *mongox.Client) repo.Project { } func (r *ProjectRepo) Init() error { - return createIndexes(context.Background(), r.client, projectIndexes, projectUniqueIndexes) + idx := mongox.IndexFromKeys(projectIndexes, false) + idx = append(idx, mongox.IndexFromKeys(projectUniqueIndexes, true)...) + idx = append(idx, mongox.Index{ + Name: "re_publication_token", + Key: bson.D{{Key: "publication.token", Value: 1}}, + Unique: true, + Filter: bson.M{"publication.token": bson.M{"$type": "string"}}, + }) + idx = append(idx, mongox.Index{ + Name: "re_alias", + Key: bson.D{{Key: "alias", Value: 1}}, + Unique: true, + Filter: bson.M{"alias": bson.M{"$type": "string"}}, + }) + return createIndexes2(context.Background(), r.client, idx...) } func (r *ProjectRepo) Filtered(f repo.WorkspaceFilter) repo.Project { @@ -90,13 +104,16 @@ func (r *ProjectRepo) FindByIDOrAlias(ctx context.Context, id project.IDOrAlias) return r.findOne(ctx, filter) } -func (r *ProjectRepo) FindByPublicName(ctx context.Context, name string) (*project.Project, error) { +func (r *ProjectRepo) IsAliasAvailable(ctx context.Context, name string) (bool, error) { if name == "" { - return nil, rerror.ErrNotFound + return false, nil } - return r.findOne(ctx, bson.M{ + + // no need to filter by workspace, because alias is unique across all workspaces + c, err := r.client.Count(ctx, bson.M{ "alias": name, }) + return c == 0 && err == nil, err } func (r *ProjectRepo) FindByPublicAPIToken(ctx context.Context, token string) (*project.Project, error) { diff --git a/server/internal/infrastructure/mongo/project_test.go b/server/internal/infrastructure/mongo/project_test.go index 71c978b16c..061270a180 100644 --- a/server/internal/infrastructure/mongo/project_test.go +++ b/server/internal/infrastructure/mongo/project_test.go @@ -421,7 +421,7 @@ func Test_projectRepo_FindByIDs(t *testing.T) { } } -func Test_projectRepo_FindByPublicName(t *testing.T) { +func Test_projectRepo_IsAliasAvailable(t *testing.T) { now := time.Now().Truncate(time.Millisecond).UTC() tid1 := accountdomain.NewWorkspaceID() id1 := id.NewProjectID() @@ -445,7 +445,7 @@ func Test_projectRepo_FindByPublicName(t *testing.T) { seeds project.List arg string filter *repo.WorkspaceFilter - want *project.Project + want bool wantErr error }{ { @@ -453,8 +453,8 @@ func Test_projectRepo_FindByPublicName(t *testing.T) { seeds: project.List{}, arg: "xyz123", filter: nil, - want: nil, - wantErr: rerror.ErrNotFound, + want: true, + wantErr: nil, }, { name: "Not found", @@ -463,8 +463,8 @@ func Test_projectRepo_FindByPublicName(t *testing.T) { }, arg: "xyz123", filter: nil, - want: nil, - wantErr: rerror.ErrNotFound, + want: true, + wantErr: nil, }, { name: "public Found", @@ -473,16 +473,16 @@ func Test_projectRepo_FindByPublicName(t *testing.T) { }, arg: "xyz123", filter: nil, - want: p1, + want: false, wantErr: nil, }, { - name: "linited Found", + name: "limited Found", seeds: project.List{ p2, }, arg: "xyz321", - want: p2, + want: false, filter: nil, wantErr: nil, }, @@ -495,11 +495,11 @@ func Test_projectRepo_FindByPublicName(t *testing.T) { }, arg: "xyz123", filter: nil, - want: p1, + want: false, wantErr: nil, }, { - name: "Filtered should not Found", + name: "Filtered should Found", seeds: project.List{ p1, project.New().NewID().Workspace(accountdomain.NewWorkspaceID()).MustBuild(), @@ -507,8 +507,8 @@ func Test_projectRepo_FindByPublicName(t *testing.T) { }, arg: "xyz123", filter: &repo.WorkspaceFilter{Readable: []accountdomain.WorkspaceID{accountdomain.NewWorkspaceID()}, Writable: []accountdomain.WorkspaceID{}}, - want: nil, - wantErr: rerror.ErrNotFound, + want: false, + wantErr: nil, }, { name: "Filtered should Found", @@ -519,7 +519,7 @@ func Test_projectRepo_FindByPublicName(t *testing.T) { }, arg: "xyz123", filter: &repo.WorkspaceFilter{Readable: []accountdomain.WorkspaceID{tid1}, Writable: []accountdomain.WorkspaceID{}}, - want: p1, + want: false, wantErr: nil, }, } @@ -544,7 +544,7 @@ func Test_projectRepo_FindByPublicName(t *testing.T) { r = r.Filtered(*tc.filter) } - got, err := r.FindByPublicName(ctx, tc.arg) + got, err := r.IsAliasAvailable(ctx, tc.arg) if tc.wantErr != nil { assert.ErrorIs(t, err, tc.wantErr) return diff --git a/server/internal/usecase/interactor/item_import.go b/server/internal/usecase/interactor/item_import.go index adc20d2615..5e0b285110 100644 --- a/server/internal/usecase/interactor/item_import.go +++ b/server/internal/usecase/interactor/item_import.go @@ -473,7 +473,7 @@ func guessSchemaFields(sp schema.Package, orderedMap *orderedmap.OrderedMap, isG } for _, k := range orderedMap.Keys() { v, _ := orderedMap.Get(k) - if v == nil || k == "id" { + if k == "id" { continue } @@ -505,26 +505,27 @@ func guessSchemaFields(sp schema.Package, orderedMap *orderedmap.OrderedMap, isG func fieldFrom(k string, v any, sp schema.Package) interfaces.CreateFieldParam { t := value.TypeText - switch reflect.TypeOf(v).Kind() { - case reflect.Bool: - t = value.TypeBool - case reflect.Int: - case reflect.Int8: - case reflect.Int16: - case reflect.Int32: - case reflect.Int64: - case reflect.Uint: - case reflect.Uint8: - case reflect.Uint16: - case reflect.Uint32: - case reflect.Uint64: - case reflect.Float32: - case reflect.Float64: - t = value.TypeNumber - case reflect.String: - t = value.TypeText - default: - + if v != nil { + switch reflect.TypeOf(v).Kind() { + case reflect.Bool: + t = value.TypeBool + case reflect.Int: + case reflect.Int8: + case reflect.Int16: + case reflect.Int32: + case reflect.Int64: + case reflect.Uint: + case reflect.Uint8: + case reflect.Uint16: + case reflect.Uint32: + case reflect.Uint64: + case reflect.Float32: + case reflect.Float64: + t = value.TypeNumber + case reflect.String: + t = value.TypeText + default: + } } return interfaces.CreateFieldParam{ ModelID: nil, diff --git a/server/internal/usecase/interactor/project.go b/server/internal/usecase/interactor/project.go index 8b7fa633f6..43d0648e25 100644 --- a/server/internal/usecase/interactor/project.go +++ b/server/internal/usecase/interactor/project.go @@ -2,7 +2,6 @@ package interactor import ( "context" - "errors" "time" "github.com/reearth/reearth-cms/server/internal/usecase" @@ -13,7 +12,6 @@ import ( "github.com/reearth/reearth-cms/server/pkg/project" "github.com/reearth/reearthx/account/accountdomain" "github.com/reearth/reearthx/account/accountdomain/workspace" - "github.com/reearth/reearthx/rerror" "github.com/reearth/reearthx/usecasex" ) @@ -54,11 +52,9 @@ func (i *Project) Create(ctx context.Context, p interfaces.CreateProjectParam, o pb = pb.Description(*p.Description) } if p.Alias != nil { - proj2, _ := i.repos.Project.FindByPublicName(ctx, *p.Alias) - if proj2 != nil { + if ok, _ := i.repos.Project.IsAliasAvailable(ctx, *p.Alias); !ok { return nil, interfaces.ErrProjectAliasAlreadyUsed } - pb = pb.Alias(*p.Alias) } if len(p.RequestRoles) > 0 { @@ -95,10 +91,8 @@ func (i *Project) Update(ctx context.Context, p interfaces.UpdateProjectParam, o proj.UpdateDescription(*p.Description) } - if p.Alias != nil { - proj2, _ := i.repos.Project.FindByPublicName(ctx, *p.Alias) - - if proj2 != nil && proj2.ID() != proj.ID() { + if p.Alias != nil && *p.Alias != proj.Alias() { + if ok, _ := i.repos.Project.IsAliasAvailable(ctx, *p.Alias); !ok { return nil, interfaces.ErrProjectAliasAlreadyUsed } @@ -140,12 +134,7 @@ func (i *Project) CheckAlias(ctx context.Context, alias string) (bool, error) { return false, project.ErrInvalidAlias } - prj, err := i.repos.Project.FindByPublicName(ctx, alias) - if prj == nil && err == nil || err != nil && errors.Is(err, rerror.ErrNotFound) { - return true, nil - } - - return false, err + return i.repos.Project.IsAliasAvailable(ctx, alias) }) } @@ -182,7 +171,7 @@ func (i *Project) RegenerateToken(ctx context.Context, pId id.ProjectID, operato return nil, interfaces.ErrOperationDenied } - if p.Publication() == nil || p.Publication().Scope() != project.PublicationScopeLimited { + if p.Publication() == nil || p.Publication().Scope() != project.PublicationScopeLimited { return nil, interfaces.ErrInvalidProject } diff --git a/server/internal/usecase/repo/project.go b/server/internal/usecase/repo/project.go index 46b4025aec..7f0ac5095a 100644 --- a/server/internal/usecase/repo/project.go +++ b/server/internal/usecase/repo/project.go @@ -15,7 +15,7 @@ type Project interface { FindByID(context.Context, id.ProjectID) (*project.Project, error) FindByIDOrAlias(context.Context, project.IDOrAlias) (*project.Project, error) FindByWorkspaces(context.Context, accountdomain.WorkspaceIDList, *usecasex.Pagination) (project.List, *usecasex.PageInfo, error) - FindByPublicName(context.Context, string) (*project.Project, error) + IsAliasAvailable(context.Context, string) (bool, error) CountByWorkspace(context.Context, accountdomain.WorkspaceID) (int, error) FindByPublicAPIToken(context.Context, string) (*project.Project, error) Save(context.Context, *project.Project) error