From c67bc5688fc950e7e4fc93a0a0173d41a4278963 Mon Sep 17 00:00:00 2001 From: AndrewSisley Date: Thu, 14 Mar 2024 15:07:06 -0400 Subject: [PATCH] feat: Add PatchCollection (#2402) ## Relevant issue(s) Resolves #2389 ## Description Adds the PatchCollection command. Mutating anything but the collection name is currently disabled, we can expand this as we see fit, but for now I'd prefer to keep the initial PR small. This change means that the Collection Name is no longer always going to be the same as the Schema Name. --- cli/cli.go | 1 + cli/collection_patch.go | 69 +++++ cli/schema_patch.go | 2 +- client/db.go | 11 + client/mocks/db.go | 43 +++ db/collection.go | 280 ++++++++++++++++++ db/description/collection.go | 36 +++ db/errors.go | 260 ++++++++++------ db/txn_db.go | 25 ++ http/client.go | 19 ++ http/handler_store.go | 30 ++ tests/clients/cli/wrapper.go | 10 + tests/clients/http/wrapper.go | 7 + .../collection_description/simple_test.go | 42 +++ .../updates/add/collections_test.go | 107 +++++++ .../updates/add/sources_test.go | 39 +++ .../updates/copy/name_test.go | 98 ++++++ .../updates/move/name_test.go | 66 +++++ .../updates/remove/collections_test.go | 41 +++ .../updates/remove/name_test.go | 49 +++ .../updates/replace/fields_test.go | 39 +++ .../updates/replace/id_test.go | 146 +++++++++ .../updates/replace/indexes_test.go | 39 +++ .../updates/replace/name_test.go | 210 +++++++++++++ .../updates/replace/root_id_test.go | 39 +++ .../updates/replace/schema_version_id_test.go | 39 +++ .../updates/replace/sources_test.go | 39 +++ .../updates/test/name_test.go | 60 ++++ .../field/kind/foreign_object_array_test.go | 2 +- .../add/field/kind/foreign_object_test.go | 2 +- .../field/with_index_test.go} | 6 +- .../schema/updates/add/simple_test.go | 2 +- .../schema/updates/copy/simple_test.go | 2 +- .../schema/updates/replace/simple_test.go | 2 +- tests/integration/test_case.go | 12 + tests/integration/utils2.go | 19 ++ 36 files changed, 1798 insertions(+), 95 deletions(-) create mode 100644 cli/collection_patch.go create mode 100644 tests/integration/collection_description/simple_test.go create mode 100644 tests/integration/collection_description/updates/add/collections_test.go create mode 100644 tests/integration/collection_description/updates/add/sources_test.go create mode 100644 tests/integration/collection_description/updates/copy/name_test.go create mode 100644 tests/integration/collection_description/updates/move/name_test.go create mode 100644 tests/integration/collection_description/updates/remove/collections_test.go create mode 100644 tests/integration/collection_description/updates/remove/name_test.go create mode 100644 tests/integration/collection_description/updates/replace/fields_test.go create mode 100644 tests/integration/collection_description/updates/replace/id_test.go create mode 100644 tests/integration/collection_description/updates/replace/indexes_test.go create mode 100644 tests/integration/collection_description/updates/replace/name_test.go create mode 100644 tests/integration/collection_description/updates/replace/root_id_test.go create mode 100644 tests/integration/collection_description/updates/replace/schema_version_id_test.go create mode 100644 tests/integration/collection_description/updates/replace/sources_test.go create mode 100644 tests/integration/collection_description/updates/test/name_test.go rename tests/integration/schema/updates/{index/simple_test.go => add/field/with_index_test.go} (88%) diff --git a/cli/cli.go b/cli/cli.go index 4cdb8c443b..c40c6528d8 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -95,6 +95,7 @@ func NewDefraCommand() *cobra.Command { MakeCollectionUpdateCommand(), MakeCollectionCreateCommand(), MakeCollectionDescribeCommand(), + MakeCollectionPatchCommand(), ) client := MakeClientCommand() diff --git a/cli/collection_patch.go b/cli/collection_patch.go new file mode 100644 index 0000000000..49d5a91305 --- /dev/null +++ b/cli/collection_patch.go @@ -0,0 +1,69 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package cli + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +func MakeCollectionPatchCommand() *cobra.Command { + var patchFile string + var cmd = &cobra.Command{ + Use: "patch [patch]", + Short: "Patch existing collection descriptions", + Long: `Patch existing collection descriptions. + +Uses JSON Patch to modify collection descriptions. + +Example: patch from an argument string: + defradb client collection patch '[{ "op": "add", "path": "...", "value": {...} }]' + +Example: patch from file: + defradb client collection patch -p patch.json + +Example: patch from stdin: + cat patch.json | defradb client collection patch - + +To learn more about the DefraDB GraphQL Schema Language, refer to https://docs.source.network.`, + Args: cobra.RangeArgs(0, 1), + RunE: func(cmd *cobra.Command, args []string) error { + store := mustGetContextStore(cmd) + + var patch string + switch { + case patchFile != "": + data, err := os.ReadFile(patchFile) + if err != nil { + return err + } + patch = string(data) + case len(args) > 0 && args[0] == "-": + data, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return err + } + patch = string(data) + case len(args) == 1: + patch = args[0] + default: + return fmt.Errorf("patch cannot be empty") + } + + return store.PatchCollection(cmd.Context(), patch) + }, + } + cmd.Flags().StringVarP(&patchFile, "patch-file", "p", "", "File to load a patch from") + return cmd +} diff --git a/cli/schema_patch.go b/cli/schema_patch.go index 23f425396d..cf9224d204 100644 --- a/cli/schema_patch.go +++ b/cli/schema_patch.go @@ -37,7 +37,7 @@ Example: patch from an argument string: defradb client schema patch '[{ "op": "add", "path": "...", "value": {...} }]' '{"lenses": [...' Example: patch from file: - defradb client schema patch -f patch.json + defradb client schema patch -p patch.json Example: patch from stdin: cat patch.json | defradb client schema patch - diff --git a/client/db.go b/client/db.go index 7b0cc8060f..660c03998f 100644 --- a/client/db.go +++ b/client/db.go @@ -120,6 +120,17 @@ type Store interface { // A lens configuration may also be provided, it will be added to all collections using the schema. PatchSchema(context.Context, string, immutable.Option[model.Lens], bool) error + // PatchCollection takes the given JSON patch string and applies it to the set of CollectionDescriptions + // present in the database. + // + // It will also update the GQL types used by the query system. It will error and not apply any of the + // requested, valid updates should the net result of the patch result in an invalid state. The + // individual operations defined in the patch do not need to result in a valid state, only the net result + // of the full patch. + // + // Currently only the collection name can be modified. + PatchCollection(context.Context, string) error + // SetActiveSchemaVersion activates all collection versions with the given schema version, and deactivates all // those without it (if they share the same schema root). // diff --git a/client/mocks/db.go b/client/mocks/db.go index aeb54ea4cd..c6f6711a59 100644 --- a/client/mocks/db.go +++ b/client/mocks/db.go @@ -857,6 +857,49 @@ func (_c *DB_NewTxn_Call) RunAndReturn(run func(context.Context, bool) (datastor return _c } +// PatchCollection provides a mock function with given fields: _a0, _a1 +func (_m *DB) PatchCollection(_a0 context.Context, _a1 string) error { + ret := _m.Called(_a0, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DB_PatchCollection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PatchCollection' +type DB_PatchCollection_Call struct { + *mock.Call +} + +// PatchCollection is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 string +func (_e *DB_Expecter) PatchCollection(_a0 interface{}, _a1 interface{}) *DB_PatchCollection_Call { + return &DB_PatchCollection_Call{Call: _e.mock.On("PatchCollection", _a0, _a1)} +} + +func (_c *DB_PatchCollection_Call) Run(run func(_a0 context.Context, _a1 string)) *DB_PatchCollection_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *DB_PatchCollection_Call) Return(_a0 error) *DB_PatchCollection_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *DB_PatchCollection_Call) RunAndReturn(run func(context.Context, string) error) *DB_PatchCollection_Call { + _c.Call.Return(run) + return _c +} + // PatchSchema provides a mock function with given fields: _a0, _a1, _a2, _a3 func (_m *DB) PatchSchema(_a0 context.Context, _a1 string, _a2 immutable.Option[model.Lens], _a3 bool) error { ret := _m.Called(_a0, _a1, _a2, _a3) diff --git a/db/collection.go b/db/collection.go index c9d311f01a..23ef06d9c4 100644 --- a/db/collection.go +++ b/db/collection.go @@ -13,10 +13,13 @@ package db import ( "bytes" "context" + "encoding/json" "fmt" + "reflect" "strconv" "strings" + jsonpatch "github.com/evanphx/json-patch/v5" "github.com/ipfs/go-cid" ds "github.com/ipfs/go-datastore" "github.com/ipfs/go-datastore/query" @@ -526,6 +529,283 @@ func validateUpdateSchemaFields( return hasChanged, nil } +func (db *db) patchCollection( + ctx context.Context, + txn datastore.Txn, + patchString string, +) error { + patch, err := jsonpatch.DecodePatch([]byte(patchString)) + if err != nil { + return err + } + + cols, err := description.GetCollections(ctx, txn) + if err != nil { + return err + } + + existingColsByID := map[uint32]client.CollectionDescription{} + for _, col := range cols { + existingColsByID[col.ID] = col + } + + existingDescriptionJson, err := json.Marshal(existingColsByID) + if err != nil { + return err + } + + newDescriptionJson, err := patch.Apply(existingDescriptionJson) + if err != nil { + return err + } + + var newColsByID map[uint32]client.CollectionDescription + decoder := json.NewDecoder(strings.NewReader(string(newDescriptionJson))) + decoder.DisallowUnknownFields() + err = decoder.Decode(&newColsByID) + if err != nil { + return err + } + + err = db.validateCollectionChanges(existingColsByID, newColsByID) + if err != nil { + return err + } + + for _, col := range newColsByID { + _, err := description.SaveCollection(ctx, txn, col) + if err != nil { + return err + } + } + + return db.loadSchema(ctx, txn) +} + +var patchCollectionValidators = []func( + map[uint32]client.CollectionDescription, + map[uint32]client.CollectionDescription, +) error{ + validateCollectionNameUnique, + validateSingleVersionActive, + validateSourcesNotModified, + validateIndexesNotModified, + validateFieldsNotModified, + validateIDNotZero, + validateIDUnique, + validateIDExists, + validateRootIDNotMutated, + validateSchemaVersionIDNotMutated, + validateCollectionNotRemoved, +} + +func (db *db) validateCollectionChanges( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + for _, validators := range patchCollectionValidators { + err := validators(oldColsByID, newColsByID) + if err != nil { + return err + } + } + + return nil +} + +func validateCollectionNameUnique( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + names := map[string]struct{}{} + for _, col := range newColsByID { + if !col.Name.HasValue() { + continue + } + + if _, ok := names[col.Name.Value()]; ok { + return NewErrCollectionAlreadyExists(col.Name.Value()) + } + names[col.Name.Value()] = struct{}{} + } + + return nil +} + +func validateSingleVersionActive( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + rootsWithActiveCol := map[uint32]struct{}{} + for _, col := range newColsByID { + if !col.Name.HasValue() { + continue + } + + if _, ok := rootsWithActiveCol[col.RootID]; ok { + return NewErrMultipleActiveCollectionVersions(col.Name.Value(), col.RootID) + } + rootsWithActiveCol[col.RootID] = struct{}{} + } + + return nil +} + +func validateSourcesNotModified( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + for _, newCol := range newColsByID { + oldCol, ok := oldColsByID[newCol.ID] + if !ok { + continue + } + + // DeepEqual is temporary, as this validation is temporary, for example soon + // users will be able to be able to change the migration + if !reflect.DeepEqual(oldCol.Sources, newCol.Sources) { + return NewErrCollectionSourcesCannotBeMutated(newCol.ID) + } + } + + return nil +} + +func validateIndexesNotModified( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + for _, newCol := range newColsByID { + oldCol, ok := oldColsByID[newCol.ID] + if !ok { + continue + } + + // DeepEqual is temporary, as this validation is temporary + if !reflect.DeepEqual(oldCol.Indexes, newCol.Indexes) { + return NewErrCollectionIndexesCannotBeMutated(newCol.ID) + } + } + + return nil +} + +func validateFieldsNotModified( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + for _, newCol := range newColsByID { + oldCol, ok := oldColsByID[newCol.ID] + if !ok { + continue + } + + // DeepEqual is temporary, as this validation is temporary + if !reflect.DeepEqual(oldCol.Fields, newCol.Fields) { + return NewErrCollectionFieldsCannotBeMutated(newCol.ID) + } + } + + return nil +} + +func validateIDNotZero( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + for _, newCol := range newColsByID { + if newCol.ID == 0 { + return ErrCollectionIDCannotBeZero + } + } + + return nil +} + +func validateIDUnique( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + colIds := map[uint32]struct{}{} + for _, newCol := range newColsByID { + if _, ok := colIds[newCol.ID]; ok { + return NewErrCollectionIDAlreadyExists(newCol.ID) + } + colIds[newCol.ID] = struct{}{} + } + + return nil +} + +func validateIDExists( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + for _, newCol := range newColsByID { + if _, ok := oldColsByID[newCol.ID]; !ok { + return NewErrAddCollectionIDWithPatch(newCol.ID) + } + } + + return nil +} + +func validateRootIDNotMutated( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + for _, newCol := range newColsByID { + oldCol, ok := oldColsByID[newCol.ID] + if !ok { + continue + } + + if newCol.RootID != oldCol.RootID { + return NewErrCollectionRootIDCannotBeMutated(newCol.ID) + } + } + + return nil +} + +func validateSchemaVersionIDNotMutated( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { + for _, newCol := range newColsByID { + oldCol, ok := oldColsByID[newCol.ID] + if !ok { + continue + } + + if newCol.SchemaVersionID != oldCol.SchemaVersionID { + return NewErrCollectionSchemaVersionIDCannotBeMutated(newCol.ID) + } + } + + return nil +} + +func validateCollectionNotRemoved( + oldColsByID map[uint32]client.CollectionDescription, + newColsByID map[uint32]client.CollectionDescription, +) error { +oldLoop: + for _, oldCol := range oldColsByID { + for _, newCol := range newColsByID { + // It is not enough to just match by the map index, in case the index does not pair + // up with the ID (this can happen if a user moves the collection within the map) + if newCol.ID == oldCol.ID { + continue oldLoop + } + } + + return NewErrCollectionsCannotBeDeleted(oldCol.ID) + } + + return nil +} + // SetActiveSchemaVersion activates all collection versions with the given schema version, and deactivates all // those without it (if they share the same schema root). // diff --git a/db/description/collection.go b/db/description/collection.go index 8ffd473053..a6e9cd8b57 100644 --- a/db/description/collection.go +++ b/db/description/collection.go @@ -13,8 +13,10 @@ package description import ( "context" "encoding/json" + "errors" "sort" + ds "github.com/ipfs/go-datastore" "github.com/ipfs/go-datastore/query" "github.com/sourcenetwork/defradb/client" @@ -29,6 +31,11 @@ func SaveCollection( txn datastore.Txn, desc client.CollectionDescription, ) (client.CollectionDescription, error) { + existing, err := GetCollectionByID(ctx, txn, desc.ID) + if err != nil && !errors.Is(err, ds.ErrNotFound) { + return client.CollectionDescription{}, err + } + buf, err := json.Marshal(desc) if err != nil { return client.CollectionDescription{}, err @@ -40,6 +47,35 @@ func SaveCollection( return client.CollectionDescription{}, err } + if existing.Name.HasValue() && existing.Name != desc.Name { + nameKey := core.NewCollectionNameKey(existing.Name.Value()) + idBuf, err := txn.Systemstore().Get(ctx, nameKey.ToDS()) + nameIndexExsts := true + if err != nil { + if errors.Is(err, ds.ErrNotFound) { + nameIndexExsts = false + } else { + return client.CollectionDescription{}, err + } + } + if nameIndexExsts { + var keyID uint32 + err = json.Unmarshal(idBuf, &keyID) + if err != nil { + return client.CollectionDescription{}, err + } + + if keyID == desc.ID { + // The name index may have already been overwritten, pointing at another collection + // we should only remove the existing index if it still points at this collection + err := txn.Systemstore().Delete(ctx, nameKey.ToDS()) + if err != nil { + return client.CollectionDescription{}, err + } + } + } + } + if desc.Name.HasValue() { idBuf, err := json.Marshal(desc.ID) if err != nil { diff --git a/db/errors.go b/db/errors.go index 34dd0d53b5..a41e396a8b 100644 --- a/db/errors.go +++ b/db/errors.go @@ -16,95 +16,112 @@ import ( ) const ( - errFailedToGetHeads string = "failed to get document heads" - errFailedToCreateCollectionQuery string = "failed to create collection prefix query" - errFailedToGetCollection string = "failed to get collection" - errFailedToGetAllCollections string = "failed to get all collections" - errDocVerification string = "the document verification failed" - errAddingP2PCollection string = "cannot add collection ID" - errRemovingP2PCollection string = "cannot remove collection ID" - errAddCollectionWithPatch string = "unknown collection, adding collections via patch is not supported" - errCollectionIDDoesntMatch string = "CollectionID does not match existing" - errSchemaRootDoesntMatch string = "SchemaRoot does not match existing" - errCannotModifySchemaName string = "modifying the schema name is not supported" - errCannotSetVersionID string = "setting the VersionID is not supported. It is updated automatically" - errRelationalFieldMissingSchema string = "a `Schema` [name] must be provided when adding a new relation field" - errRelationalFieldInvalidRelationType string = "invalid RelationType" - errRelationalFieldMissingIDField string = "missing id field for relation object field" - errRelationalFieldMissingRelationName string = "missing relation name" - errPrimarySideNotDefined string = "primary side of relation not defined" - errPrimarySideOnMany string = "cannot set the many side of a relation as primary" - errBothSidesPrimary string = "both sides of a relation cannot be primary" - errRelatedFieldKindMismatch string = "invalid Kind of the related field" - errRelatedFieldRelationTypeMismatch string = "invalid RelationType of the related field" - errRelationalFieldIDInvalidType string = "relational id field of invalid kind" - errDuplicateField string = "duplicate field" - errCannotMutateField string = "mutating an existing field is not supported" - errCannotMoveField string = "moving fields is not currently supported" - errCannotDeleteField string = "deleting an existing field is not supported" - errFieldKindNotFound string = "no type found for given name" - errFieldKindDoesNotMatchFieldSchema string = "field Kind does not match field Schema" - errSchemaNotFound string = "no schema found for given name" - errDocumentAlreadyExists string = "a document with the given ID already exists" - errDocumentDeleted string = "a document with the given ID has been deleted" - errIndexMissingFields string = "index missing fields" - errNonZeroIndexIDProvided string = "non-zero index ID provided" - errIndexFieldMissingName string = "index field missing name" - errIndexFieldMissingDirection string = "index field missing direction" - errIndexWithNameAlreadyExists string = "index with name already exists" - errInvalidStoredIndex string = "invalid stored index" - errInvalidStoredIndexKey string = "invalid stored index key" - errNonExistingFieldForIndex string = "creating an index on a non-existing property" - errCollectionDoesntExisting string = "collection with given name doesn't exist" - errFailedToStoreIndexedField string = "failed to store indexed field" - errFailedToReadStoredIndexDesc string = "failed to read stored index description" - errCanNotDeleteIndexedField string = "can not delete indexed field" - errCanNotAddIndexWithPatch string = "adding indexes via patch is not supported" - errCanNotDropIndexWithPatch string = "dropping indexes via patch is not supported" - errCanNotChangeIndexWithPatch string = "changing indexes via patch is not supported" - errIndexWithNameDoesNotExists string = "index with name doesn't exists" - errCorruptedIndex string = "corrupted index. Please delete and recreate the index" - errInvalidFieldValue string = "invalid field value" - errUnsupportedIndexFieldType string = "unsupported index field type" - errIndexDescriptionHasNoFields string = "index description has no fields" - errFieldOrAliasToFieldNotExist string = "The given field or alias to field does not exist" - errCreateFile string = "failed to create file" - errRemoveFile string = "failed to remove file" - errOpenFile string = "failed to open file" - errCloseFile string = "failed to close file" - errFailedtoCloseQueryReqAllIDs string = "failed to close query requesting all docIDs" - errFailedToReadByte string = "failed to read byte" - errFailedToWriteString string = "failed to write string" - errJSONDecode string = "failed to decode JSON" - errDocFromMap string = "failed to create a new doc from map" - errDocCreate string = "failed to save a new doc to collection" - errDocUpdate string = "failed to update doc to collection" - errExpectedJSONObject string = "expected JSON object" - errExpectedJSONArray string = "expected JSON array" - errOneOneAlreadyLinked string = "target document is already linked to another document" - errIndexDoesNotMatchName string = "the index used does not match the given name" - errCanNotIndexNonUniqueFields string = "can not index a doc's field(s) that violates unique index" - errInvalidViewQuery string = "the query provided is not valid as a View" + errFailedToGetHeads string = "failed to get document heads" + errFailedToCreateCollectionQuery string = "failed to create collection prefix query" + errFailedToGetCollection string = "failed to get collection" + errFailedToGetAllCollections string = "failed to get all collections" + errDocVerification string = "the document verification failed" + errAddingP2PCollection string = "cannot add collection ID" + errRemovingP2PCollection string = "cannot remove collection ID" + errAddCollectionWithPatch string = "adding collections via patch is not supported" + errCollectionIDDoesntMatch string = "CollectionID does not match existing" + errSchemaRootDoesntMatch string = "SchemaRoot does not match existing" + errCannotModifySchemaName string = "modifying the schema name is not supported" + errCannotSetVersionID string = "setting the VersionID is not supported" + errRelationalFieldMissingSchema string = "a schema name must be provided when adding a new relation field" + errRelationalFieldInvalidRelationType string = "invalid RelationType" + errRelationalFieldMissingIDField string = "missing id field for relation object field" + errRelationalFieldMissingRelationName string = "missing relation name" + errPrimarySideNotDefined string = "primary side of relation not defined" + errPrimarySideOnMany string = "cannot set the many side of a relation as primary" + errBothSidesPrimary string = "both sides of a relation cannot be primary" + errRelatedFieldKindMismatch string = "invalid Kind of the related field" + errRelatedFieldRelationTypeMismatch string = "invalid RelationType of the related field" + errRelationalFieldIDInvalidType string = "relational id field of invalid kind" + errDuplicateField string = "duplicate field" + errCannotMutateField string = "mutating an existing field is not supported" + errCannotMoveField string = "moving fields is not currently supported" + errCannotDeleteField string = "deleting an existing field is not supported" + errFieldKindNotFound string = "no type found for given name" + errFieldKindDoesNotMatchFieldSchema string = "field Kind does not match field Schema" + errSchemaNotFound string = "no schema found for given name" + errDocumentAlreadyExists string = "a document with the given ID already exists" + errDocumentDeleted string = "a document with the given ID has been deleted" + errIndexMissingFields string = "index missing fields" + errNonZeroIndexIDProvided string = "non-zero index ID provided" + errIndexFieldMissingName string = "index field missing name" + errIndexFieldMissingDirection string = "index field missing direction" + errIndexWithNameAlreadyExists string = "index with name already exists" + errInvalidStoredIndex string = "invalid stored index" + errInvalidStoredIndexKey string = "invalid stored index key" + errNonExistingFieldForIndex string = "creating an index on a non-existing property" + errCollectionDoesntExisting string = "collection with given name doesn't exist" + errFailedToStoreIndexedField string = "failed to store indexed field" + errFailedToReadStoredIndexDesc string = "failed to read stored index description" + errCanNotDeleteIndexedField string = "can not delete indexed field" + errCanNotAddIndexWithPatch string = "adding indexes via patch is not supported" + errCanNotDropIndexWithPatch string = "dropping indexes via patch is not supported" + errCanNotChangeIndexWithPatch string = "changing indexes via patch is not supported" + errIndexWithNameDoesNotExists string = "index with name doesn't exists" + errCorruptedIndex string = "corrupted index. Please delete and recreate the index" + errInvalidFieldValue string = "invalid field value" + errUnsupportedIndexFieldType string = "unsupported index field type" + errIndexDescriptionHasNoFields string = "index description has no fields" + errFieldOrAliasToFieldNotExist string = "The given field or alias to field does not exist" + errCreateFile string = "failed to create file" + errRemoveFile string = "failed to remove file" + errOpenFile string = "failed to open file" + errCloseFile string = "failed to close file" + errFailedtoCloseQueryReqAllIDs string = "failed to close query requesting all docIDs" + errFailedToReadByte string = "failed to read byte" + errFailedToWriteString string = "failed to write string" + errJSONDecode string = "failed to decode JSON" + errDocFromMap string = "failed to create a new doc from map" + errDocCreate string = "failed to save a new doc to collection" + errDocUpdate string = "failed to update doc to collection" + errExpectedJSONObject string = "expected JSON object" + errExpectedJSONArray string = "expected JSON array" + errOneOneAlreadyLinked string = "target document is already linked to another document" + errIndexDoesNotMatchName string = "the index used does not match the given name" + errCanNotIndexNonUniqueFields string = "can not index a doc's field(s) that violates unique index" + errInvalidViewQuery string = "the query provided is not valid as a View" + errCollectionAlreadyExists string = "collection already exists" + errMultipleActiveCollectionVersions string = "multiple versions of same collection cannot be active" + errCollectionSourcesCannotBeMutated string = "collection sources cannot be mutated" + errCollectionIndexesCannotBeMutated string = "collection indexes cannot be mutated" + errCollectionFieldsCannotBeMutated string = "collection fields cannot be mutated" + errCollectionRootIDCannotBeMutated string = "collection root ID cannot be mutated" + errCollectionSchemaVersionIDCannotBeMutated string = "collection schema version ID cannot be mutated" + errCollectionIDCannotBeZero string = "collection ID cannot be zero" + errCollectionsCannotBeDeleted string = "collections cannot be deleted" ) var ( - ErrFailedToGetCollection = errors.New(errFailedToGetCollection) - ErrSubscriptionsNotAllowed = errors.New("server does not accept subscriptions") - ErrInvalidFilter = errors.New("invalid filter") - ErrCollectionAlreadyExists = errors.New("collection already exists") - ErrCollectionNameEmpty = errors.New("collection name can't be empty") - ErrSchemaNameEmpty = errors.New("schema name can't be empty") - ErrSchemaRootEmpty = errors.New("schema root can't be empty") - ErrSchemaVersionIDEmpty = errors.New("schema version ID can't be empty") - ErrKeyEmpty = errors.New("key cannot be empty") - ErrCannotSetVersionID = errors.New(errCannotSetVersionID) - ErrIndexMissingFields = errors.New(errIndexMissingFields) - ErrIndexFieldMissingName = errors.New(errIndexFieldMissingName) - ErrCorruptedIndex = errors.New(errCorruptedIndex) - ErrExpectedJSONObject = errors.New(errExpectedJSONObject) - ErrExpectedJSONArray = errors.New(errExpectedJSONArray) - ErrInvalidViewQuery = errors.New(errInvalidViewQuery) - ErrCanNotIndexNonUniqueFields = errors.New(errCanNotIndexNonUniqueFields) + ErrFailedToGetCollection = errors.New(errFailedToGetCollection) + ErrSubscriptionsNotAllowed = errors.New("server does not accept subscriptions") + ErrInvalidFilter = errors.New("invalid filter") + ErrCollectionAlreadyExists = errors.New(errCollectionAlreadyExists) + ErrCollectionNameEmpty = errors.New("collection name can't be empty") + ErrSchemaNameEmpty = errors.New("schema name can't be empty") + ErrSchemaRootEmpty = errors.New("schema root can't be empty") + ErrSchemaVersionIDEmpty = errors.New("schema version ID can't be empty") + ErrKeyEmpty = errors.New("key cannot be empty") + ErrCannotSetVersionID = errors.New(errCannotSetVersionID) + ErrIndexMissingFields = errors.New(errIndexMissingFields) + ErrIndexFieldMissingName = errors.New(errIndexFieldMissingName) + ErrCorruptedIndex = errors.New(errCorruptedIndex) + ErrExpectedJSONObject = errors.New(errExpectedJSONObject) + ErrExpectedJSONArray = errors.New(errExpectedJSONArray) + ErrInvalidViewQuery = errors.New(errInvalidViewQuery) + ErrCanNotIndexNonUniqueFields = errors.New(errCanNotIndexNonUniqueFields) + ErrMultipleActiveCollectionVersions = errors.New(errMultipleActiveCollectionVersions) + ErrCollectionSourcesCannotBeMutated = errors.New(errCollectionSourcesCannotBeMutated) + ErrCollectionIndexesCannotBeMutated = errors.New(errCollectionIndexesCannotBeMutated) + ErrCollectionFieldsCannotBeMutated = errors.New(errCollectionFieldsCannotBeMutated) + ErrCollectionRootIDCannotBeMutated = errors.New(errCollectionRootIDCannotBeMutated) + ErrCollectionSchemaVersionIDCannotBeMutated = errors.New(errCollectionSchemaVersionIDCannotBeMutated) + ErrCollectionIDCannotBeZero = errors.New(errCollectionIDCannotBeZero) + ErrCollectionsCannotBeDeleted = errors.New(errCollectionsCannotBeDeleted) ) // NewErrFailedToGetHeads returns a new error indicating that the heads of a document @@ -208,6 +225,13 @@ func NewErrAddCollectionWithPatch(name string) error { ) } +func NewErrAddCollectionIDWithPatch(id uint32) error { + return errors.New( + errAddCollectionWithPatch, + errors.NewKV("ID", id), + ) +} + func NewErrCollectionIDDoesntMatch(name string, existingID, proposedID uint32) error { return errors.New( errCollectionIDDoesntMatch, @@ -543,3 +567,67 @@ func NewErrInvalidViewQueryMissingQuery() error { errors.NewKV("Reason", "No query provided"), ) } + +func NewErrCollectionAlreadyExists(name string) error { + return errors.New( + errCollectionAlreadyExists, + errors.NewKV("Name", name), + ) +} + +func NewErrCollectionIDAlreadyExists(id uint32) error { + return errors.New( + errCollectionAlreadyExists, + errors.NewKV("ID", id), + ) +} + +func NewErrMultipleActiveCollectionVersions(name string, root uint32) error { + return errors.New( + errMultipleActiveCollectionVersions, + errors.NewKV("Name", name), + errors.NewKV("Root", root), + ) +} + +func NewErrCollectionSourcesCannotBeMutated(colID uint32) error { + return errors.New( + errCollectionSourcesCannotBeMutated, + errors.NewKV("CollectionID", colID), + ) +} + +func NewErrCollectionIndexesCannotBeMutated(colID uint32) error { + return errors.New( + errCollectionIndexesCannotBeMutated, + errors.NewKV("CollectionID", colID), + ) +} + +func NewErrCollectionFieldsCannotBeMutated(colID uint32) error { + return errors.New( + errCollectionFieldsCannotBeMutated, + errors.NewKV("CollectionID", colID), + ) +} + +func NewErrCollectionRootIDCannotBeMutated(colID uint32) error { + return errors.New( + errCollectionRootIDCannotBeMutated, + errors.NewKV("CollectionID", colID), + ) +} + +func NewErrCollectionSchemaVersionIDCannotBeMutated(colID uint32) error { + return errors.New( + errCollectionSchemaVersionIDCannotBeMutated, + errors.NewKV("CollectionID", colID), + ) +} + +func NewErrCollectionsCannotBeDeleted(colID uint32) error { + return errors.New( + errCollectionsCannotBeDeleted, + errors.NewKV("CollectionID", colID), + ) +} diff --git a/db/txn_db.go b/db/txn_db.go index f2fbe7cea3..09a7002033 100644 --- a/db/txn_db.go +++ b/db/txn_db.go @@ -267,6 +267,31 @@ func (db *explicitTxnDB) PatchSchema( return db.patchSchema(ctx, db.txn, patchString, migration, setAsDefaultVersion) } +func (db *implicitTxnDB) PatchCollection( + ctx context.Context, + patchString string, +) error { + txn, err := db.NewTxn(ctx, false) + if err != nil { + return err + } + defer txn.Discard(ctx) + + err = db.patchCollection(ctx, txn, patchString) + if err != nil { + return err + } + + return txn.Commit(ctx) +} + +func (db *explicitTxnDB) PatchCollection( + ctx context.Context, + patchString string, +) error { + return db.patchCollection(ctx, db.txn, patchString) +} + func (db *implicitTxnDB) SetActiveSchemaVersion(ctx context.Context, schemaVersionID string) error { txn, err := db.NewTxn(ctx, false) if err != nil { diff --git a/http/client.go b/http/client.go index 142a359c5b..33b9c21fb8 100644 --- a/http/client.go +++ b/http/client.go @@ -161,6 +161,25 @@ func (c *Client) PatchSchema( return err } +func (c *Client) PatchCollection( + ctx context.Context, + patch string, +) error { + methodURL := c.http.baseURL.JoinPath("collections") + + body, err := json.Marshal(patch) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, methodURL.String(), bytes.NewBuffer(body)) + if err != nil { + return err + } + _, err = c.http.request(req) + return err +} + func (c *Client) SetActiveSchemaVersion(ctx context.Context, schemaVersionID string) error { methodURL := c.http.baseURL.JoinPath("schema", "default") diff --git a/http/handler_store.go b/http/handler_store.go index af82f0bc44..6077e6ea60 100644 --- a/http/handler_store.go +++ b/http/handler_store.go @@ -92,6 +92,24 @@ func (s *storeHandler) PatchSchema(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(http.StatusOK) } +func (s *storeHandler) PatchCollection(rw http.ResponseWriter, req *http.Request) { + store := req.Context().Value(storeContextKey).(client.Store) + + var patch string + err := requestJSON(req, &patch) + if err != nil { + responseJSON(rw, http.StatusBadRequest, errorResponse{err}) + return + } + + err = store.PatchCollection(req.Context(), patch) + if err != nil { + responseJSON(rw, http.StatusBadRequest, errorResponse{err}) + return + } + rw.WriteHeader(http.StatusOK) +} + func (s *storeHandler) SetActiveSchemaVersion(rw http.ResponseWriter, req *http.Request) { store := req.Context().Value(storeContextKey).(client.Store) @@ -476,6 +494,17 @@ func (h *storeHandler) bindRoutes(router *Router) { collectionDescribe.AddResponse(200, collectionsResponse) collectionDescribe.Responses.Set("400", errorResponse) + patchCollection := openapi3.NewOperation() + patchCollection.OperationID = "patch_collection" + patchCollection.Description = "Update collection definitions" + patchCollection.Tags = []string{"collection"} + patchCollection.RequestBody = &openapi3.RequestBodyRef{ + Value: openapi3.NewRequestBody().WithJSONSchema(openapi3.NewStringSchema()), + } + patchCollection.Responses = openapi3.NewResponses() + patchCollection.Responses.Set("200", successResponse) + patchCollection.Responses.Set("400", errorResponse) + collectionDefintionsSchema := openapi3.NewArraySchema() collectionDefintionsSchema.Items = collectionDefinitionSchema @@ -590,6 +619,7 @@ func (h *storeHandler) bindRoutes(router *Router) { router.AddRoute("/backup/export", http.MethodPost, backupExport, h.BasicExport) router.AddRoute("/backup/import", http.MethodPost, backupImport, h.BasicImport) router.AddRoute("/collections", http.MethodGet, collectionDescribe, h.GetCollection) + router.AddRoute("/collections", http.MethodPatch, patchCollection, h.PatchCollection) router.AddRoute("/view", http.MethodPost, views, h.AddView) router.AddRoute("/view", http.MethodPost, views, h.AddView) router.AddRoute("/graphql", http.MethodGet, graphQLGet, h.ExecRequest) diff --git a/tests/clients/cli/wrapper.go b/tests/clients/cli/wrapper.go index 89ba2cf3db..4c52a86abc 100644 --- a/tests/clients/cli/wrapper.go +++ b/tests/clients/cli/wrapper.go @@ -210,6 +210,16 @@ func (w *Wrapper) PatchSchema( return err } +func (w *Wrapper) PatchCollection( + ctx context.Context, + patch string, +) error { + args := []string{"client", "collection", "patch"} + args = append(args, patch) + _, err := w.cmd.execute(ctx, args) + return err +} + func (w *Wrapper) SetActiveSchemaVersion(ctx context.Context, schemaVersionID string) error { args := []string{"client", "schema", "set-active"} args = append(args, schemaVersionID) diff --git a/tests/clients/http/wrapper.go b/tests/clients/http/wrapper.go index b45105a7f7..4de71c4f1f 100644 --- a/tests/clients/http/wrapper.go +++ b/tests/clients/http/wrapper.go @@ -106,6 +106,13 @@ func (w *Wrapper) PatchSchema( return w.client.PatchSchema(ctx, patch, migration, setAsDefaultVersion) } +func (w *Wrapper) PatchCollection( + ctx context.Context, + patch string, +) error { + return w.client.PatchCollection(ctx, patch) +} + func (w *Wrapper) SetActiveSchemaVersion(ctx context.Context, schemaVersionID string) error { return w.client.SetActiveSchemaVersion(ctx, schemaVersionID) } diff --git a/tests/integration/collection_description/simple_test.go b/tests/integration/collection_description/simple_test.go new file mode 100644 index 0000000000..1070e8cd99 --- /dev/null +++ b/tests/integration/collection_description/simple_test.go @@ -0,0 +1,42 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package collection_description + +import ( + "testing" + + "github.com/sourcenetwork/immutable" + + "github.com/sourcenetwork/defradb/client" + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrSimpleCreatesColGivenEmptyType(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.GetCollections{ + ExpectedResults: []client.CollectionDescription{ + { + ID: 1, + Name: immutable.Some("Users"), + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/add/collections_test.go b/tests/integration/collection_description/updates/add/collections_test.go new file mode 100644 index 0000000000..9193b57dc6 --- /dev/null +++ b/tests/integration/collection_description/updates/add/collections_test.go @@ -0,0 +1,107 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package add + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateAddCollections_WithUndefinedID_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "add", "path": "/2", "value": {"Name": "Dogs"} } + ] + `, + ExpectedError: "collection ID cannot be zero", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateAddCollections_WithZeroedID_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "add", "path": "/2", "value": {"ID": 0, "Name": "Dogs"} } + ] + `, + ExpectedError: "collection ID cannot be zero", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateAddCollections_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "add", "path": "/2", "value": {"ID": 2, "Name": "Dogs"} } + ] + `, + ExpectedError: "adding collections via patch is not supported. ID: 2", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateAddCollections_WithNoIndex_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "add", "path": "/-", "value": {"Name": "Dogs"} } + ] + `, + // We get this error because we are marshalling into a map[uint32]CollectionDescription, + // we will need to handle `-` when we allow adding collections via patches. + ExpectedError: "json: cannot unmarshal number - into Go value of type uint32", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/add/sources_test.go b/tests/integration/collection_description/updates/add/sources_test.go new file mode 100644 index 0000000000..37010aa15c --- /dev/null +++ b/tests/integration/collection_description/updates/add/sources_test.go @@ -0,0 +1,39 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package add + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateAddSources_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "add", "path": "/1/Sources/-", "value": {"SourceCollectionID": 1} } + ] + `, + ExpectedError: "collection sources cannot be mutated. CollectionID: 1", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/copy/name_test.go b/tests/integration/collection_description/updates/copy/name_test.go new file mode 100644 index 0000000000..b915d111ac --- /dev/null +++ b/tests/integration/collection_description/updates/copy/name_test.go @@ -0,0 +1,98 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package copy + +import ( + "testing" + + "github.com/sourcenetwork/immutable" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateCopyName_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.SchemaPatch{ + Patch: ` + [ + { "op": "add", "path": "/Users/Fields/-", "value": {"Name": "name", "Kind": "String"} } + ] + `, + SetAsDefaultVersion: immutable.Some(false), + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "copy", "from": "/1/Name", "path": "/2/Name" } + ] + `, + ExpectedError: "collection already exists. Name: Users", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateCopyName(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.SchemaPatch{ + Patch: ` + [ + { "op": "add", "path": "/Users/Fields/-", "value": {"Name": "name", "Kind": "String"} } + ] + `, + SetAsDefaultVersion: immutable.Some(false), + }, + testUtils.PatchCollection{ + // Activate the second collection by setting its name to that of the first, + // then decativate the original collection version by removing the name + Patch: ` + [ + { "op": "copy", "from": "/1/Name", "path": "/2/Name" }, + { "op": "remove", "path": "/1/Name" } + ] + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John" + }`, + }, + testUtils.Request{ + Request: `query { + Users { + name + } + }`, + Results: []map[string]any{ + { + "name": "John", + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/move/name_test.go b/tests/integration/collection_description/updates/move/name_test.go new file mode 100644 index 0000000000..f493b03c1a --- /dev/null +++ b/tests/integration/collection_description/updates/move/name_test.go @@ -0,0 +1,66 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package move + +import ( + "testing" + + "github.com/sourcenetwork/immutable" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateMoveName(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.SchemaPatch{ + Patch: ` + [ + { "op": "add", "path": "/Users/Fields/-", "value": {"Name": "name", "Kind": "String"} } + ] + `, + SetAsDefaultVersion: immutable.Some(false), + }, + testUtils.PatchCollection{ + // Make the second collection the active one by moving its name from the first to the second + Patch: ` + [ + { "op": "move", "from": "/1/Name", "path": "/2/Name" } + ] + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John" + }`, + }, + testUtils.Request{ + Request: `query { + Users { + name + } + }`, + Results: []map[string]any{ + { + "name": "John", + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/remove/collections_test.go b/tests/integration/collection_description/updates/remove/collections_test.go new file mode 100644 index 0000000000..b9363bde66 --- /dev/null +++ b/tests/integration/collection_description/updates/remove/collections_test.go @@ -0,0 +1,41 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package remove + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateRemoveCollections(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + } + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "remove", "path": "/1" } + ] + `, + ExpectedError: `collections cannot be deleted. CollectionID: 1`, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/remove/name_test.go b/tests/integration/collection_description/updates/remove/name_test.go new file mode 100644 index 0000000000..e352491cd7 --- /dev/null +++ b/tests/integration/collection_description/updates/remove/name_test.go @@ -0,0 +1,49 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package remove + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateRemoveName(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + } + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "remove", "path": "/1/Name" } + ] + `, + }, + testUtils.Request{ + Request: `query { + Users { + name + } + }`, + // The Users collection has been deactivated and is no longer accessible + ExpectedError: `Cannot query field "Users" on type "Query".`, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/replace/fields_test.go b/tests/integration/collection_description/updates/replace/fields_test.go new file mode 100644 index 0000000000..03aa8cdb1e --- /dev/null +++ b/tests/integration/collection_description/updates/replace/fields_test.go @@ -0,0 +1,39 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package replace + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateReplaceFields_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/Fields", "value": [{}] } + ] + `, + ExpectedError: "collection fields cannot be mutated. CollectionID: 1", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/replace/id_test.go b/tests/integration/collection_description/updates/replace/id_test.go new file mode 100644 index 0000000000..b83c634385 --- /dev/null +++ b/tests/integration/collection_description/updates/replace/id_test.go @@ -0,0 +1,146 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package replace + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateReplaceID_WithZero_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/ID", "value": 0 } + ] + `, + ExpectedError: "collection ID cannot be zero", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateReplaceID_WithExisting_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.SchemaUpdate{ + Schema: ` + type Books {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/ID", "value": 2 } + ] + `, + ExpectedError: "collection already exists. ID: 2", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateReplaceID_WithExistingSameRoot_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.SchemaPatch{ + Patch: ` + [ + { "op": "add", "path": "/Users/Fields/-", "value": {"Name": "name", "Kind": "String"} } + ] + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/ID", "value": 2 }, + { "op": "replace", "path": "/2/ID", "value": 1 } + ] + `, + ExpectedError: "collection sources cannot be mutated.", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateReplaceID_WithExistingDifferentRoot_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.SchemaUpdate{ + Schema: ` + type Dogs {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/ID", "value": 2 }, + { "op": "replace", "path": "/2/ID", "value": 1 } + ] + `, + ExpectedError: "collection root ID cannot be mutated.", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateReplaceID_WithNew_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/ID", "value": 2 } + ] + `, + ExpectedError: "adding collections via patch is not supported. ID: 2", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/replace/indexes_test.go b/tests/integration/collection_description/updates/replace/indexes_test.go new file mode 100644 index 0000000000..9302d1f192 --- /dev/null +++ b/tests/integration/collection_description/updates/replace/indexes_test.go @@ -0,0 +1,39 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package replace + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateReplaceIndexes_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/Indexes", "value": [{}] } + ] + `, + ExpectedError: "collection indexes cannot be mutated. CollectionID: 1", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/replace/name_test.go b/tests/integration/collection_description/updates/replace/name_test.go new file mode 100644 index 0000000000..98f1ba8c98 --- /dev/null +++ b/tests/integration/collection_description/updates/replace/name_test.go @@ -0,0 +1,210 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package replace + +import ( + "testing" + + "github.com/sourcenetwork/immutable" + + "github.com/sourcenetwork/defradb/client" + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateReplaceName_GivenExistingName(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + } + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John" + }`, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/Name", "value": "Actors" } + ] + `, + }, + testUtils.GetCollections{ + ExpectedResults: []client.CollectionDescription{ + { + ID: 1, + Name: immutable.Some("Actors"), + }, + }, + }, + testUtils.Request{ + Request: `query { + Users { + name + } + }`, + ExpectedError: `Cannot query field "Users" on type "Query".`, + }, + testUtils.Request{ + Request: `query { + Actors { + name + } + }`, + Results: []map[string]any{ + { + "name": "John", + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateReplaceName_GivenInactiveCollectionWithSameName_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + } + `, + }, + testUtils.SchemaPatch{ + Patch: ` + [ + { "op": "add", "path": "/Users/Fields/-", "value": {"Name": "foo", "Kind": "String"} } + ] + `, + SetAsDefaultVersion: immutable.Some(false), + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/2/Name", "value": "Users" } + ] + `, + ExpectedError: "collection already exists. Name: Users", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateReplaceName_GivenInactiveCollection_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + } + `, + }, + testUtils.SchemaPatch{ + Patch: ` + [ + { "op": "add", "path": "/Users/Fields/-", "value": {"Name": "foo", "Kind": "String"} } + ] + `, + SetAsDefaultVersion: immutable.Some(false), + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/2/Name", "value": "Actors" } + ] + `, + // The params at the end of the error message is dependant on the order Go decides to iterate through + // a map and so is not included in the test. + ExpectedError: "multiple versions of same collection cannot be active", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateReplaceName_RemoveExistingName(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + } + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John" + }`, + }, + testUtils.SchemaPatch{ + Patch: ` + [ + { "op": "add", "path": "/Users/Fields/-", "value": {"Name": "foo", "Kind": "String"} } + ] + `, + SetAsDefaultVersion: immutable.Some(false), + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "remove", "path": "/1/Name" }, + { "op": "replace", "path": "/2/Name", "value": "Actors" } + ] + `, + }, + testUtils.GetCollections{ + FilterOptions: client.CollectionFetchOptions{ + IncludeInactive: immutable.Some(true), + }, + ExpectedResults: []client.CollectionDescription{ + { + ID: 1, + }, + { + ID: 2, + Name: immutable.Some("Actors"), + Sources: []any{ + &client.CollectionSource{ + SourceCollectionID: 1, + }, + }, + }, + }, + }, + testUtils.Request{ + Request: `query { + Actors { + name + } + }`, + Results: []map[string]any{ + { + "name": "John", + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/replace/root_id_test.go b/tests/integration/collection_description/updates/replace/root_id_test.go new file mode 100644 index 0000000000..fee98f0664 --- /dev/null +++ b/tests/integration/collection_description/updates/replace/root_id_test.go @@ -0,0 +1,39 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package replace + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateReplaceRootID_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/RootID", "value": 2 } + ] + `, + ExpectedError: "collection root ID cannot be mutated. CollectionID: 1", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/replace/schema_version_id_test.go b/tests/integration/collection_description/updates/replace/schema_version_id_test.go new file mode 100644 index 0000000000..e4b1e7f42c --- /dev/null +++ b/tests/integration/collection_description/updates/replace/schema_version_id_test.go @@ -0,0 +1,39 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package replace + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateReplaceSchemaVersionID_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/SchemaVersionID", "value": "ghfdsas" } + ] + `, + ExpectedError: "collection schema version ID cannot be mutated. CollectionID: 1", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/replace/sources_test.go b/tests/integration/collection_description/updates/replace/sources_test.go new file mode 100644 index 0000000000..2d06e01d4a --- /dev/null +++ b/tests/integration/collection_description/updates/replace/sources_test.go @@ -0,0 +1,39 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package replace + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateReplaceSources_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/Sources", "value": [{"SourceCollectionID": 1}] } + ] + `, + ExpectedError: "collection sources cannot be mutated. CollectionID: 1", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/updates/test/name_test.go b/tests/integration/collection_description/updates/test/name_test.go new file mode 100644 index 0000000000..7baa13aca1 --- /dev/null +++ b/tests/integration/collection_description/updates/test/name_test.go @@ -0,0 +1,60 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package test + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestColDescrUpdateTestName(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "test", "path": "/1/Name", "value": "Users" } + ] + `, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestColDescrUpdateTestName_Fails(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users {} + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "test", "path": "/1/Name", "value": "Dogs" } + ] + `, + ExpectedError: "testing value /1/Name failed: test failed", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/schema/updates/add/field/kind/foreign_object_array_test.go b/tests/integration/schema/updates/add/field/kind/foreign_object_array_test.go index 95b19e1a59..f1f8c05411 100644 --- a/tests/integration/schema/updates/add/field/kind/foreign_object_array_test.go +++ b/tests/integration/schema/updates/add/field/kind/foreign_object_array_test.go @@ -34,7 +34,7 @@ func TestSchemaUpdatesAddFieldKindForeignObjectArray(t *testing.T) { { "op": "add", "path": "/Users/Fields/-", "value": {"Name": "foo", "Kind": 17} } ] `, - ExpectedError: "a `Schema` [name] must be provided when adding a new relation field. Field: foo, Kind: 17", + ExpectedError: "a schema name must be provided when adding a new relation field. Field: foo, Kind: 17", }, }, } diff --git a/tests/integration/schema/updates/add/field/kind/foreign_object_test.go b/tests/integration/schema/updates/add/field/kind/foreign_object_test.go index 525c41d658..794ce0a546 100644 --- a/tests/integration/schema/updates/add/field/kind/foreign_object_test.go +++ b/tests/integration/schema/updates/add/field/kind/foreign_object_test.go @@ -34,7 +34,7 @@ func TestSchemaUpdatesAddFieldKindForeignObject(t *testing.T) { { "op": "add", "path": "/Users/Fields/-", "value": {"Name": "foo", "Kind": 16} } ] `, - ExpectedError: "a `Schema` [name] must be provided when adding a new relation field. Field: foo, Kind: 16", + ExpectedError: "a schema name must be provided when adding a new relation field. Field: foo, Kind: 16", }, }, } diff --git a/tests/integration/schema/updates/index/simple_test.go b/tests/integration/schema/updates/add/field/with_index_test.go similarity index 88% rename from tests/integration/schema/updates/index/simple_test.go rename to tests/integration/schema/updates/add/field/with_index_test.go index fb506ec623..52815789f8 100644 --- a/tests/integration/schema/updates/index/simple_test.go +++ b/tests/integration/schema/updates/add/field/with_index_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Democratized Data Foundation +// Copyright 2024 Democratized Data Foundation // // Use of this software is governed by the Business Source License // included in the file licenses/BSL.txt. @@ -8,7 +8,7 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -package index +package field import ( "testing" @@ -16,7 +16,7 @@ import ( testUtils "github.com/sourcenetwork/defradb/tests/integration" ) -func TestPatching_ForCollectionWithIndex_StillWorks(t *testing.T) { +func TestSchemaUpdatesAddFieldSimple_WithExistingIndex(t *testing.T) { test := testUtils.TestCase{ Description: "Test patching schema for collection with index still works", Actions: []any{ diff --git a/tests/integration/schema/updates/add/simple_test.go b/tests/integration/schema/updates/add/simple_test.go index 0eac29b49a..88d36680b0 100644 --- a/tests/integration/schema/updates/add/simple_test.go +++ b/tests/integration/schema/updates/add/simple_test.go @@ -33,7 +33,7 @@ func TestSchemaUpdatesAddSimpleErrorsAddingSchema(t *testing.T) { { "op": "add", "path": "/-", "value": {"Name": "books"} } ] `, - ExpectedError: "unknown collection, adding collections via patch is not supported. Name: books", + ExpectedError: "adding collections via patch is not supported. Name: books", }, testUtils.Request{ Request: `query { diff --git a/tests/integration/schema/updates/copy/simple_test.go b/tests/integration/schema/updates/copy/simple_test.go index 206cd49b52..cdda8abaf8 100644 --- a/tests/integration/schema/updates/copy/simple_test.go +++ b/tests/integration/schema/updates/copy/simple_test.go @@ -38,7 +38,7 @@ func TestSchemaUpdatesCopyCollectionWithRemoveIDAndReplaceName(t *testing.T) { { "op": "replace", "path": "/Book/Name", "value": "Book" } ] `, - ExpectedError: "unknown collection, adding collections via patch is not supported. Name: Book", + ExpectedError: "adding collections via patch is not supported. Name: Book", }, }, } diff --git a/tests/integration/schema/updates/replace/simple_test.go b/tests/integration/schema/updates/replace/simple_test.go index 7729a274c9..722ff36f9b 100644 --- a/tests/integration/schema/updates/replace/simple_test.go +++ b/tests/integration/schema/updates/replace/simple_test.go @@ -44,7 +44,7 @@ func TestSchemaUpdatesReplaceCollectionErrors(t *testing.T) { // WARNING: An error is still expected if/when we allow the adding of collections, as this also // implies that the "Users" collection is to be deleted. Only once we support the adding *and* // removal of collections should this not error. - ExpectedError: "unknown collection, adding collections via patch is not supported. Name: Book", + ExpectedError: "adding collections via patch is not supported. Name: Book", }, }, } diff --git a/tests/integration/test_case.go b/tests/integration/test_case.go index ce6e456fbb..7cda289319 100644 --- a/tests/integration/test_case.go +++ b/tests/integration/test_case.go @@ -97,6 +97,18 @@ type SchemaPatch struct { ExpectedError string } +type PatchCollection struct { + // NodeID may hold the ID (index) of a node to apply this patch to. + // + // If a value is not provided the patch will be applied to all nodes. + NodeID immutable.Option[int] + + // The Patch to apply to the collection description. + Patch string + + ExpectedError string +} + // GetSchema is an action that fetches schema using the provided options. type GetSchema struct { // NodeID may hold the ID (index) of a node to apply this patch to. diff --git a/tests/integration/utils2.go b/tests/integration/utils2.go index d5cdcbd01d..40b2c81d86 100644 --- a/tests/integration/utils2.go +++ b/tests/integration/utils2.go @@ -260,6 +260,9 @@ func performAction( case SchemaPatch: patchSchema(s, action) + case PatchCollection: + patchCollection(s, action) + case GetSchema: getSchema(s, action) @@ -1005,6 +1008,22 @@ func patchSchema( refreshIndexes(s) } +func patchCollection( + s *state, + action PatchCollection, +) { + for _, node := range getNodes(action.NodeID, s.nodes) { + err := node.PatchCollection(s.ctx, action.Patch) + expectedErrorRaised := AssertError(s.t, s.testCase.Description, err, action.ExpectedError) + + assertExpectedErrorRaised(s.t, s.testCase.Description, action.ExpectedError, expectedErrorRaised) + } + + // If the schema was updated we need to refresh the collection definitions. + refreshCollections(s) + refreshIndexes(s) +} + func getSchema( s *state, action GetSchema,