diff --git a/CHANGELOG.md b/CHANGELOG.md index d2aedb71da..6cb58b14b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## vNext (TBD) ### Enhancements +* Added two new methods on `Migration` (Issue [#2543](https://github.com/realm/realm-dotnet/issues/2543)): + * `RemoveType(typeName)` allows to completely remove a type and its schema from a realm during a migration. + * `RenameProperty(typeName, oldPropertyName, newPropertyName)` allows to rename a property during a migration. * A Realm Schema can now be constructed at runtime as opposed to generated automatically from the model classes. The automatic generation continues to work and should cover the needs of the vast majority of Realm users. Manually constructing the schema may be required when the shape of the objects depends on some information only known at runtime or in very rare cases where it may provide performance benefits by representing a collection of known size as properties on the class. (Issue [#824](https://github.com/realm/realm-dotnet/issues/824)) * `RealmConfiguration.ObjectClasses` has now been deprecated in favor of `RealmConfiguration.Schema`. `RealmSchema` has an implicit conversion operator from `Type[]` so code that previously looked like `ObjectClasses = new[] { typeof(Foo), typeof(Bar) }` can be trivially updated to `Schema = new[] { typeof(Foo), typeof(Bar) }`. * `Property` has been converted to a read-only struct by removing the setters from its properties. Those didn't do anything previously, so we don't expect anyone was using them. diff --git a/Realm/Realm/Handles/SharedRealmHandle.cs b/Realm/Realm/Handles/SharedRealmHandle.cs index 9317999cff..0686eaa9c3 100644 --- a/Realm/Realm/Handles/SharedRealmHandle.cs +++ b/Realm/Realm/Handles/SharedRealmHandle.cs @@ -53,9 +53,11 @@ private static class NativeMethods [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate void LogMessageCallback(PrimitiveValue message, LogLevel level); + // migrationSchema is a special schema that is used only in the context of a migration block. + // It is a pointer because we need to be able to modify this schema in some migration methods directly in core. [return: MarshalAs(UnmanagedType.U1)] [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - internal delegate bool MigrationCallback(IntPtr oldRealm, IntPtr newRealm, Native.Schema oldSchema, ulong schemaVersion, IntPtr managedMigrationHandle); + internal delegate bool MigrationCallback(IntPtr oldRealm, IntPtr newRealm, IntPtr migrationSchema, Native.Schema oldSchema, ulong schemaVersion, IntPtr managedMigrationHandle); [return: MarshalAs(UnmanagedType.U1)] [UnmanagedFunctionPointer(CallingConvention.Cdecl)] @@ -181,6 +183,17 @@ public static extern void install_callbacks( [DllImport(InteropConfig.DLL_NAME, EntryPoint = "shared_realm_create_results", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr create_results(SharedRealmHandle sharedRealm, UInt32 table_key, out NativeException ex); + [DllImport(InteropConfig.DLL_NAME, EntryPoint = "shared_realm_rename_property", CallingConvention = CallingConvention.Cdecl)] + public static extern void rename_property(SharedRealmHandle sharedRealm, + [MarshalAs(UnmanagedType.LPWStr)] string typeName, IntPtr typeNameLength, + [MarshalAs(UnmanagedType.LPWStr)] string oldName, IntPtr oldNameLength, + [MarshalAs(UnmanagedType.LPWStr)] string newName, IntPtr newNameLength, + IntPtr migrationSchema, + out NativeException ex); + + [DllImport(InteropConfig.DLL_NAME, EntryPoint = "shared_realm_remove_type", CallingConvention = CallingConvention.Cdecl)] + public static extern bool remove_type(SharedRealmHandle sharedRealm, [MarshalAs(UnmanagedType.LPWStr)] string typeName, IntPtr typeLength, out NativeException ex); + #pragma warning restore SA1121 // Use built-in type alias #pragma warning restore IDE0049 // Use built-in type alias } @@ -464,6 +477,20 @@ public bool TryFindObject(TableKey tableKey, in RealmValue id, out ObjectHandle return true; } + public void RenameProperty(string typeName, string oldName, string newName, IntPtr migrationSchema) + { + NativeMethods.rename_property(this, typeName, (IntPtr)typeName.Length, + oldName, (IntPtr)oldName.Length, newName, (IntPtr)newName.Length, migrationSchema, out var nativeException); + nativeException.ThrowIfNecessary(); + } + + public bool RemoveType(string typeName) + { + var result = NativeMethods.remove_type(this, typeName, (IntPtr)typeName.Length, out var nativeException); + nativeException.ThrowIfNecessary(); + return result; + } + public ResultsHandle CreateResults(TableKey tableKey) { var result = NativeMethods.create_results(this, tableKey.Value, out var nativeException); @@ -521,7 +548,7 @@ private static void LogMessage(PrimitiveValue message, LogLevel level) } [MonoPInvokeCallback(typeof(NativeMethods.MigrationCallback))] - private static bool OnMigration(IntPtr oldRealmPtr, IntPtr newRealmPtr, Native.Schema oldSchema, ulong schemaVersion, IntPtr managedMigrationHandle) + private static bool OnMigration(IntPtr oldRealmPtr, IntPtr newRealmPtr, IntPtr migrationSchema, Native.Schema oldSchema, ulong schemaVersion, IntPtr managedMigrationHandle) { var migrationHandle = GCHandle.FromIntPtr(managedMigrationHandle); var migration = (Migration)migrationHandle.Target; @@ -538,7 +565,7 @@ private static bool OnMigration(IntPtr oldRealmPtr, IntPtr newRealmPtr, Native.S var newRealmHandle = new UnownedRealmHandle(newRealmPtr); var newRealm = new Realm(newRealmHandle, migration.Configuration, migration.Schema); - var result = migration.Execute(oldRealm, newRealm); + var result = migration.Execute(oldRealm, newRealm, migrationSchema); return result; } diff --git a/Realm/Realm/Migration.cs b/Realm/Realm/Migration.cs index 1950e681b3..0fbb210e32 100644 --- a/Realm/Realm/Migration.cs +++ b/Realm/Realm/Migration.cs @@ -18,6 +18,7 @@ using System; using System.Runtime.InteropServices; +using Realms.Helpers; using Realms.Schema; namespace Realms @@ -34,6 +35,7 @@ namespace Realms public class Migration { private GCHandle? _handle; + private IntPtr _migrationSchema; internal GCHandle MigrationHandle => _handle ?? throw new ObjectDisposedException(nameof(Migration)); @@ -62,10 +64,11 @@ internal Migration(RealmConfiguration configuration, RealmSchema schema) _handle = GCHandle.Alloc(this); } - internal bool Execute(Realm oldRealm, Realm newRealm) + internal bool Execute(Realm oldRealm, Realm newRealm, IntPtr migrationSchema) { OldRealm = oldRealm; NewRealm = newRealm; + _migrationSchema = migrationSchema; try { @@ -83,11 +86,68 @@ internal bool Execute(Realm oldRealm, Realm newRealm) NewRealm.Dispose(); NewRealm = null; + + _migrationSchema = IntPtr.Zero; } return true; } + /// + /// Removes a type during a migration. All the data associated with the type, as well as its schema, will be removed from . + /// + /// The type that needs to be removed. + /// + /// The removed type will still be accessible from in the migration block. + /// The type must not be present in the new schema. can be used on if one needs to delete the content of the table. + /// + /// true if the type does exist in the old schema, false otherwise. + public bool RemoveType(string typeName) + { + Argument.NotNullOrEmpty(typeName, nameof(typeName)); + return NewRealm.SharedRealmHandle.RemoveType(typeName); + } + + /// + /// Renames a property during a migration. + /// + /// The type for which the property rename needs to be performed. + /// The previous name of the property. + /// The new name of the property. + /// + /// + /// // Model in the old schema + /// class Dog : RealmObject + /// { + /// public string DogName { get; set; } + /// } + /// + /// // Model in the new schema + /// class Dog : RealmObject + /// { + /// public string Name { get; set; } + /// } + /// + /// //After the migration Dog.Name will contain the same values as Dog.DogName from the old realm, without the need to copy them explicitly + /// var config = new RealmConfiguration + /// { + /// SchemaVersion = 1, + /// MigrationCallback = (migration, oldSchemaVersion) => + /// { + /// migration.RenameProperty("Dog", "DogName", "Name"); + /// } + /// }; + /// + /// + public void RenameProperty(string typeName, string oldPropertyName, string newPropertyName) + { + Argument.NotNullOrEmpty(typeName, nameof(typeName)); + Argument.NotNullOrEmpty(oldPropertyName, nameof(oldPropertyName)); + Argument.NotNullOrEmpty(newPropertyName, nameof(newPropertyName)); + + NewRealm.SharedRealmHandle.RenameProperty(typeName, oldPropertyName, newPropertyName, _migrationSchema); + } + internal void ReleaseHandle() { _handle?.Free(); diff --git a/Tests/Realm.Tests/Database/MigrationTests.cs b/Tests/Realm.Tests/Database/MigrationTests.cs index c7e2e16899..15f3ba4410 100644 --- a/Tests/Realm.Tests/Database/MigrationTests.cs +++ b/Tests/Realm.Tests/Database/MigrationTests.cs @@ -17,9 +17,11 @@ //////////////////////////////////////////////////////////////////////////// using System; +using System.Collections.Generic; using System.IO; using System.Linq; using NUnit.Framework; +using Realms.Exceptions; using Realms.Schema; namespace Realms.Tests.Database @@ -116,7 +118,7 @@ public void ExceptionInMigrationCallback() } }; - var ex = Assert.Throws(() => GetRealm(configuration).Dispose()); + var ex = Assert.Throws(() => GetRealm(configuration)); Assert.That(ex.Flatten().InnerException, Is.SameAs(dummyException)); } @@ -146,7 +148,7 @@ public void MigrationTriggersDelete() }); } - var newConfig = new RealmConfiguration + var newConfig = new RealmConfiguration(path) { IsDynamic = true, ShouldDeleteIfMigrationNeeded = true, @@ -170,5 +172,241 @@ public void MigrationTriggersDelete() }); } } + + [Test] + public void MigrationRenameProperty() + { + var path = TestHelpers.CopyBundledFileToDocuments(FileToMigrate, Path.Combine(InteropConfig.DefaultStorageFolder, Guid.NewGuid().ToString())); + + var configuration = new RealmConfiguration(path) + { + SchemaVersion = 100, + MigrationCallback = (migration, oldSchemaVersion) => + { + migration.RenameProperty(nameof(Person), "TriggersSchema", nameof(Person.OptionalAddress)); + + var oldPeople = (IQueryable)migration.OldRealm.DynamicApi.All("Person"); + var newPeople = migration.NewRealm.All(); + + for (var i = 0; i < newPeople.Count(); i++) + { + var oldPerson = oldPeople.ElementAt(i); + var newPerson = newPeople.ElementAt(i); + + var oldValue = oldPerson.DynamicApi.Get("TriggersSchema"); + var newValue = newPerson.OptionalAddress; + Assert.That(newValue, Is.EqualTo(oldValue)); + } + } + }; + + using var realm = GetRealm(configuration); + } + + [Test] + public void MigrationRenamePropertyErrors() + { + var path = TestHelpers.CopyBundledFileToDocuments(FileToMigrate, Path.Combine(InteropConfig.DefaultStorageFolder, Guid.NewGuid().ToString())); + + var oldPropertyValues = new List(); + + var configuration = new RealmConfiguration(path) + { + SchemaVersion = 100, + MigrationCallback = (migration, oldSchemaVersion) => + { + migration.RenameProperty(nameof(Person), "TriggersSchema", "PropertyNotInNewSchema"); + } + }; + + var ex = Assert.Throws(() => GetRealm(configuration)); + Assert.That(ex.Message, Does.Contain("Renamed property 'Person.PropertyNotInNewSchema' does not exist")); + + configuration = new RealmConfiguration(path) + { + SchemaVersion = 100, + MigrationCallback = (migration, oldSchemaVersion) => + { + migration.RenameProperty(nameof(Person), "PropertyNotInOldSchema", nameof(Person.OptionalAddress)); + } + }; + + var ex2 = Assert.Throws(() => GetRealm(configuration)); + Assert.That(ex2.Flatten().InnerException.Message, Does.Contain("Cannot rename property 'Person.PropertyNotInOldSchema' because it does not exist")); + + configuration = new RealmConfiguration(path) + { + SchemaVersion = 100, + MigrationCallback = (migration, oldSchemaVersion) => + { + migration.RenameProperty("NonExistingType", "TriggersSchema", nameof(Person.OptionalAddress)); + } + }; + + ex2 = Assert.Throws(() => GetRealm(configuration)); + Assert.That(ex2.Flatten().InnerException.Message, Does.Contain("Cannot rename properties for type 'NonExistingType' because it does not exist")); + + configuration = new RealmConfiguration(path) + { + SchemaVersion = 100, + MigrationCallback = (migration, oldSchemaVersion) => + { + migration.RenameProperty(nameof(Person), "TriggersSchema", nameof(Person.Birthday)); + } + }; + + ex2 = Assert.Throws(() => GetRealm(configuration)); + Assert.That(ex2.Flatten().InnerException.Message, Does.Contain("Cannot rename property 'Person.TriggersSchema' to 'Birthday' because it would change from type 'string' to 'date'.")); + + configuration = new RealmConfiguration(path) + { + SchemaVersion = 100, + MigrationCallback = (migration, oldSchemaVersion) => + { + migration.RenameProperty(nameof(Person), nameof(Person.Latitude), nameof(Person.Longitude)); + } + }; + + ex2 = Assert.Throws(() => GetRealm(configuration)); + Assert.That(ex2.Flatten().InnerException.Message, Does.Contain("Cannot rename property 'Person.Latitude' to 'Longitude' because the source property still exists.")); + } + + [Test] + public void MigrationRenamePropertyInvalidArguments() + { + var path = TestHelpers.CopyBundledFileToDocuments(FileToMigrate, Path.Combine(InteropConfig.DefaultStorageFolder, Guid.NewGuid().ToString())); + + var configuration = new RealmConfiguration(path) + { + SchemaVersion = 100, + MigrationCallback = (migration, oldSchemaVersion) => + { + Assert.Throws(() => migration.RenameProperty(null, "TriggersSchema", "OptionalAddress")); + Assert.Throws(() => migration.RenameProperty("Person", null, "OptionalAddress")); + Assert.Throws(() => migration.RenameProperty("Person", "TriggersSchema", null)); + + Assert.Throws(() => migration.RenameProperty(string.Empty, "TriggersSchema", "OptionalAddress")); + Assert.Throws(() => migration.RenameProperty("Person", string.Empty, "OptionalAddress")); + Assert.Throws(() => migration.RenameProperty("Person", "TriggersSchema", string.Empty)); + } + }; + + using var realm = GetRealm(configuration); + } + + [Test] + public void MigrationRemoveTypeInSchema() + { + var oldRealmConfig = new RealmConfiguration() + { + SchemaVersion = 0, + Schema = new[] { typeof(Dog), typeof(Owner), typeof(Person) }, + }; + + using (var oldRealm = GetRealm(oldRealmConfig)) + { + } + + var newRealmConfig = new RealmConfiguration() + { + SchemaVersion = 1, + Schema = new[] { typeof(PrimaryKeyObjectIdObject), typeof(Person) }, + MigrationCallback = (migration, oldSchemaVersion) => + { + migration.RemoveType(nameof(Person)); + } + }; + + var ex = Assert.Throws(() => GetRealm(newRealmConfig)); + Assert.That(ex.Flatten().InnerException.Message, Does.Contain("Attempted to remove type 'Person', that is present in the current schema")); + } + + [Test] + public void MigrationRemoveTypeNotInSchema() + { + var oldRealmConfig = new RealmConfiguration() + { + SchemaVersion = 0, + Schema = new[] { typeof(Dog), typeof(Owner), typeof(Person) }, + }; + + using (var oldRealm = GetRealm(oldRealmConfig)) + { + oldRealm.Write(() => + { + oldRealm.Add(new Person { FirstName = "Maria" }); + }); + } + + var migrationCallbackCalled = false; + var newRealmConfig = new RealmConfiguration() + { + SchemaVersion = 1, + Schema = new[] { typeof(Dog), typeof(Owner) }, + MigrationCallback = (migration, oldSchemaVersion) => + { + migrationCallbackCalled = true; + + var migrationResultNotInSchema = migration.RemoveType("NotInSchemaType"); + Assert.That(migrationResultNotInSchema, Is.False); + + var migrationResult = migration.RemoveType(nameof(Person)); + Assert.That(migrationResult, Is.True); + + // Removed type in oldRealm is still available for the duration of the migration + var oldPeople = (IQueryable)migration.OldRealm.DynamicApi.All("Person"); + var oldPerson = oldPeople.First(); + + Assert.That(oldPeople.Count(), Is.EqualTo(1)); + Assert.That(oldPerson.DynamicApi.Get("FirstName"), Is.EqualTo("Maria")); + } + }; + + using (var newRealm = GetRealm(newRealmConfig)) + { + Assert.That(migrationCallbackCalled, Is.True); + + // This just means that "Person" is not in the schema that we pass in the config, but we're not sure it has been removed. + Assert.That(newRealm.Schema.TryFindObjectSchema("Person", out _), Is.False); + } + + var newRealmDynamicConfig = new RealmConfiguration() + { + SchemaVersion = 1, + IsDynamic = true, + }; + + using var newRealmDynamic = GetRealm(newRealmDynamicConfig); + + // This means that "Person" is not in the schema anymore, as we retrieve it directly from core. + Assert.That(newRealmDynamic.Schema.TryFindObjectSchema("Person", out _), Is.False); + } + + [Test] + public void MigrationRemoveTypeInvalidArguments() + { + var oldRealmConfig = new RealmConfiguration() + { + SchemaVersion = 0, + Schema = new[] { typeof(Dog), typeof(Owner), typeof(Person) }, + }; + + using (var oldRealm = GetRealm(oldRealmConfig)) + { + } + + var newRealmConfig = new RealmConfiguration() + { + SchemaVersion = 1, + Schema = new[] { typeof(PrimaryKeyObjectIdObject), typeof(Person) }, + MigrationCallback = (migration, oldSchemaVersion) => + { + Assert.Throws(() => migration.RemoveType(null)); + Assert.Throws(() => migration.RemoveType(string.Empty)); + } + }; + + using var realm = GetRealm(newRealmConfig); + } } } diff --git a/wrappers/src/shared_realm_cs.cpp b/wrappers/src/shared_realm_cs.cpp index 3976140a84..650d91f91e 100644 --- a/wrappers/src/shared_realm_cs.cpp +++ b/wrappers/src/shared_realm_cs.cpp @@ -46,7 +46,7 @@ using RealmChangedT = void(void* managed_state_handle); using GetNativeSchemaT = void(SchemaForMarshaling schema, void* managed_callback); using OnBindingContextDestructedT = void(void* managed_handle); using LogMessageT = void(realm_value_t message, util::Logger::Level level); -using MigrationCallbackT = bool(realm::SharedRealm* old_realm, realm::SharedRealm* new_realm, SchemaForMarshaling, uint64_t schema_version, void* managed_migration_handle); +using MigrationCallbackT = bool(realm::SharedRealm* old_realm, realm::SharedRealm* new_realm, Schema* migration_schema, SchemaForMarshaling, uint64_t schema_version, void* managed_migration_handle); using ShouldCompactCallbackT = bool(void* managed_config_handle, uint64_t total_size, uint64_t data_size); namespace realm { std::function s_object_notification_callback; @@ -180,7 +180,8 @@ REALM_EXPORT SharedRealm* shared_realm_open(Configuration configuration, SchemaO config.schema_version = configuration.schema_version; if (configuration.managed_migration_handle) { - config.migration_function = [&configuration](SharedRealm oldRealm, SharedRealm newRealm, Schema schema) { + config.migration_function = [&configuration](SharedRealm oldRealm, SharedRealm newRealm, Schema& migrationSchema) { + std::vector schema_objects; std::vector schema_properties; @@ -195,7 +196,7 @@ REALM_EXPORT SharedRealm* shared_realm_open(Configuration configuration, SchemaO schema_properties.data() }; - if (!s_on_migration(&oldRealm, &newRealm, schema_for_marshaling, oldRealm->schema_version(), configuration.managed_migration_handle)) { + if (!s_on_migration(&oldRealm, &newRealm, &migrationSchema, schema_for_marshaling, oldRealm->schema_version(), configuration.managed_migration_handle)) { throw ManagedExceptionDuringMigration(); } }; @@ -547,4 +548,41 @@ REALM_EXPORT Results* shared_realm_create_results(SharedRealm& realm, TableKey t }); } +REALM_EXPORT void shared_realm_rename_property(const SharedRealm& realm, uint16_t* type_name_buf, size_t type_name_len, + uint16_t* old_name_buf, size_t old_Name_len, uint16_t* new_name_buf, size_t new_name_len, Schema* migration_schema, NativeException::Marshallable& ex) +{ + return handle_errors(ex, [&]() { + Utf16StringAccessor type_name_str(type_name_buf, type_name_len); + Utf16StringAccessor old_name_str(old_name_buf, old_Name_len); + Utf16StringAccessor new_name_str(new_name_buf, new_name_len); + + ObjectStore::rename_property(realm->read_group(), *migration_schema, type_name_str, old_name_str, new_name_str); + }); +} + +REALM_EXPORT bool shared_realm_remove_type(const SharedRealm& realm, uint16_t* type_name_buf, size_t type_name_len, NativeException::Marshallable& ex) +{ + return handle_errors(ex, [&]() { + Utf16StringAccessor type_name_str(type_name_buf, type_name_len); + + auto table = ObjectStore::table_for_object_type(realm->read_group(), type_name_str); + // If the table does not exist then we return false + if (!table) + { + return false; + } + + const auto obj_schema = realm->schema().find(type_name_str); + // If the table exists, but it's in the current schema, then we throw an exception + // User can always exclude it from schema in config, or remove it completely + if (obj_schema != realm->schema().end()) + { + throw std::runtime_error(util::format("Attempted to remove type '%1', that is present in the current schema", type_name_str.to_string())); + } + + realm->read_group().remove_table(table->get_key()); + return true; + }); +} + } diff --git a/wrappers/src/shared_realm_cs.hpp b/wrappers/src/shared_realm_cs.hpp index b8c38565c1..d74f536bcf 100644 --- a/wrappers/src/shared_realm_cs.hpp +++ b/wrappers/src/shared_realm_cs.hpp @@ -34,8 +34,7 @@ using SharedSyncUser = std::shared_ptr; using namespace realm; using namespace realm::binding; -class ManagedExceptionDuringMigration : public std::runtime_error -{ +class ManagedExceptionDuringMigration : public std::runtime_error { public: ManagedExceptionDuringMigration() : std::runtime_error("Uncaught .NET exception during Realm migration") { }