4
\$\begingroup\$

So I am working on an application (top-secret!) that I need settings for, these settings are pretty basic, but I don't want to make a single-row table with a bunch of serialized settings, or a single-row table with a bunch of columns, etc.

So I made a Setting class that's pretty handy:

public class Setting
{
    [Key]
    [MaxLength(64)]
    public string Name { get; set; }

    public string Value { get; set; }

    public string Type { get; set; }

    public Setting() { }

    public Setting(string name, string value, string type)
    {
        Name = name;
        Value = value;
        Type = type;
    }

    public static class Default
    {
        public static Setting RequireEmailValidation => new Setting("RequireEmailValidation", false.AsString(), nameof(Boolean));
    }
}

So it's a bit hinky if you're not sure why I did things.

First: I have the parameterized constructor and the default constructor, which seem to contradict. I wanted the parameterized one so I can make absolutely sure to set the required properties for a setting when I instantiate them. The default one is for Entity Framework.

Second: I have this static class Default which contains the default settings of the application. This is for two purposes:

First: seeding. It allows me to easily seed the default settings of the application:

protected override void Seed(Models.MasterDbContext context)
{
    var settings = typeof(Models.Setting.Default).GetProperties();
    foreach (var setting in settings)
    {
        context.Settings.AddOrUpdate(
            x => x.Name,
            (Models.Setting)setting.GetValue(null, null)
        );
    }

    context.SaveChanges();
}

Obviously this is nice. It makes seeding a breeze.

Next: it makes it nice to locate a setting from the DB:

_context.Settings.First(x => x.Name == Setting.Default.RequireEmailValidation.Name)

You can't bork a string name this way (you can, but you don't need to). You can just say Setting.Default.SomeSetting.Name to get it. Awesome! It also means if you want to change a setting value from the default you can just grab Setting.Default.SomeSetting and manipulate the .Value. Excellent!

This obviously creates the problem of all Setting values being a string. So I have an extension for that:

public static class SettingExtensions
{
    /// <summary>
    /// Returns the <see cref="Setting.Value"/> value as the specified type.
    /// </summary>
    /// <typeparam name="T">The <see cref="IConvertible"/> type to convert the <see cref="Setting"/> to.</typeparam>
    /// <param name="setting">The <see cref="Setting"/> to convert.</param>
    /// <returns>The strong type of the <see cref="Setting.Value"/>.</returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static T As<T>(this Setting setting)
        where T : IConvertible =>
        setting.Value.As<T>();
}

And a string extension as well:

public static class StringExtensions
{
    /// <summary>
    /// Converts a <see cref="string"/> to a strong type.
    /// </summary>
    /// <typeparam name="T">An <see cref="IConvertible"/> type to return from the <see cref="string"/>.</typeparam>
    /// <param name="value">The <see cref="string"/> value to convert.</param>
    /// <returns>The strong type value of the <see cref="string"/>.</returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static T As<T>(this string value)
        where T : IConvertible =>
        (T)Convert.ChangeType(value, typeof(T));

    /// <summary>
    /// Converts an <see cref="IConvertible"/> to a <see cref="string"/>.
    /// </summary>
    /// <typeparam name="T">The <see cref="IConvertible"/> to convert.</typeparam>
    /// <param name="value">The <see cref="IConvertible"/> value to convert.</param>
    /// <returns>The <see cref="string"/> representation of the <see cref="IConvertible"/>.</returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static string AsString<T>(this T value)
        where T : IConvertible =>
        (string)Convert.ChangeType(value, typeof(string));
}

So if you have a basic type (which all mine are at the moment) you can just do:

false.AsString();
SomeSetting.As<bool>();

Etc.

Actual usage:

EmailConfirmed = !_context.Settings.First(x => x.Name == Setting.Default.RequireEmailValidation.Name).As<bool>()

So, this is all fairly simple in the end, but it looks complex.

