views:

333

answers:

3

I have an application that stores a collection of objects in the user settings, and is deployed via ClickOnce. The next version of the applications has a modified type for the objects stored. For example, the previous version's type was:

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}

And the new version's type is:

public class Person
{
    public string Name { get; set; }
    public DateTime DateOfBirth { get; set; }
}

Obviously, ApplicationSettingsBase.Upgrade wouldn't know how to perform an upgrade, since Age needs to be converted using (age) => DateTime.Now.AddYears(-age), so only the Name property would be upgraded, and DateOfBirth would just have the value of Default(DateTime).

So I'd like to provide an upgrade routine, by overriding ApplicationSettingsBase.Upgrade, that would convert the values as needed. But I've ran into three problems:

  1. When trying to access the previous version's value using ApplicationSettingsBase.GetPreviousVersion, the returned value would be an object of the current version, which doesn't have the Age property and has an empty DateOfBirth property (since it can't deserialize Age into DateOfBirth).
  2. I couldn't find a way to find out from which version of the application I'm upgrading. If there is an upgrade procedure from v1 to v2 and a procedure from v2 to v3, if a user is upgrading from v1 to v3, I need to run both upgrade procedures in order, but if the user is upgrading from v2, I only need to run the second upgrade procedure.
  3. Even if I knew what the previous version of the application is, and I could access the user settings in their former structure (say by just getting a raw XML node), if I wanted to chain upgrade procedures (as described in issue 2), where would I store the intermediate values? If upgrading from v2 to v3, the upgrade procedure would read the old values from v2 and write them directly to the strongly-typed settings wrapper class in v3. But if upgrading from v1, where would I put the results of the v1 to v2 upgrade procedure, since the application only has a wrapper class for v3?

I thought I could avoid all these issues if the upgrade code would perform the conversion directly on the user.config file, but I found no easy way to get the location of the user.config of the previous version, since LocalFileSettingsProvider.GetPreviousConfigFileName(bool) is a private method.

Does anyone have a ClickOnce-compatible solution for upgrading user settings that change type between application versions, preferably a solution that can support skipping versions (e.g. upgrading from v1 to v3 without requiring the user to in install v2)?

A: 

This may not really be the answer you are looking for but it sounds like you are overcomplicating the problem by trying to manage this as an upgrade where you aren't going to continue to support the old version.

The problem isn't simply that the data type of a field is changing, the problem is that you are totally changing the business logic behind the object and need to support objects that have data relating to both old and new business logic.

Why not just continue to have a person class which has all 3 properties on it.

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public DateTime DateOfBirth { get; set; }
}

When the user upgrades to the new version, the age is still stored, so when you access the DateOfBirth field you just check if a DateOfBirth exists, and if it doesn't you calculate it from the age and save it so when you next access it, it already has a date of birth and the age field can be ignored.

You could mark the age field as obsolete so you remember not to use it in future.

If necessary you could add some kind of private version field to the person class so internally it knows how to handle itself depending on what version it considers itself to be.

Sometimes you do have to have objects that aren't perfect in design because you still have to support data from old versions.

Simon P Stevens
I guess my example was oversimplified. The object being stored in the user settings file is a more complex object with nested objects. One of the nested types was refactored, and one of its fields was changed from an enumeration to a custom object, basically representing the same data using a proper design pattern. The field even has the same name. I thought about keeping the old field, but when looking at the long term consequences, my assembly would have many obsolete members and classes that were once stored in the user settings, which could create a serious maintenance issue.
Allon Guralnek
It's a tricky one, I see what you are trying to do, but sometimes you do end up with obsolete members in order to continue support for old versions.
Simon P Stevens
A: 

I know this has already been answered but I have been toying with this and wanted to add a way I handled a similar (not the same) situation with Custom Types:

public class Person
{

    public string Name { get; set; }
    public int Age { get; set; }
    private DateTime _dob;
    public DateTime DateOfBirth
    {
      get
     {
      if (_dob is null)
      { _dob = DateTime.Today.AddYears(Age * -1); }
      else { return _dob; }     
     }
     set { _dob = value; }
    }
 }

If both the private _dob and public Age is null or 0, you have another issue all together. You could always set DateofBirth to DateTime.Today by default in that case. Also, if all you have is an individual's age, how will you tell their DateOfBirth down to the day?

masenkablast
That's the solution I thought about using in the first place, but I had a problem - the base class of business object I storing changed, and so XMLSerializer failed to deserialize it in its new form. So this method only works on very basic classes. I am currently working on a more elaborate (and a bit hackish) scheme to perform such an upgrade, and so far it seems to work well. Will post it here when I'm done.
Allon Guralnek
A: 

