Table of Contents

Interface IDynamoDbConverter<T>

Namespace
DynaSharp.Serialization
Assembly
DynaSharp.dll

Converts values of type T to and from DynamoDB attribute values.

public interface IDynamoDbConverter<T>

Type Parameters

T

The .NET type.

Remarks

Converters are static: the source generator calls them directly through the type parameter, without instances, virtual calls or delegates. Built-in converters and AttributeValue use the same contract; they are structs, because a struct type argument gives every use its own compiled code, so the call is direct and can be inlined, where a class would share one instantiation and look the method up at run time. Implement it on a readonly record struct as well, which also satisfies CA1815 (static classes cannot implement interfaces; a class works but is slower), and reference it from the entity, for example to store a Uri as its original text:

public readonly record struct UriConverter : IDynamoDbConverter<Uri>
{
    public static void Write(DynamoDbWriter writer, Uri value) =>
        writer.WriteString(value.OriginalString);

    public static Uri Read(ref DynamoDbReader reader, AttributeType type)
    {
        ConverterChecks.Expect(type, AttributeType.String);
        return new Uri(reader.GetString(), UriKind.Absolute);
    }
}

The members are static abstract, so a converter generic over its value, such as JsonConverter<T>, declares static members on a generic type. That is CA1000, which fails a project that treats it as an error. Write one struct per type over a generic helper, as here with a TextValue.Write<T> and a TextValue.Read<T> of your own:

public readonly record struct MoneyConverter : IDynamoDbConverter<Money>
{
    public static void Write(DynamoDbWriter writer, Money value) => TextValue.Write(writer, value);

    public static Money Read(ref DynamoDbReader reader, AttributeType type) => TextValue.Read<Money>(ref reader, type);
}

A converter that cannot read a value throws JsonException, FormatException, OverflowException or InvalidOperationException (a UriFormatException is a FormatException); the code mapping an entity reports exactly those, with the name of the attribute, and lets every other exception through as a bug. Keep values out of messages: they end up in logs. A converter of a type that is a nullable value or reference gets the null handling from NullableConverter<T, TConverter> and NullableReferenceConverter<T, TConverter>; implement the converter of the non-null type. To let a read of an item go on past a value that means nothing to the property, implement CountsAsMissing(in DynamoDbReader, AttributeType) as well.

Methods

CountsAsMissing(in DynamoDbReader, AttributeType)

Finds out whether a stored value counts as if the attribute were not in the item. The mirror of IsOmitted(T) for a read: it is for a value that means nothing to the property, such as a name an enum no longer has, or a blank string where a JSON payload belongs, so that one bad attribute does not fail the read of the item.

public static bool CountsAsMissing(in DynamoDbReader reader, AttributeType type)

Parameters

reader DynamoDbReader

The reader, positioned on the value. Read it with the getters, which do not move it.

type AttributeType

The DynamoDB type of the value.

Returns

bool

true if the value counts as a missing attribute.

Remarks

A lenient enum: a name the type does not have keeps the initial value. Read(ref DynamoDbReader, AttributeType) stays strict, because a list has positions and asks nothing. The value is parsed twice, here and in Read(ref DynamoDbReader, AttributeType), and only on a property whose converter answers. An enum that the generated converter stores by name needs none of this: DynamoDbEnumAttribute on the enum gives that converter the same answer. This one is for an enum stored otherwise.

public readonly record struct LenientPositionConverter : IDynamoDbConverter<Position>
{
    public static void Write(DynamoDbWriter writer, Position value) =>
        writer.WriteString(value.ToString());

    public static Position Read(ref DynamoDbReader reader, AttributeType type)
    {
        ConverterChecks.Expect(type, AttributeType.String);
        return Enum.TryParse<Position>(reader.GetString(), out var value) ? value : throw new FormatException("Not a position.");
    }

    public static bool CountsAsMissing(in DynamoDbReader reader, AttributeType type) =>
        type == AttributeType.String && !Enum.TryParse<Position>(reader.GetString(), out _);
}

A blank string as null: a Payload? stored as JSON text reads as null for a stored "". The value is looked at twice here as well, first for its length and then by the parser, and only on a property whose converter answers.

public readonly record struct PayloadConverter : IDynamoDbConverter<Payload>
{
    public static void Write(DynamoDbWriter writer, Payload value) =>
        writer.WriteString(JsonSerializer.Serialize(value, ShopJson.Default.Payload));

    public static Payload Read(ref DynamoDbReader reader, AttributeType type)
    {
        ConverterChecks.Expect(type, AttributeType.String);
        return JsonSerializer.Deserialize(reader.GetStringUtf8(), ShopJson.Default.Payload)!;
    }

    public static bool CountsAsMissing(in DynamoDbReader reader, AttributeType type) =>
        type == AttributeType.String && reader.GetStringUtf8().IsEmpty;
}