Please comment on anything I can improve. There will eventually be a crapton (that's metric) of Setting values in the DB, and I want to make sure they're right from the beginning.

Also: due to the importance of this, I will be offering a (or more) bounty (when it's eligible) to the best answer(s).

\$\endgroup\$
  • \$\begingroup\$ How do you work with the Type property ? And what's it's purpose ? As you can't directly convert string with value Boolean to boolean type. \$\endgroup\$ – Denis Jan 6 '17 at 19:36
  • \$\begingroup\$ @denis I'm using the Type property to display that to the user. (So they know what valid values are.) \$\endgroup\$ – 410_Gone Jan 6 '17 at 19:37
  • \$\begingroup\$ So what bothers you is the fact that you have string properties everywhere right ? \$\endgroup\$ – Denis Jan 6 '17 at 19:43
  • 1
    \$\begingroup\$ @denis Not necessarily, I don't have any specific concerns for this other than making sure I'm doing it in a manner that will be easily maintainable. \$\endgroup\$ – 410_Gone Jan 6 '17 at 19:44
2
\$\begingroup\$

Let's make it more generic and declarative with attributes. This should upgrade your current solution.


The Setting class stays the same but I removed the Default class and instead I created a new one, the Configuration class.

This one will hold each setting as a property decorated with the DefaultValue attribute. In this example I used the context as a parameter for the constructor but this may be of course a context factory lambda or anything else that works for you best.

Anyway, the one and only place where the name of the setting is actually written down is the property name. To get the setting you use another attribute but this time a compiler attribute CallerMemberName.

Allows you to obtain the method or property name of the caller to the method.

This will pass the property name to the method that now can search for the setting. You can easily cache it if you like.

public class Configuration
{
    private readonly TestContext _context;
    public Configuration(TestContext context)
    {
        _context = context;
    }

    [DefaultValue("False")]
    public bool RequireEmailValidation
    {
        get { return GetSetting<bool>(); }
    }

    private T GetSetting<T>([CallerMemberName] string name = null) where T : IConvertible
    {            
        var setting = _context.Settings.SingleOrDefault(s => s.Name == name);
        return setting.As<T>();
    }
}

Lastly the Seed method need to be aware of the DefaultValue property so it needs small changes too. This means it needs to look only for properties decorated with it and then get the value from it.

protected override void Seed(Models.MasterDbContext context)
{
    var settings = 
        typeof(Configuration)
        .GetProperties()
        .Where(x => x.GetCustomAttribute<DefaultValueAttribute>() != null);
    foreach (var setting in settings)
    {
        context.Settings.AddOrUpdate(
            x => x.Name,
            setting.GetCustomAttribute<DefaultValueAttribute>().Value
        );
    }

    context.SaveChanges();
}
\$\endgroup\$
2
+50
\$\begingroup\$

You can create a really similar but new generic class which helps you by :

  • Removing the unnecessary use of the SettingExtensions && StringExtensions classes, tho StringExtensions can still be useful in some other cases but SettingExtensions becomes completely redundant.

  • You can have readonly / get only properties.

  • Shortens your constructor by 1 parameter.

  • More security for your types. Let's say you want to have a double setting but you've declared the type as int, it's not likely to happen but it is possible. With the generic approach this wont happen.

Setting setting = new Setting("Setting", "123.4", nameof(Int32));

I haven't remove your original Settings class as you might want to use it to load some data into it, instead I've allowed the conversion from Setting to Setting<T>. If that's not the case you can remove it to shorten the code even further.

I've also left the Default class in your original Setting class rather than in the generic one to avoid the need of specifying redundant type arguments and it now looks like this :

public class Setting
{
    public string Name { get; set; }

    public string Value { get; set; }

    public string Type { get; set; }

    public Setting()
    {

    }

    public Setting(string name, string value, string type)
    {
        Name = name;
        Value = value;
        Type = type;
    }

    public static class Default
    {
        public static Setting<bool> RequireEmailValidation => new Setting<bool>("RequireEmailValidation", false);
    }
}

And this is the Setting<T> class :

public class Setting<TValue>
{
    public string Name { get; }

    public TValue Value { get; }

    public string Type => typeof(TValue).Name;

    public Setting(Setting setting)
    {
        Name = setting.Name;
        Value = (TValue) Convert.ChangeType(setting.Value, typeof(TValue));
    }

    public Setting(string name, TValue value)
    {
        Name = name;
        Value = value;
    }
}

Usage examples :

Here's how you would retrieve the first require email validation :

Setting<bool>[] settings =
{
    new Setting<bool>("Setting", false),
    new Setting<bool>("RequireEmailValidation", false),
};

bool EmailConfirmed = !settings.First(x => x.Name == Setting.Default.RequireEmailValidation.Name).Value;

And here are some generic settings creation :

var SomeGenericWrapperSetting = new Setting<int>(SomeSetting);
foreach (var propertyInfo in SomeGenericWrapperSetting.GetType().GetProperties())
{
    Console.WriteLine(propertyInfo.PropertyType + " " + propertyInfo.GetValue(SomeGenericWrapperSetting));
}

var SomeGenericSetting = new Setting<int>("Setting", 123);
foreach (var propertyInfo in SomeGenericSetting.GetType().GetProperties())
{
    Console.WriteLine(propertyInfo.PropertyType + " " + propertyInfo.GetValue(SomeGenericSetting));
}

var defaultGenericSetting = Setting.Default.RequireEmailValidation;
foreach (var propertyInfo in defaultGenericSetting.GetType().GetProperties())
{
    Console.WriteLine(propertyInfo.PropertyType + " " + propertyInfo.GetValue(defaultGenericSetting));
}
\$\endgroup\$
1
\$\begingroup\$

Use nameof here for compile-time value checking, and Boolean.FalseString is just as clear as false.AsString() (although, using false.AsString() will match default settings using other types better, so this is optional):

public static Setting RequireEmailValidation => new Setting("RequireEmailValidation", false.AsString(), nameof(Boolean));
public static Setting RequireEmailValidation => new Setting(nameof(RequireEmailValidation), Boolean.FalseString, nameof(Boolean));

Your Setting class reminds me a lot of UWP's local/remote settings. These are stored in a limited-size file on the user's computer, and are made available to the developer as an IPropertySet<KeyValuePair<string, object>>. The caller can assign any serializable value to a key, and the caller is responsible for handling casts when accessing the data. I'm not sure how you are storing your data internally, but you may (or may not) wish to look into wrapping this so the serialization to a string is handled internally and you can just pass unconverted values in.

Going further along this line, I would store the type as a Type, which you could use to handle the conversion back. You could perhaps make this a Setting<T> (would Entity Framework allow this?) instead of passing the type in manually, and have the value be of type T, which would provide further type checking at compile time, and would allow you to present the value its real type to the caller. This would probably have to be a wrapper over the current version, and may or may not be too fancy for your uses.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.