Mapping members

edit

Attributes on your types change how their members map to keys. They are in the Meziantou.Framework.Toml.Serialization namespace.

Rename, ignore and require members

public sealed class DatabaseConfig
{
    [TomlRequired]
    public string Host { get; set; } = "";

    [TomlPropertyName("port_number")]
    public int Port { get; set; }

    [TomlIgnore]
    public string ConnectionString => $"{Host}:{Port}";
}

public sealed class Endpoint
{
    [TomlConstructor]
    public Endpoint(string host, int port)
    {
        Host = host;
        Port = port;
    }

    public string Host { get; }
    public int Port { get; }
}
var database = TomlSerializer.Deserialize<DatabaseConfig>("Host = \"localhost\"\nport_number = 5432");
var endpoint = TomlSerializer.Deserialize<Endpoint>("Host = \"localhost\"\nPort = 80");

Assert.Equal("localhost:5432", database!.ConnectionString);
Assert.Equal("Host = \"localhost\"\nport_number = 5432\n", TomlSerializer.Serialize(database));
Assert.Throws<TomlException>(() => TomlSerializer.Deserialize<DatabaseConfig>("port_number = 5432"));
Assert.Equal(80, endpoint!.Port);
AttributeEffect
[TomlPropertyName]Sets the key of the member. It wins over the naming policy.
[TomlIgnore]Ignores the member, always or only when its value is null or default (Condition = TomlIgnoreCondition.WhenWritingNull or WhenWritingDefault).
[TomlInclude]Includes a non-public member, or a field.
[TomlPropertyOrder]Sets the position of the member in its table.
[TomlRequired]The key must be present: a missing key throws a TomlException.

The C# required modifier makes a member required too, unless the constructor used has [SetsRequiredMembers].

Choose the constructor

A type without a parameterless constructor, such as a positional record, is created with its constructor: each parameter receives the value of the key with the same name. When a type has several constructors, mark the one to use with [TomlConstructor], as Endpoint does above.

A constructor parameter without a default value must be present in the document, unless RespectRequiredConstructorParameters is false: it then receives the default value of its type.

Fill existing values

By default, reading assigns new values to the members that have a setter, and leaves read-only members untouched (TomlObjectCreationHandling.Replace). Populate reuses the objects and collections the members already hold, and appends to collections instead of clearing them. Set it on a type, on a member with [TomlObjectCreationHandling], or for every type with PreferredObjectCreationHandling:

[TomlObjectCreationHandling(TomlObjectCreationHandling.Populate)]
public sealed class ReleaserConfiguration
{
    public List<string> Channels { get; } = ["stable"];
}
var configuration = TomlSerializer.Deserialize<ReleaserConfiguration>("Channels = [\"beta\"]");

Assert.Equal(["stable", "beta"], configuration!.Channels);
  • Populate on a member that cannot be populated, such as a struct without a setter, throws. On a type or in the options, it is a preference: such members are left unchanged.
  • Populate does not apply to types created with a constructor that has parameters.
  • A member with a converter is always replaced.

Accept a single value or an array

A collection member requires an array. With [TomlSingleOrArray], it also accepts a single value:

public sealed class PackagingConfiguration
{
    [TomlSingleOrArray]
    [TomlPropertyName("rid")]
    public List<string> RuntimeIdentifiers { get; } = [];
}
var single = TomlSerializer.Deserialize<PackagingConfiguration>("rid = \"win-x64\"");
var array = TomlSerializer.Deserialize<PackagingConfiguration>("rid = [\"win-x64\", \"linux-x64\"]");

Assert.Equal(["win-x64"], single!.RuntimeIdentifiers);
Assert.Equal(["win-x64", "linux-x64"], array!.RuntimeIdentifiers);

Run code before or after serialization

Implement the callback interfaces to validate or prepare an object:

InterfaceCalled
ITomlOnSerializingBefore the object is written.
ITomlOnSerializedAfter the object is written.
ITomlOnDeserializingWhen the instance is created, before its members are set. Constructor arguments are already set.
ITomlOnDeserializedAfter the members are read.
public sealed class ValidatedConfig : ITomlOnDeserialized
{
    public int Port { get; set; } = 8080;

    public void OnTomlDeserialized()
    {
        if (Port is < 1 or > 65535)
            throw new InvalidOperationException($"Invalid port: {Port}");
    }
}
Assert.Equal(8080, TomlSerializer.Deserialize<ValidatedConfig>("")!.Port);
Assert.Throws<InvalidOperationException>(() => TomlSerializer.Deserialize<ValidatedConfig>("Port = 0"));

An exception thrown by a callback or a setter is not wrapped: it reaches your code as it is, and TryDeserialize does not catch it. An exception thrown by a constructor is reported as a TomlException.

Keep unknown keys

[TomlExtensionData] collects the keys that match no member. The member is a dictionary with string keys, such as IDictionary<string, object?> or TomlTable:

public sealed class ExtensibleConfig
{
    public string Name { get; set; } = "";

    [TomlExtensionData]
    public IDictionary<string, object?>? Extra { get; set; }
}
var config = TomlSerializer.Deserialize<ExtensibleConfig>("Name = \"app\"\nversion = 2");

Assert.Equal("app", config!.Name);
Assert.Equal(2L, config.Extra!["version"]);

The values have the types of the document model: string, long, double, bool, TomlDateTime, TomlTable, TomlArray and TomlTableArray.

Reject unknown keys

Unknown keys are skipped by default. To reject them, set UnmappedMemberHandling = TomlUnmappedMemberHandling.Disallow in the options, or [TomlUnmappedMemberHandling(TomlUnmappedMemberHandling.Disallow)] on a type. A typo in a configuration file then fails instead of being ignored. Extension data still collects the unknown keys.