Imagine you're implementing a work experience page and you want users to be able to specify date ranges, but they might not want to be specific about the day or month. For example:
Software Engineer
May 2015 - July 2016
But you want to allow for more or less precision (i.e. a full date or just a year)
These classes can be used to faciliate this concept. It supports rendering (.ToString()
), parsing, and it provides overrides to control this functionality precisely.
PartialDate.cs
public struct PartialDate
{
public int? Year { get; }
public int? Month { get; }
public int? Day { get; }
private static readonly PartialDateFormat[] DefaultFormat =
{
PartialDateField.Month, " ", PartialDateField.Day, ", ", PartialDateField.Year
};
private static readonly PartialDateFormat[] YearFormat = { PartialDateField.Year };
private static readonly PartialDateFormat[] MonthYearFormat =
{
PartialDateField.Month, " ", PartialDateField.Year
};
public PartialDate(int? year = null, int? month = null, int? day = null)
{
Year = year;
Month = month;
Day = day;
}
public bool IsEmpty => Year == null && Month == null && Day == null;
public static PartialDate Parse(string s)
{
return Parse(s, new[] { DefaultFormat, MonthYearFormat, YearFormat });
}
public static PartialDate Parse(string s, PartialDateFormat[][] patterns)
{
if (!TryParse(s, out var result))
throw new FormatException($"Could not parse '{s}'");
return result;
}
public static bool TryParse(string s, out PartialDate result)
{
return TryParse(s, out result, new[] { DefaultFormat, MonthYearFormat, YearFormat });
}
public static bool TryParse(string s, out PartialDate result, PartialDateFormat[][] patterns)
{
if (s == null)
{
result = new PartialDate();
return true;
}
int? year = null;
int? month = null;
int? day = null;
foreach (var pattern in patterns)
{
var literals = new Queue<PartialDateFormat>(pattern.Where(x => x.Field.IsLiteralField()));
foreach (var format in pattern)
{
if (format.Field.IsDateField())
{
PartialDateFormat? nextLiteral = literals.Count > 0 ? (PartialDateFormat?)literals.Dequeue() : null;
var nextLiteralIndex = nextLiteral == null ? s.Length : s.IndexOf(nextLiteral.Value.Format, StringComparison.Ordinal);
if (nextLiteralIndex == -1)
{
goto next;
}
var datePart = s.Substring(0, nextLiteralIndex);
if (!DateTime.TryParseExact(" " + datePart, " " + format.Format, null, DateTimeStyles.None, out var dateComponent))
{
goto next;
}
switch (format.Field)
{
case PartialDateField.Year:
year = dateComponent.Year;
break;
case PartialDateField.Month:
month = dateComponent.Month;
break;
case PartialDateField.Day:
day = dateComponent.Day;
break;
}
s = s.Substring(nextLiteralIndex + (nextLiteral?.Format.Length ?? 0));
}
}
result = new PartialDate(year, month, day);
return true;
next: ;
}
result = default;
return false;
}
public string Encode()
{
return $"{Year?.ToString() ?? "?"}-{Month?.ToString() ?? "?"}-{Day?.ToString() ?? "?"}";
}
public static PartialDate Decode(string value)
{
if (string.IsNullOrEmpty(value))
return default;
var parts = value.Split('-');
int? ParsePart(string s) => s == "?" ? (int?)null : int.Parse(s);
return new PartialDate(ParsePart(parts[0]), ParsePart(parts[1]), ParsePart(parts[2]));
}
public override string ToString()
{
return ToString(DefaultFormat);
}
public int? this[PartialDateField field]
{
get
{
switch (field)
{
case PartialDateField.Year:
return Year;
case PartialDateField.Month:
return Month;
case PartialDateField.Day:
return Day;
default:
throw new ArgumentException("Only date fields are allowed in the indexer", nameof(field));
}
}
}
public string ToString(params PartialDateFormat[] formats)
{
formats = formats.Length == 0 ? DefaultFormat : formats;
var builder = new StringBuilder();
var date = new DateTime(Year ?? 1, Month ?? 1, Day ?? 1);
for (var i = 0; i < formats.Length; i++)
{
var format = formats[i];
switch (format.Field)
{
case PartialDateField.PrefixLiteral:
if (i == formats.Length - 1 || !formats[i + 1].Field.IsDateField())
throw new ArgumentException("A prefix literal must come directly before a date field", nameof(formats));
var prefixValue = this[formats[i + 1].Field];
if (prefixValue != null)
builder.Append(format.Format);
break;
case PartialDateField.SuffixLiteral:
if (i == 0 || !formats[i - 1].Field.IsDateField())
throw new ArgumentException("A suffix literal must come directly after a date field");
var suffixValue = this[formats[i - 1].Field];
if (suffixValue != null)
builder.Append(format.Format);
break;
case PartialDateField.Year:
case PartialDateField.Month:
case PartialDateField.Day:
var value = this[format.Field];
if (value != null)
{
var formattedValue = date.ToString(" " + format.Format).Trim();
builder.Append(formattedValue);
}
break;
}
}
return builder.ToString();
}
public bool Equals(PartialDate other)
{
return Year == other.Year && Month == other.Month && Day == other.Day;
}
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
return obj is PartialDate other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
var hashCode = Year.GetHashCode();
hashCode = (hashCode * 397) ^ Month.GetHashCode();
hashCode = (hashCode * 397) ^ Day.GetHashCode();
return hashCode;
}
}
}
PartialDateField.cs
public enum PartialDateField
{
None,
/// <summary>
/// A literal that will be omitted if the next non-literal field is absent
/// </summary>
PrefixLiteral,
/// <summary>
/// A literal that will be omitted if the previous non-literal field is absent
/// </summary>
SuffixLiteral,
/// <summary>
/// Outputs the year component of a PartialDate
/// </summary>
Year,
/// <summary>
/// Outputs the month component of a PartialDate
/// </summary>
Month,
/// <summary>
/// Outputs the day component of a PartialDate
/// </summary>
Day
}
PartialDateFormat.cs
t
{
public PartialDateField Field { get; }
public string Format { get; }
public PartialDateFormat(PartialDateField field) : this()
{
if (field == PartialDateField.PrefixLiteral || field == PartialDateField.SuffixLiteral || field == PartialDateField.None)
throw new ArgumentException(nameof(field));
Field = field;
switch (field)
{
case PartialDateField.Year:
Format = "yyyy";
break;
case PartialDateField.Month:
Format = "MMMM";
break;
case PartialDateField.Day:
Format = "d";
break;
}
}
public PartialDateFormat(string literal) : this()
{
Format = literal;
}
public PartialDateFormat(PartialDateField field, string format) : this()
{
if (field == PartialDateField.None)
throw new ArgumentException(nameof(field));
Field = field;
Format = format;
}
public static implicit operator PartialDateFormat(PartialDateField field)
{
return new PartialDateFormat(field);
}
public static implicit operator PartialDateFormat(string literal)
{
return Suffix(literal);
}
public static PartialDateFormat Prefix(string prefixLiteral)
{
return new PartialDateFormat(PartialDateField.PrefixLiteral, prefixLiteral);
}
public static PartialDateFormat Suffix(string suffixLiteral)
{
return new PartialDateFormat(PartialDateField.SuffixLiteral, suffixLiteral);
}
}
PartialDateExtensions.cs
public static class PartialDateFieldExtensions
{
public static bool IsDateField(this PartialDateField field)
{
return field == PartialDateField.Year || field == PartialDateField.Month || field == PartialDateField.Day;
}
public static bool IsLiteralField(this PartialDateField field)
{
return field == PartialDateField.PrefixLiteral || field == PartialDateField.SuffixLiteral;
}
}