I ended up using a more complex way to do upgrades, by reading the raw XML from the user settings file, then run a series of upgrade routines that refactor the data to the way it's supposed to be in the new next version. Also, due to a bug I found in ClickOnce's ApplicationDeployment.CurrentDeployment.IsFirstRun property (you can see the Microsoft Connect feedback here), I had to use my own IsFirstRun setting to know when to perform the upgrade. The whole system works very well for me (but it was made with blood and sweat due to a few very stubborn snags). Ignore comments mark what is specific to my application and is not part of the upgrade system.

using System;
using System.Collections.Specialized;
using System.Configuration;
using System.Xml;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using System.Reflection;
using System.Text;
using MyApp.Forms;
using MyApp.Entities;

namespace MyApp.Properties
{
    public sealed partial class Settings
    {
     private static readonly Version CurrentVersion = Assembly.GetExecutingAssembly().GetName().Version;

     private Settings()
     {
      InitCollections();  // ignore
     }

     public override void Upgrade()
     {
      UpgradeFromPreviousVersion();
      BadDataFiles = new StringCollection();  // ignore
      UpgradePerformed = true; // this is a boolean value in the settings file that is initialized to false to indicate that settings file is brand new and requires upgrading
      InitCollections();  // ignore
      Save();
     }

     // ignore
     private void InitCollections()
     {
      if (BadDataFiles == null)
       BadDataFiles = new StringCollection();

      if (UploadedGames == null)
       UploadedGames = new StringDictionary();

      if (SavedSearches == null)
       SavedSearches = SavedSearchesCollection.Default;
     }

     private void UpgradeFromPreviousVersion()
     {
      try
      {
       // This works for both ClickOnce and non-ClickOnce applications, whereas
       // ApplicationDeployment.CurrentDeployment.DataDirectory only works for ClickOnce applications
       DirectoryInfo currentSettingsDir = new FileInfo(ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.PerUserRoamingAndLocal).FilePath).Directory;

       if (currentSettingsDir == null)
        throw new Exception("Failed to determine the location of the settings file.");

       if (!currentSettingsDir.Exists)
        currentSettingsDir.Create();

       // LINQ to Objects for .NET 2.0 courtesy of LINQBridge (linqbridge.googlecode.com)
       var previousSettings = (from dir in currentSettingsDir.Parent.GetDirectories()
                               let dirVer = new { Dir = dir, Ver = new Version(dir.Name) }
                               where dirVer.Ver < CurrentVersion
                               orderby dirVer.Ver descending
                               select dirVer).FirstOrDefault();

       if (previousSettings == null)
        return;

       XmlElement userSettings = ReadUserSettings(previousSettings.Dir.GetFiles("user.config").Single().FullName);
       userSettings = SettingsUpgrader.Upgrade(userSettings, previousSettings.Ver);
       WriteUserSettings(userSettings, currentSettingsDir.FullName + @"\user.config", true);

       Reload();
      }
      catch (Exception ex)
      {
       MessageBoxes.Alert(MessageBoxIcon.Error, "There was an error upgrading the the user settings from the previous version. The user settings will be reset.\n\n" + ex.Message);
       Default.Reset();
      }
     }

     private static XmlElement ReadUserSettings(string configFile)
     {
      // PreserveWhitespace required for unencrypted files due to https://connect.microsoft.com/VisualStudio/feedback/ViewFeedback.aspx?FeedbackID=352591
      var doc = new XmlDocument { PreserveWhitespace = true };
      doc.Load(configFile);
      XmlNode settingsNode = doc.SelectSingleNode("configuration/userSettings/MyApp.Properties.Settings");
      XmlNode encryptedDataNode = settingsNode["EncryptedData"];
      if (encryptedDataNode != null)
      {
       var provider = new RsaProtectedConfigurationProvider();
       provider.Initialize("userSettings", new NameValueCollection());
       return (XmlElement)provider.Decrypt(encryptedDataNode);
      }
      else
      {
       return (XmlElement)settingsNode;
      }
     }

     private static void WriteUserSettings(XmlElement settingsNode, string configFile, bool encrypt)
     {
      XmlDocument doc;
      XmlNode MyAppSettings;

      if (encrypt)
      {
       var provider = new RsaProtectedConfigurationProvider();
       provider.Initialize("userSettings", new NameValueCollection());
       XmlNode encryptedSettings = provider.Encrypt(settingsNode);
       doc = encryptedSettings.OwnerDocument;
       MyAppSettings = doc.CreateElement("MyApp.Properties.Settings").AppendNewAttribute("configProtectionProvider", provider.GetType().Name);
       MyAppSettings.AppendChild(encryptedSettings);
      }
      else
      {
       doc = settingsNode.OwnerDocument;
       MyAppSettings = settingsNode;
      }

      doc.RemoveAll();
      doc.AppendNewElement("configuration")
       .AppendNewElement("userSettings")
       .AppendChild(MyAppSettings);

      using (var writer = new XmlTextWriter(configFile, Encoding.UTF8) { Formatting = Formatting.Indented, Indentation = 4 })
       doc.Save(writer);
     }

