8
\$\begingroup\$

The most valuable of all talents is that of never using two words when one will do. Thomas Jefferson.

NuGet and GitHub

I found it useful to automatically normalize string content a little bit and explicitly state what kind of content could be held in a field:

[TestMethod]
public void Normalize()
{
    var name = new Name(" Thomas ", null, "  Jefferson \n \r ");
    Assert.AreEqual("Thomas", name.First);
    Assert.AreEqual("", name.Middle);
    Assert.AreEqual("Jefferson", name.Last);
}

where test uses the following demo class:

class Name
{
    public Name(string first, string middle, string last)
        : this((Word)first, (WordOrEmpty)middle, (Word)last)
    {
    }

    public Name(Word first, WordOrEmpty middle, Word last)
    {
        First = first;
        Middle = middle;
        Last = last;
    }

    public Word First { get; }
    public WordOrEmpty Middle { get; }
    public Word Last { get; }
}

Library classes are:

public class Text : String<Text>, IEnumerable<Line>
{
    public static explicit operator Text(string text) => new Text(text);
    public Text(string text)
        : base(text, EmptyIfNull, Trim)
    {
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    public IEnumerator<Line> GetEnumerator() => Text
        .Split(new[] { "\r\n", "\n\r", "\r", "\n" }, StringSplitOptions.None)
        .Select(l => (Line)l)
        .GetEnumerator();
}

And:

public class Line : String<Line>, IEnumerable<Word>
{
    public static explicit operator Line(string text) => new Line(text);
    public Line(string text) 
        : base(text, EmptyIfNull, Trim, SpaceIfNewLine)
    {
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    public IEnumerator<Word> GetEnumerator() => Text
        .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
        .Select(l => (Word)l)
        .GetEnumerator();
}

And:

public class LineOrNull : String<LineOrNull>, IEnumerable<Word>
{
    public static explicit operator LineOrNull(string text) => new LineOrNull(text);
    public LineOrNull(string text)
        : base(text, NullIfEmpty, Trim, SpaceIfNewLine)
    {
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    public IEnumerator<Word> GetEnumerator() => (Text ?? "")
        .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
        .Select(l => (Word)l)
        .GetEnumerator();
}

And:

public class Word : String<Word>
{
    public static explicit operator Word(string text) => new Word(text);
    public Word(string text)
        : base(text, NotNullOrWhitespace, Trim, NotMultiline, NoSpace)
    {
    }
}

And:

public class WordOrNull : String<WordOrNull>
{
    public static explicit operator WordOrNull(string text) => new WordOrNull(text);
    public WordOrNull(string text)
        : base(text, Trim, NullIfEmpty, NotMultiline, NoSpace)
    {
    }
}

And:

public class WordOrEmpty : String<WordOrEmpty>
{
    public static explicit operator WordOrEmpty(string text) => new WordOrEmpty(text);
    public WordOrEmpty(string text)
        : base(text, Trim, EmptyIfNull, NotMultiline, NoSpace)
    {
    }
}

Where:

[JsonConverter(typeof(StringJsonConverter))]
public abstract class String<T> : ValueObject<T>
    where T: String<T>
{
    protected static string Trim(string text) => text?.Trim();
    protected static string EmptyIfNull(string text) => text ?? Empty;
    protected static string NullIfEmpty(string text) => IsNullOrWhiteSpace(text) ? null : text;
    protected static string SpaceIfNewLine(string text) => text
        ?.Replace("\n\r", " ")
        ?.Replace("\r\n", " ")
        ?.Replace("\r", " ")
        ?.Replace("\n", " ");            

    protected static string Upper(string text) => text?.ToUpper();
    protected static string Lower(string text) => text?.ToLower();

    protected static string NotNull(string text) =>
        text ?? throw new TextException();
    protected static string NotNullOrWhitespace(string text) =>
        IsNullOrWhiteSpace(text) ? throw new TextException() :
        text;
    protected static string NotNullOrEmpty(string text) =>
        IsNullOrEmpty(text) ? throw new TextException() : 
        text;
    protected static string NoSpace(string text) =>
        text == null ? null :
        text.Contains(' ') ? throw new TextException() :
        text;
    protected static string NotMultiline(string text) =>
        text == null ? null :
        text.Contains('\n') || text.Contains('\r') ? throw new TextException() :
        text;

    public static implicit operator string(String<T> s) => s?.Text;

    protected String(string text, params Func<string, string>[] actions) => 
        Text = actions.Aggregate(text, (acc, f) => f(acc));

    public string Text { get; set; }

    public override string ToString() => Text;

    protected override IEnumerable<object> EqualityCheckAttributes => 
        new[] { Text };
}

Where:

public class TextException : Exception
{
    public TextException([CallerMemberName] string rule = null)
        : base($"Must be {rule}.")
    {
    }
}

And:

class StringJsonConverter : JsonConverter
{
    public override bool CanConvert(Type objectType) =>
        objectType == typeof(object) ? false :
        objectType.IsConstructedGenericType && objectType.GetGenericTypeDefinition() == typeof(String<>) ? true :
        CanConvert(objectType.BaseType);

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) =>
        Activator.CreateInstance(objectType, reader.Value);

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) =>
        writer.WriteValue(value.ToString());
}

And:

public abstract class ValueObject<T> : IEquatable<ValueObject<T>>
             where T : ValueObject<T>
{
    protected abstract IEnumerable<object> EqualityCheckAttributes { get; }

    public override int GetHashCode() =>
        EqualityCheckAttributes
            .Aggregate(0, (hash, a) => unchecked(hash * 31 + (a?.GetHashCode() ?? 0)));

    public override bool Equals(object obj) =>
        Equals(obj as ValueObject<T>);

    public virtual bool Equals(ValueObject<T> other) =>
        other != null &&
        GetType() == other.GetType() &&
        EqualityCheckAttributes.SequenceEqual(other.EqualityCheckAttributes);

    public static bool operator ==(ValueObject<T> left, ValueObject<T> right) =>
        Equals(left, right);

    public static bool operator !=(ValueObject<T> left, ValueObject<T> right) =>
        !Equals(left, right);
}
\$\endgroup\$
  • 1
    \$\begingroup\$ Would you mind clarifying why you provide string overloads for the construct of Name? It's not clear to me why it should be the job of Name (or other similar classes) to normalise those inputs. \$\endgroup\$ – VisualMelon Sep 26 at 8:10
  • 1
    \$\begingroup\$ @VisualMelon compare to StreamWriter ctor overloads. It saves a bunch of typing by having a primitive type based overload, especially while unit testing. \$\endgroup\$ – Dmitry Nogin Sep 26 at 14:46
  • 1
    \$\begingroup\$ I would group the static helpers in nested classes like Filters and Constraints so their usage would be easier to understand like: base(text, Filters.Trim, Filters.NullIfEmpty, Constraints.NotMultiline, Constraints.NoSpace) \$\endgroup\$ – t3chb0t Oct 3 at 20:45
  • 1
    \$\begingroup\$ @t3chb0t How Use.Trimmed, Use.NullIfEmpty and Only.NotMultiline sound? \$\endgroup\$ – Dmitry Nogin Oct 3 at 20:49
  • 1
    \$\begingroup\$ You're thinking about making it fluent? ;-] then we'll need to think harder :P \$\endgroup\$ – t3chb0t Oct 3 at 20:52
8
\$\begingroup\$

I don't have much to say, but this stuff does look useful. The types are designed to be exposed to the world, so it's transparent to the consumer and could make the intentions clear.

There is one huge problem:

public string Text { get; set; }

Anyone can change this value and by-pass all the rules: I assume this is a typo and you intended for this to be getter-only.

General Stuff

This all needs documenting, to explain what the different classes do. SpaceIfNewLine is one example that baffles me completely: I would expect Line to fail if someone threw a complete text at it. I don't like this behaviour, because we shouldn't allow the user to accidently overlook that and give them nonsensical but meaningful results to trip over later (fail fast and all that); if you want this behaviour, then it must be documented clearly. NotMultiline and NoSpace with Word make much more sense to me.

I don't like how you swap null or "" and vice-versa in places. Perhaps this is completely necessary in your domain, but receiving a null all too often indicates a programming error (not necessarily on the part of the component that produces the null originally), and quietly coercing it obscures this from the caller.

The behaviour of ValueObject<T> really needs documentating: it is completely opaque to the consumer, and will create confusion if someone extends a class that extends it. Word etc. should probably be sealed.

Exceptions

The exceptions this produces will be cryptic: "Must be IsNullOrEmpty". I would ditch the CallerMemberName stuff and just force yourself to write a clear message, or atleast change it to $"String value violates rule {rule}" (where the grammar won't go funky) and rename it to a TextRuleViolationException, or something like that: it shoudn't be used by things that aren't 'rules', because then the message would be meaningless.

Buffering the CallerMemberName bit inside a TextException Violated(string rule) method in String<T> would avoid misuse of this otherwise public API from outside, and save you typing new in each rule, which you seem to be very keep to avoid.

There is also no provision for informing the user what was in violation: as it first or last that went wrong when I tried to create my name? Your code cannot adequately support the stringy constructor for Name (which I don't think should exist anyway) without some addition method to perform the conversion, catch the exception, and annotate it with the parameter name.

\$\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.