The answer true means as if the attribute were not in the item, whatever the property is: it keeps its initial value, and a nullable one does too, so an initializer on a Payload? stays and only one without gives null. A required one throws DynamoDbMappingException, and an input of a key that holds it is restored from that key. Select gives what TryReadMissing(out T) gives, which is null for a nullable handle, also where a read of the entity keeps an initializer, and otherwise a throw that DYNA209 reports. Return throws, as it does for a missing attribute. A value that is {"NULL":true} is passed here as Null, except on a nullable property, which reads it as null without asking.

The default answers false. The source generator emits the question only for a [DynamoDbConverter] or a [DynamoDbDefaultConverter] that implements this, implicitly or explicitly, and for the converter it writes for an enum with [DynamoDbEnum(UnknownName = UnknownEnumName.CountsAsMissing)]. It is the property that a converter answers for: an element of a list, a map or a set is not asked, because a list has positions, and neither is a plain key, because a read that took it for missing would give an entity with another key. Keys.X.Parse and Item do not ask either.

The reader stands on the value, as it does for Read(ref DynamoDbReader, AttributeType), and this cannot move it, so Read(ref DynamoDbReader, AttributeType) finds the value where it was, and skipping a value that counts as missing is not the converter's job. On an in reader the getters work as they always do, a call that moves a reader compiles and moves a copy that the compiler makes for that one call, and passing it on as ref does not compile. So to look inside a list or a map, copy the reader once and walk the copy, because a loop over the parameter itself starts over with every call and never ends. A copy is small and cheap:

public static bool CountsAsMissing(in DynamoDbReader reader, AttributeType type)
{
    if (type != AttributeType.Map)
    {
        return false;
    }

    var map = reader;
    while (map.TryReadAttributeName())
    {
        if (map.AttributeNameEquals("body"u8))
        {
            return map.ReadStartAttribute() == AttributeType.String && map.GetStringUtf8().IsEmpty;
        }

        map.SkipAttributeValue();
    }

    return true;
}

This can throw what Read(ref DynamoDbReader, AttributeType) can, with the same result. Read(ref DynamoDbReader, AttributeType) is not called for a value counted as missing.

IsOmitted(T)

Finds out whether a write treats a value like null, which leaves the attribute out. It is for a value that DynamoDB would refuse, or that should not be stored, such as a list stored as a set, which cannot be empty: public static bool IsOmitted(List<string> value) => value.Count == 0;. OmitEmptyAttribute asks the same question through DynaSharp.Serialization.OmitEmptyConverter<TConverter> and DynaSharp.Serialization.OmitEmptyCollectionConverter<T, TElement, TConverter>.

public static bool IsOmitted(T value)

Parameters

value T

The value, never null on a write.

Returns

bool

true if the value counts as null.

Remarks

Every write of the property asks: a Put of the entity, Item.From, a batch, a step of a transaction and a nested document leave the attribute out, Set by handle is a Remove, SetIfNotNull adds nothing and SetIfNotExists rejects the value with an ArgumentException. A raw path writes what it is given. A nullable property asks with a value that is not null.

The default answers false. The source generator emits the call only for a [DynamoDbConverter] or a [DynamoDbDefaultConverter] that implements this, implicitly or explicitly. It is the property that a converter answers for: a property that feeds a key is always written, because a key cannot do without it, and an element of a list or a map is always written, because a list has positions.

A read does not ask. The item has no attribute, so a read of the entity keeps the initial value of the property, and throws DynamoDbMappingException if it is required. Give the property an initial value that means the same as what was left out, and implement TryReadMissing(out T) for Select.

Read(ref DynamoDbReader, AttributeType)

Reads the attribute value the reader is positioned on.

public static abstract T Read(ref DynamoDbReader reader, AttributeType type)

Parameters

reader DynamoDbReader

The reader, positioned on the value after ReadStartAttribute() or TryReadStartAttribute(out AttributeType). The caller reads the closing brace afterwards, so the converter must read the value completely (all elements of a list, map or set) and nothing more.

type AttributeType

The DynamoDB type of the value. Check it with Expect(AttributeType, AttributeType), which throws JsonException for types the converter does not support. Null is passed to the converter too, so it can map it to null where that makes sense.

Returns

T

The value.

TryReadMissing(out T)

Finds out what a read of one attribute gives when the item does not have it. No value is the answer for most types, and the read then throws DynamoDbMappingException rather than make one up. A nullable type answers with null, because null is left out on write, and a set with an empty set, because DynamoDB has none.

public static bool TryReadMissing(out T value)

Parameters

value T

The value, if there is one.

Returns

bool

true if a missing attribute has a value of this type.

Remarks

This is infrastructure of Select, which alone asks it, and the built-in converters and the generated handle of a version with Allow answer for themselves. A converter of your own needs it in one case: a converter of a set, because an empty set is a missing attribute in DynamoDB, so without an answer here a Select throws for every item whose set is empty. A read of the entity does not ask this: a property with a converter of your own keeps its initial value, or throws if it is required.

Write(DynamoDbWriter, T)

Writes a value as one complete attribute value, for example with WriteNumber<T>(T).

public static abstract void Write(DynamoDbWriter writer, T value)

Parameters

writer DynamoDbWriter

The writer, positioned where a value is expected (after an attribute name, or inside a list). It is a ref struct, so it is never null and needs no check.

value T

The value.