     private static class SettingsUpgrader
     {
      private static readonly Version MinimumVersion = new Version(0, 2, 1, 0);

      public static XmlElement Upgrade(XmlElement userSettings, Version oldSettingsVersion)
      {
       if (oldSettingsVersion < MinimumVersion)
        throw new Exception("The minimum required version for upgrade is " + MinimumVersion);

       var upgradeMethods = from method in typeof(SettingsUpgrader).GetMethods(BindingFlags.Static | BindingFlags.NonPublic)
                            where method.Name.StartsWith("UpgradeFrom_")
             let methodVer = new { Version = new Version(method.Name.Substring(12).Replace('_', '.')), Method = method }
             where methodVer.Version >= oldSettingsVersion && methodVer.Version < CurrentVersion
             orderby methodVer.Version ascending 
                            select methodVer;

       foreach (var methodVer in upgradeMethods)
       {
        try
        {
         methodVer.Method.Invoke(null, new object[] { userSettings });
        }
        catch (TargetInvocationException ex)
        {
         throw new Exception(string.Format("Failed to upgrade user setting from version {0}: {1}",
                                           methodVer.Version, ex.InnerException.Message), ex.InnerException);
        }
       }

       return userSettings;
      }

      private static void UpgradeFrom_0_2_1_0(XmlElement userSettings)
      {
       // ignore method body - put your own upgrade code here

       var savedSearches = userSettings.SelectNodes("//SavedSearch");

       foreach (XmlElement savedSearch in savedSearches)
       {
        string xml = savedSearch.InnerXml;
        xml = xml.Replace("IRuleOfGame", "RuleOfGame");
        xml = xml.Replace("Field>", "FieldName>");
        xml = xml.Replace("Type>", "Comparison>");
        savedSearch.InnerXml = xml;


        if (savedSearch["Name"].GetTextValue() == "Tournament")
         savedSearch.AppendNewElement("ShowTournamentColumn", "true");
        else
         savedSearch.AppendNewElement("ShowTournamentColumn", "false");
       }
      }
     }
    }
}

The following custom extention methods and helper classes were used:

using System;
using System.Windows.Forms;
using System.Collections.Generic;
using System.Xml;


namespace MyApp
{
    public static class ExtensionMethods
    {
     public static XmlNode AppendNewElement(this XmlNode element, string name)
     {
      return AppendNewElement(element, name, null);
     }
     public static XmlNode AppendNewElement(this XmlNode element, string name, string value)
     {
      return AppendNewElement(element, name, value, null);
     }
     public static XmlNode AppendNewElement(this XmlNode element, string name, string value, params KeyValuePair<string, string>[] attributes)
     {
      XmlDocument doc = element.OwnerDocument ?? (XmlDocument)element;
      XmlElement addedElement = doc.CreateElement(name);

      if (value != null)
       addedElement.SetTextValue(value);

      if (attributes != null)
       foreach (var attribute in attributes)
        addedElement.AppendNewAttribute(attribute.Key, attribute.Value);

      element.AppendChild(addedElement);

      return addedElement;
     }
     public static XmlNode AppendNewAttribute(this XmlNode element, string name, string value)
     {
      XmlAttribute attr = element.OwnerDocument.CreateAttribute(name);
      attr.Value = value;
      element.Attributes.Append(attr);
      return element;
     }
    }
}

namespace MyApp.Forms
{
    public static class MessageBoxes
    {
     private static readonly string Caption = "MyApp v" + Application.ProductVersion;

     public static void Alert(MessageBoxIcon icon, params object[] args)
     {
      MessageBox.Show(GetMessage(args), Caption, MessageBoxButtons.OK, icon);
     }
     public static bool YesNo(MessageBoxIcon icon, params object[] args)
     {
      return MessageBox.Show(GetMessage(args), Caption, MessageBoxButtons.YesNo, icon) == DialogResult.Yes;
     }

     private static string GetMessage(object[] args)
     {
      if (args.Length == 1)
      {
       return args[0].ToString();
      }
      else
      {
       var messegeArgs = new object[args.Length - 1];
       Array.Copy(args, 1, messegeArgs, 0, messegeArgs.Length);
       return string.Format(args[0] as string, messegeArgs);
      }

     }
    }
}

The following Main method was used to allow the system to work:

[STAThread]
static void Main()
{
  // Ensures that the user setting's configuration system starts in an encrypted mode, otherwise an application restart is required to change modes.
  Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.PerUserRoamingAndLocal);
  SectionInformation sectionInfo = config.SectionGroups["userSettings"].Sections["MyApp.Properties.Settings"].SectionInformation;
  if (!sectionInfo.IsProtected)
  {
   sectionInfo.ProtectSection(null);
   config.Save();
  }

  if (Settings.Default.UpgradePerformed == false)
   Settings.Default.Upgrade();

  Application.Run(new frmMain());
}

I welcome any input, critique, suggestions or improvements. I hope this helps someone somewhere.

Allon Guralnek