Core - Fix storing, cloning and migrating profiles

- New profiles are stored at the current migration version, so they're no longer migrated on every start and can be duplicated right away
- A category created at the top is listed first right away, so its profiles render on top
- CloneProfileConfiguration makes a copy of the stored data, saving a clone stores its profile through the original
- Importing a profile raises ProfileAdded
- A profile configuration that isn't valid JSON is logged and skipped instead of stopping all migrations
- A stored profile without a version is migrated from version 0, like an imported one
- ProfileCategoryRepository.IsUnique returns true when the name is free
- M0001 and M0004 leave a missing node type or storage out instead of writing null
- M0003 also unwraps a list that is an item of another list
This commit is contained in:
RobertBeekman committed 2026-10-20 09:47:43 +02:00
1 parent 4296b3ffe7
commit 32b0f7169b
14 files changed
+118 -90

No files matched your search

@@ -65,16 +65,11 @@ public class ProfileCategoryTests : ProfileServiceTestBase
} }
[Fact] [Fact]
public void CreateProfileCategory_AtTheTop_IsListedLastUntilTheCategoriesAreSortedAgain() public void CreateProfileCategory_AtTheTop_IsListedFirst()
{ {
// Profiles are updated and rendered in list order, the first category ends up on top
ProfileCategory category = ProfileService.CreateProfileCategory("Tests", true); ProfileCategory category = ProfileService.CreateProfileCategory("Tests", true);
// Pinned as-is: the category is created with order 1 but appended to the end of ProfileCategories. Profiles are
// updated and rendered in list order (the first category ends up on top), so until something saves a category,
// which sorts the list, this category's profiles are drawn underneath the others rather than on top
ProfileService.ProfileCategories.Last().ShouldBeSameAs(category);
ProfileService.SaveProfileCategory(General);
ProfileService.ProfileCategories.First().ShouldBeSameAs(category); ProfileService.ProfileCategories.First().ShouldBeSameAs(category);
} }
@@ -254,7 +249,7 @@ public class ProfileCategoryTests : ProfileServiceTestBase
} }
[Fact] [Fact]
public void CloneProfileConfiguration_SharesTheStoredDataWithTheOriginal() public void CloneProfileConfiguration_HasItsOwnCopyOfTheStoredData()
{ {
ProfileConfiguration original = ProfileService.CreateProfileConfiguration(General, "Original", "TestTube"); ProfileConfiguration original = ProfileService.CreateProfileConfiguration(General, "Original", "TestTube");
@@ -267,12 +262,11 @@ public class ProfileCategoryTests : ProfileServiceTestBase
// The clone is not added to the category // The clone is not added to the category
General.ProfileConfigurations.ShouldBe([original]); General.ProfileConfigurations.ShouldBe([original]);
// Pinned as-is: documented as creating a copy, but the clone uses the same entity as the original, so saving clone.Entity.ShouldNotBeSameAs(original.Entity);
// changes made to the clone changes the original's stored data too clone.Entity.Profile.ShouldNotBeSameAs(original.Entity.Profile);
clone.Entity.ShouldBeSameAs(original.Entity);
clone.Name = "Changed through the clone"; clone.Name = "Changed through the clone";
clone.Save(); clone.Save();
original.Entity.ProfileConfiguration.Name.ShouldBe("Changed through the clone"); original.Entity.ProfileConfiguration.Name.ShouldBe("Original");
} }
[Fact] [Fact]
@@ -128,7 +128,7 @@ public class ProfileImportExportTests : ProfileServiceTestBase
} }
[Fact] [Fact]
public async Task ImportProfile_DoesNotRaiseProfileAdded() public async Task ImportProfile_RaisesProfileAdded()
{ {
ProfileConfiguration original = CreateSavedProfile("Test profile"); ProfileConfiguration original = CreateSavedProfile("Test profile");
int profileAdded = 0; int profileAdded = 0;
@@ -138,9 +138,7 @@ public class ProfileImportExportTests : ProfileServiceTestBase
await ImportExport(original, General, true); await ImportExport(original, General, true);
// Pinned as-is: creating a profile raises ProfileAdded but importing one doesn't, only the category's own profileAdded.ShouldBe(1);
// ProfileConfigurationAdded event tells listeners about an imported profile
profileAdded.ShouldBe(0);
addedToCategory.ShouldBe(1); addedToCategory.ShouldBe(1);
} }
@@ -291,42 +289,41 @@ public class ProfileImportExportTests : ProfileServiceTestBase
} }
[Fact] [Fact]
public void ProfilesThatWereNeverSavedInTheEditor_AreStoredAsVersionZero() public void NewProfiles_AreStoredAtTheCurrentVersion()
{ {
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBe(0); int version = GetStoredProfiles().Single().ProfileConfiguration.Version;
version.ShouldBeGreaterThan(0);
ProfileService.SaveProfile(ProfileService.ActivateProfile(configuration), true); ProfileService.SaveProfile(ProfileService.ActivateProfile(configuration), true);
GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBeGreaterThan(0); GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBe(version);
} }
[Fact] [Fact]
public void ProfilesThatWereNeverSavedInTheEditor_FailToMigrateOnStartupButStillLoad() public void NewProfiles_AreLeftAloneByTheMigrationsOnStartup()
{ {
ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
int version = GetStoredProfiles().Single().ProfileConfiguration.Version;
// Artemis migrates stored profiles once on startup, a failure is logged and the profile is left as it is // Artemis migrates stored profiles once on startup
Resolve<IProfileRepository>().MigrateProfiles(); Resolve<IProfileRepository>().MigrateProfiles();
GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBe(0); GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBe(version);
CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single().Name.ShouldBe("Test profile"); CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single().Name.ShouldBe("Test profile");
} }
[Fact] [Fact]
public async Task ImportProfile_OfAProfileThatWasNeverSavedInTheEditor_Fails() public async Task ImportProfile_OfAProfileThatWasNeverSavedInTheEditor_Works()
{ {
// Duplicating a freshly created profile from the sidebar or the editor is an export followed by an import
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
await using Stream export = await ProfileService.ExportProfile(configuration); await using Stream export = await ProfileService.ExportProfile(configuration);
// Pinned as-is: saving the category (which is all creating a profile does) stores the profile without setting its ProfileConfiguration imported = await ProfileService.ImportProfile(export, General, true, false, "copy");
// migration version, only SaveProfile sets it. The export says version 0, so importing runs every migration on
// JSON that is already up to date and the first one fails on it. Duplicating a freshly created profile from the imported.Name.ShouldBe("Test profile - copy");
// sidebar or the editor (an export followed by an import) runs into this. Restarting Artemis runs into it too, but General.ProfileConfigurations.Count.ShouldBe(2);
// there the failed migration is only logged and the profile loads normally, see the test above
InvalidOperationException exception = await Should.ThrowAsync<InvalidOperationException>(() => ProfileService.ImportProfile(export, General, true, false, "copy"));
exception.StackTrace!.ShouldContain("M0001NodeProviders");
General.ProfileConfigurations.ShouldBe([configuration]);
} }
/// <summary> /// <summary>
@@ -112,7 +112,7 @@ public class M0001NodeProvidersTests
} }
[Fact] [Fact]
public void Migrate_NodeWithoutType_GetsANullType() public void Migrate_NodeWithoutType_LeavesTheTypeOut()
{ {
JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile); JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile);
JsonObject node = profile.FindNode(OldProfileJson.LayerTransformNodeId); JsonObject node = profile.FindNode(OldProfileJson.LayerTransformNodeId);
@@ -120,10 +120,7 @@ public class M0001NodeProvidersTests
_migration.Migrate(new JsonObject(), profile); _migration.Migrate(new JsonObject(), profile);
// Pinned as-is: a missing type is written back as "Type": null rather than left out, which gives the NodeEntity node.ContainsKey("Type").ShouldBeFalse();
// a null type even though the property isn't nullable
node.ContainsKey("Type").ShouldBeTrue();
node["Type"].ShouldBeNull();
node["ProviderId"]!.GetValue<string>().ShouldBe(OldProfileJson.GeneralNodesProviderId); node["ProviderId"]!.GetValue<string>().ShouldBe(OldProfileJson.GeneralNodesProviderId);
} }
@@ -115,7 +115,7 @@ public class M0003SystemTextJsonTests
} }
[Fact] [Fact]
public void Migrate_ListDirectlyInsideAList_KeepsItsValuesWrapper() public void Migrate_ListDirectlyInsideAList_LosesItsValuesWrapperToo()
{ {
JsonObject profile = OldProfileJson.Parse(""" JsonObject profile = OldProfileJson.Parse("""
{"Rows": {"$type": "List", "$values": [{"$type": "List", "$values": [1, 2]}]}} {"Rows": {"$type": "List", "$values": [{"$type": "List", "$values": [1, 2]}]}}
@@ -123,9 +123,7 @@ public class M0003SystemTextJsonTests
_migration.Migrate(new JsonObject(), profile); _migration.Migrate(new JsonObject(), profile);
// Pinned as-is: items of a list are only searched for lists in their properties, a list that is itself an item profile.ShouldBeJson("""{"Rows": [[1, 2]]}""");
// keeps its "$values" wrapper. No profile entity has a list of lists, so old profiles aren't affected.
profile.ShouldBeJson("""{"Rows": [{"$values": [1, 2]}]}""");
} }
[Fact] [Fact]
@@ -122,7 +122,7 @@ public class M0004NodeStorageTests
} }
[Fact] [Fact]
public void Migrate_NodeWithoutStorage_GetsANullStorage() public void Migrate_NodeWithoutStorage_LeavesTheStorageOut()
{ {
(JsonObject configuration, JsonObject profile) = Version3Profile(); (JsonObject configuration, JsonObject profile) = Version3Profile();
JsonObject node = profile.FindNode(OldProfileJson.LayerTransformNodeId); JsonObject node = profile.FindNode(OldProfileJson.LayerTransformNodeId);
@@ -130,10 +130,7 @@ public class M0004NodeStorageTests
_migration.Migrate(configuration, profile); _migration.Migrate(configuration, profile);
// Pinned as-is: a missing storage is written back as "Storage": null rather than left out, which gives the node.ContainsKey("Storage").ShouldBeFalse();
// NodeEntity a null storage even though the property isn't nullable
node.ContainsKey("Storage").ShouldBeTrue();
node["Storage"].ShouldBeNull();
} }
[Fact] [Fact]
@@ -147,8 +147,8 @@ public class ProfileMigrationTests : IDisposable
configuration.Remove("Version"); configuration.Remove("Version");
JsonObject profile = OldProfileJson.Parse(OldProfileJson.MigratedProfile); JsonObject profile = OldProfileJson.Parse(OldProfileJson.MigratedProfile);
// Pinned as-is: without a version the profile is taken to be from before version 1 and M0001 fails on the // Without a version the profile is taken to be from before version 1, the version was added to tell those apart.
// current format, so a profile in the current format without a version can't be imported // M0001 fails on the current format, so a profile in the current format without a version can't be imported
Should.Throw<InvalidOperationException>(() => _services.Resolve<IProfileRepository>().MigrateProfile(configuration, profile)); Should.Throw<InvalidOperationException>(() => _services.Resolve<IProfileRepository>().MigrateProfile(configuration, profile));
} }
@@ -117,15 +117,13 @@ public class ProfileCategoryRepositoryTests : IDisposable
} }
[Fact] [Fact]
public void IsUnique_ReturnsWhetherTheNameIsTaken() public void IsUnique_ReturnsWhetherTheNameIsFree()
{ {
ProfileCategoryEntity games = new() {Name = "Games"}; ProfileCategoryEntity games = new() {Name = "Games"};
_repository.Add(games); _repository.Add(games);
// Pinned as-is: despite its name this returns true when another category already has the name, so true means _repository.IsUnique("Games", null).ShouldBeFalse();
// the name is not unique. Nothing in Artemis calls it at the moment. _repository.IsUnique("Other", null).ShouldBeTrue();
_repository.IsUnique("Games", null).ShouldBeTrue();
_repository.IsUnique("Other", null).ShouldBeFalse();
} }
[Fact] [Fact]
@@ -134,8 +132,8 @@ public class ProfileCategoryRepositoryTests : IDisposable
ProfileCategoryEntity games = new() {Name = "Games"}; ProfileCategoryEntity games = new() {Name = "Games"};
_repository.Add(games); _repository.Add(games);
_repository.IsUnique("Games", games.Id).ShouldBeFalse(); _repository.IsUnique("Games", games.Id).ShouldBeTrue();
_repository.IsUnique("Games", Guid.NewGuid()).ShouldBeTrue(); _repository.IsUnique("Games", Guid.NewGuid()).ShouldBeFalse();
} }
[Fact] [Fact]
@@ -143,8 +141,8 @@ public class ProfileCategoryRepositoryTests : IDisposable
{ {
_repository.Add(new ProfileCategoryEntity {Name = "Games"}); _repository.Add(new ProfileCategoryEntity {Name = "Games"});
_repository.IsUnique(" Games ", null).ShouldBeTrue(); _repository.IsUnique(" Games ", null).ShouldBeFalse();
_repository.IsUnique("games", null).ShouldBeFalse(); _repository.IsUnique("games", null).ShouldBeTrue();
} }
[Fact] [Fact]
@@ -5,7 +5,6 @@ using Artemis.Core.Tests.Infrastructure.Storage;
using Artemis.Storage.Entities.Profile; using Artemis.Storage.Entities.Profile;
using Artemis.Storage.Repositories; using Artemis.Storage.Repositories;
using Artemis.Storage.Repositories.Interfaces; using Artemis.Storage.Repositories.Interfaces;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Artemis.Core.Tests.Storage.Repositories; namespace Artemis.Core.Tests.Storage.Repositories;
@@ -138,17 +137,17 @@ public class ProfileRepositoryTests : IDisposable
} }
[Fact] [Fact]
public void MigrateProfiles_ProfileWithoutVersion_IsNotMigrated() public void MigrateProfiles_ProfileWithoutVersion_IsMigratedFromVersion0()
{ {
ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext);
Guid id = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, OldProfileJson.Version0Configuration, OldProfileJson.Version0Profile); Guid id = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, OldProfileJson.Version0Configuration, OldProfileJson.Version0Profile);
_repository.MigrateProfiles(); _repository.MigrateProfiles();
// Pinned as-is: the database query only picks profiles whose version is below the latest, a missing version // Like MigrateProfile does, a missing version counts as version 0
// doesn't count as lower. MigrateProfile itself treats a missing version as version 0. Profiles stored by (string configuration, string profile) = StoredProfiles.ReadRaw(_services.CreateDbContext, id);
// Entity Framework always have a version, so this only matters for rows written some other way. JsonNode.Parse(configuration)!["Version"]!.GetValue<int>().ShouldBeGreaterThan(0);
StoredProfiles.ReadRaw(_services.CreateDbContext, id).ShouldBe((OldProfileJson.Version0Configuration, OldProfileJson.Version0Profile)); profile.ShouldNotBe(OldProfileJson.Version0Profile);
} }
[Theory] [Theory]
@@ -169,38 +168,35 @@ public class ProfileRepositoryTests : IDisposable
} }
[Fact] [Fact]
public void MigrateProfiles_ConfigurationThatIsNotJson_StopsAllMigrations() public void MigrateProfiles_ConfigurationThatIsNotJson_IsLoggedAndSkipped()
{ {
ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext);
StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, "{broken", OldProfileJson.Version0Profile); StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, "{broken", OldProfileJson.Version0Profile);
Guid working = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, StoredProfiles.Version0ConfigurationWithVersion(), OldProfileJson.Version0Profile); Guid working = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, StoredProfiles.Version0ConfigurationWithVersion(), OldProfileJson.Version0Profile);
// Pinned as-is: the version is read from the configuration by the database query, which fails on the broken _repository.MigrateProfiles();
// JSON before any profile is migrated. Unlike a broken profile, this isn't caught and logged.
Should.Throw<SqliteException>(() => _repository.MigrateProfiles()).Message.ShouldContain("malformed JSON"); _log.Errors.ShouldHaveSingleItem().MessageTemplate.Text.ShouldBe("Failed to migrate profile container {Id}");
StoredProfiles.ReadRaw(_services.CreateDbContext, working).Profile.ShouldBe(OldProfileJson.Version0Profile); StoredProfiles.ReadRaw(_services.CreateDbContext, working).Profile.ShouldNotBe(OldProfileJson.Version0Profile);
} }
[Fact] [Fact]
public void MigrateProfiles_ProfileCreatedInArtemis_IsStoredAtVersion0AndFailsToMigrate() public void MigrateProfiles_ProfileCreatedInArtemis_IsStoredAtTheCurrentVersion()
{ {
IProfileService profileService = _services.Resolve<IProfileService>(); IProfileService profileService = _services.Resolve<IProfileService>();
ProfileCategory category = profileService.CreateProfileCategory("Created"); ProfileCategory category = profileService.CreateProfileCategory("Created");
profileService.CreateProfileConfiguration(category, "New profile", "Keyboard"); profileService.CreateProfileConfiguration(category, "New profile", "Keyboard");
// Pinned as-is: a new profile is stored by saving its category, which doesn't set the version like int version;
// ProfileRepository.Save does. The profile is in the current format, but at version 0 M0001 runs on it and
// fails. The error is logged each time the profiles are migrated (when the profile service starts) until the
// profile is saved by itself.
using (var context = _services.CreateDbContext()) using (var context = _services.CreateDbContext())
context.ProfileContainers.Single().ProfileConfiguration.Version.ShouldBe(0); version = context.ProfileContainers.Single().ProfileConfiguration.Version;
version.ShouldBeGreaterThan(0);
_repository.MigrateProfiles(); _repository.MigrateProfiles();
_log.Errors.ShouldHaveSingleItem().MessageTemplate.Text.ShouldBe("Failed to migrate profile container {Id}"); _log.Errors.ShouldBeEmpty();
_log.Errors.Single().Exception.ShouldBeOfType<InvalidOperationException>();
using (var context = _services.CreateDbContext()) using (var context = _services.CreateDbContext())
context.ProfileContainers.Single().ProfileConfiguration.Version.ShouldBe(0); context.ProfileContainers.Single().ProfileConfiguration.Version.ShouldBe(version);
} }
private static ProfileContainerEntity CreateContainer(ProfileCategoryEntity category, string name = "Profile") private static ProfileContainerEntity CreateContainer(ProfileCategoryEntity category, string name = "Profile")
@@ -161,7 +161,16 @@ internal class ProfileService : IProfileService
/// <inheritdoc /> /// <inheritdoc />
public ProfileConfiguration CloneProfileConfiguration(ProfileConfiguration profileConfiguration) public ProfileConfiguration CloneProfileConfiguration(ProfileConfiguration profileConfiguration)
{ {
return new ProfileConfiguration(profileConfiguration.Category, profileConfiguration.Entity); ProfileContainerEntity original = profileConfiguration.Entity;
ProfileContainerEntity copy = new()
{
Id = original.Id,
Icon = original.Icon.ToArray(),
ProfileCategory = original.ProfileCategory,
ProfileConfiguration = CopyEntity(original.ProfileConfiguration),
Profile = CopyEntity(original.Profile)
};
return new ProfileConfiguration(profileConfiguration.Category, copy);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -234,7 +243,7 @@ internal class ProfileService : IProfileService
} }
_profileCategoryRepository.Add(profileCategory.Entity); _profileCategoryRepository.Add(profileCategory.Entity);
ProfileCategories = new ReadOnlyCollection<ProfileCategory>([..ProfileCategories, profileCategory]); ProfileCategories = new ReadOnlyCollection<ProfileCategory>(ProfileCategories.Append(profileCategory).OrderBy(c => c.Order).ToList());
OnProfileCategoryAdded(new ProfileCategoryEventArgs(profileCategory)); OnProfileCategoryAdded(new ProfileCategoryEventArgs(profileCategory));
return profileCategory; return profileCategory;
@@ -259,6 +268,8 @@ internal class ProfileService : IProfileService
category.AddProfileConfiguration(configuration, category.ProfileConfigurations.FirstOrDefault()); category.AddProfileConfiguration(configuration, category.ProfileConfigurations.FirstOrDefault());
SaveProfileCategory(category); SaveProfileCategory(category);
// The profile is in the current format, saving it stores the current migration version with it
_profileRepository.Save(configuration.Entity);
OnProfileAdded(new ProfileConfigurationEventArgs(configuration)); OnProfileAdded(new ProfileConfigurationEventArgs(configuration));
return configuration; return configuration;
@@ -303,7 +314,18 @@ internal class ProfileService : IProfileService
profile.IsFreshImport = false; profile.IsFreshImport = false;
profile.ProfileEntity.IsFreshImport = false; profile.ProfileEntity.IsFreshImport = false;
_profileRepository.Save(profile.Configuration.Entity); // A copy made by CloneProfileConfiguration has its own stored data, save it into that of the original
ProfileContainerEntity entity = profile.Configuration.Entity;
ProfileConfiguration? original = ProfileCategories
.SelectMany(c => c.ProfileConfigurations)
.FirstOrDefault(c => c.Entity != entity && c.Entity.Id == entity.Id);
if (original != null)
{
original.Entity.Profile = CopyEntity(entity.Profile);
entity = original.Entity;
}
_profileRepository.Save(entity);
// If the provided profile is external (cloned or from the workshop?) but it is loaded locally too, reload the local instance // If the provided profile is external (cloned or from the workshop?) but it is loaded locally too, reload the local instance
// A bit dodge but it ensures local instances always represent the latest stored version // A bit dodge but it ensures local instances always represent the latest stored version
@@ -429,6 +451,7 @@ internal class ProfileService : IProfileService
profileConfiguration.LoadModules(modules); profileConfiguration.LoadModules(modules);
SaveProfileCategory(category); SaveProfileCategory(category);
OnProfileAdded(new ProfileConfigurationEventArgs(profileConfiguration));
return profileConfiguration; return profileConfiguration;
} }
@@ -448,6 +471,11 @@ internal class ProfileService : IProfileService
SaveProfileCategory(profile.Configuration.Category); SaveProfileCategory(profile.Configuration.Category);
} }
private static T CopyEntity<T>(T entity)
{
return CoreJson.Deserialize<T>(CoreJson.Serialize(entity))!;
}
private void InputServiceOnKeyboardKeyUp(object? sender, ArtemisKeyboardKeyEventArgs e) private void InputServiceOnKeyboardKeyUp(object? sender, ArtemisKeyboardKeyEventArgs e)
{ {
_pendingKeyboardEvents.Add(e); _pendingKeyboardEvents.Add(e);
@@ -83,7 +83,8 @@ namespace Artemis.Storage.Migrations.Profile
if (jsonNode == null) if (jsonNode == null)
continue; continue;
JsonObject nodeObject = jsonNode.AsObject(); JsonObject nodeObject = jsonNode.AsObject();
nodeObject["Type"] = nodeObject["Type"]?.GetValue<string>().Replace("Artemis.VisualScripting.Nodes", "Artemis.Plugins.Nodes.General.Nodes"); if (nodeObject["Type"] != null)
nodeObject["Type"] = nodeObject["Type"]!.GetValue<string>().Replace("Artemis.VisualScripting.Nodes", "Artemis.Plugins.Nodes.General.Nodes");
nodeObject["ProviderId"] = "Artemis.Plugins.Nodes.General.GeneralNodesProvider-d9e1ee78"; nodeObject["ProviderId"] = "Artemis.Plugins.Nodes.General.GeneralNodesProvider-d9e1ee78";
} }
} }
@@ -36,11 +36,7 @@ internal class M0003SystemTextJson : IProfileMigration
{ {
obj.Remove("$values"); obj.Remove("$values");
jsonObject[key] = values; jsonObject[key] = values;
foreach (JsonNode? jsonNode in values.ToList()) ConvertListItems(values);
{
if (jsonNode is JsonObject childObject)
ConvertToSystemTextJson(childObject);
}
} }
obj.Remove("$type"); obj.Remove("$type");
@@ -52,6 +48,27 @@ internal class M0003SystemTextJson : IProfileMigration
} }
} }
private static void ConvertListItems(JsonArray values)
{
for (int i = 0; i < values.Count; i++)
{
if (values[i] is not JsonObject childObject)
continue;
// A list that is itself an item of a list is wrapped too
if (childObject["$type"] != null && childObject["$values"] is JsonArray childValues)
{
childObject.Remove("$values");
values[i] = childValues;
ConvertListItems(childValues);
}
else
{
ConvertToSystemTextJson(childObject);
}
}
}
internal static void FilterType(JsonObject jsonObject) internal static void FilterType(JsonObject jsonObject)
{ {
// Replace or remove $type depending on whether there's a matching JsonDerivedType // Replace or remove $type depending on whether there's a matching JsonDerivedType
@@ -97,7 +97,8 @@ namespace Artemis.Storage.Migrations.Profile
continue; continue;
JsonObject nodeObject = jsonNode.AsObject(); JsonObject nodeObject = jsonNode.AsObject();
nodeObject["Storage"] = MigrateNodeStorageJson(nodeObject["Storage"]?.GetValue<string>(), _logger); if (nodeObject["Storage"] != null)
nodeObject["Storage"] = MigrateNodeStorageJson(nodeObject["Storage"]!.GetValue<string>(), _logger);
} }
} }
@@ -69,7 +69,7 @@ internal class ProfileCategoryRepository(Func<ArtemisDbContext> getContext, IPro
name = name.Trim(); name = name.Trim();
return id == null return id == null
? dbContext.ProfileCategories.Any(p => p.Name == name) ? !dbContext.ProfileCategories.Any(p => p.Name == name)
: dbContext.ProfileCategories.Any(p => p.Name == name && p.Id != id.Value); : !dbContext.ProfileCategories.Any(p => p.Name == name && p.Id != id.Value);
} }
} }
@@ -51,9 +51,13 @@ public class ProfileRepository(ILogger logger, Func<ArtemisDbContext> getContext
using ArtemisDbContext dbContext = getContext(); using ArtemisDbContext dbContext = getContext();
int max = profileMigrators.Max(m => m.Version); int max = profileMigrators.Max(m => m.Version);
// Query the ProfileContainerEntity table directly, grabbing the ID, profile, and configuration // Query the ProfileContainerEntity table directly, grabbing the ID, profile, and configuration.
// A missing version counts as version 0, and configurations that aren't valid JSON are included so that they fail
// and get logged below rather than failing the query
List<RawProfileContainer> containers = dbContext.Database List<RawProfileContainer> containers = dbContext.Database
.SqlQueryRaw<RawProfileContainer>("SELECT Id, Profile, ProfileConfiguration FROM ProfileContainers WHERE json_extract(ProfileConfiguration, '$.Version') < {0}", max) .SqlQueryRaw<RawProfileContainer>(
"SELECT Id, Profile, ProfileConfiguration FROM ProfileContainers WHERE CASE WHEN json_valid(ProfileConfiguration) THEN COALESCE(json_extract(ProfileConfiguration, '$.Version'), 0) < {0} ELSE 1 END",
max)
.ToList(); .ToList();
foreach (RawProfileContainer rawProfileContainer in containers) foreach (RawProfileContainer rawProfileContainer in containers)