Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/KNOWN-ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,25 @@ than read as csv. Matters if anyone sends one.

## Contracts looser than they read

### A date must carry two separators, so `15 Jan 2023` is text
### A date must carry two separators, or a month in words and a four-digit year at its end

`DateReading.LooksLikeOne` requires two of `-`, `/`, `.`, or a colon, before a parse is attempted.
That is what refuses a decimal (`1.5` reads as the fifth of January under en-US) and a day-and-month
(`3/15` takes the current year), and the price is a date written with spaces and a month name.
(`3/15` takes the current year). A date with its month in words — `17 SEP 1992`,
`September 17, 1992` — counts its spaces as separators too, but only when it ends in a year of four
digits, because the parser completes `Mon 17 Sep` with the current year just as it does `3/15`. So
`17 SEP 92` and `1992 SEP 17` are text.

A month and a year alone (`MAY-19`) and a day and a month alone (`8-MAR`) stay text whatever the
spelling: the cultures disagree on what they mean — under en-US `MAY-19` is the nineteenth of May this
year, under de-DE it is May 2019 — and a guess would import a date the file may never have held.

A genuine `0001-01-01` is refused for the neighbouring reason: year one is the marker that the text
named no year.

**Matters when** a customer exports dates in a long form. The fix is a parse against the culture's
year-bearing patterns rather than a shape test, which is more code than the case has so far earned.
**Matters when** a customer exports dates with a two-digit year in words, or months without days. The
fix for the first is a parse against the culture's year-bearing patterns rather than a shape test; the
second needs a reading the caller chooses, not one the library guesses.

### Dates cannot carry a range
`MinValue`/`MaxValue` compare numbers, so a range on a date field is refused by the validator
Expand Down
56 changes: 52 additions & 4 deletions src/TriasDev.Tabular/Analysis/DateReading.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ namespace TriasDev.Tabular;
/// names all three parts.
/// </para>
/// <para>
/// The cost is real and worth stating: <c>15 Jan 2023</c> has no separators and is refused, as is a
/// genuine <c>0001-01-01</c>. Both are recorded in the known-issues register rather than pretended
/// away.
/// A month in words passes the shape test when the value ends in a four-digit year:
/// <c>15 Jan 2023</c> is a date, <c>15 Jan 23</c> is text. A genuine <c>0001-01-01</c> is refused,
/// and is recorded in the known-issues register rather than pretended away.
/// </para>
/// </remarks>
internal static class DateReading
Expand Down Expand Up @@ -96,9 +96,17 @@ private static bool HasZone(ReadOnlySpan<char> text)
/// Whether the text is shaped like a date at all.
/// </summary>
/// <remarks>
/// <para>
/// Two separators, because one is how a decimal and a day-and-month are written and the parser
/// takes both. A colon admits a time of day so that the parse can happen and the year check can
/// refuse it, which is more honest than never looking.
/// </para>
/// <para>
/// A month in words — <c>17 SEP 1992</c>, <c>September 17, 1992</c> — is set apart by spaces, which
/// the count above does not see. It cannot simply count them: <c>Mon 17 Sep</c> has two, and the
/// parser completes it with the current year, which the year check cannot tell from a year the
/// text stated. So a value in words must also end in a year of four digits.
/// </para>
/// </remarks>
public static bool LooksLikeOne(string text)
{
Expand All @@ -110,6 +118,46 @@ public static bool LooksLikeOne(string text)
ReadOnlySpan<char> span = text.AsSpan();
int separators = span.Count('-') + span.Count('/') + span.Count('.');

return separators >= 2;
return separators >= 2 || LooksLikeOneInWords(span, separators);
}

/// <summary>
/// Whether the text could be a date with its month in words: a year of exactly four digits at its
/// end, a letter before it, and at least two separators once spaces count among them.
/// </summary>
/// <remarks>
/// The profile asks this of every value the separator count refused, which is most text, so a
/// value that does not end in four digits is refused by its last characters, before any scan.
/// The year must end the value because an address leads with its number: in a file of five million
/// locations, a four-digit run anywhere sent 1.4 million street names on to three failing parses
/// and cost the analysis 7%; at the end, thirty thousand.
/// </remarks>
private static bool LooksLikeOneInWords(ReadOnlySpan<char> text, int separators)
{
text = text.TrimEnd();

if (text.Length is < 8 or > 40
|| !char.IsAsciiDigit(text[^1]) || !char.IsAsciiDigit(text[^2])
|| !char.IsAsciiDigit(text[^3]) || !char.IsAsciiDigit(text[^4])
|| char.IsAsciiDigit(text[^5]))
{
return false;
}

bool letter = false;

foreach (char c in text[..^4])
{
if (c == ' ')
{
separators++;
}
else
{
letter |= char.IsLetter(c);
}
}

return letter && separators >= 2;
}
}
46 changes: 46 additions & 0 deletions tests/TriasDev.Tabular.Tests/Analysis/PartialDateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ private static FileProfile Profile(string csv, AnalysisOptions? options = null)
[InlineData("January 2023", "en-US")]
[InlineData("08:30", "en-US")]
[InlineData("1.5", "en-US")]
[InlineData("Mon 17 Sep", "en-US")]
[InlineData("17 Sep", "")]
public void DoesNotInventTheMissingHalfOfADate(string value, string culture)
{
// The parser fills in whatever the text leaves out, from the clock: a bare time becomes
Expand Down Expand Up @@ -72,6 +74,50 @@ public void TheExtractorRefusesWhatTheProfilerRefuses()
Assert.Equal(1, run.Summary.RowsFailed);
}

// -- A date written in words names itself as completely as one written in figures ------------

[Theory]
[InlineData("17 SEP 1992", "")]
[InlineData("17 SEP 1992", "en-US")]
[InlineData("17 SEP 1992", "de-DE")]
[InlineData("17 September 1992", "en-US")]
[InlineData("September 17, 1992", "en-US")]
[InlineData("17. September 1992", "de-DE")]
public void ReadsADateWithTheMonthInWords(string value, string culture)
{
// Spaces were not separators to the shape test, so a day, a month name and a year was text
// while the same date with dots in it was a date.
FileProfile profile = Profile($"when\n{value}\n", new AnalysisOptions { Cultures = [culture] });

Assert.Equal(new DateTime(1992, 9, 17, 0, 0, 0, DateTimeKind.Unspecified), profile.Sheets[0].Columns[0].Facts.MinDate);
}

[Fact]
public void TheExtractorReadsADateWithTheMonthInWords()
{
ImportSchema schema = new() { Fields = [ImportField.Date("when")] };

MappingPlan plan = new()
{
Culture = "",
Bindings = [new ColumnBinding { ColumnIndex = 0, Header = "when", FieldName = "when" }],
};

using MemoryStream stream = new(Utf8NoBom.GetBytes("when\n17 SEP 1992\n"), writable: false);

using ImportRun<DateTime?> run = TabularImporter.Import(
stream,
"t.csv",
plan,
schema,
row => row[schema.Fields[0] as DateImportField ?? throw new InvalidOperationException()], cancellationToken: TestContext.Current.CancellationToken);

ImportResult<DateTime?> result = run.ReadAll(cancellationToken: TestContext.Current.CancellationToken);

Assert.Empty(result.Errors);
Assert.Equal([new DateTime(1992, 9, 17, 0, 0, 0, DateTimeKind.Unspecified)], result.Items);
}

[Fact]
public void StillReadsADateThatNamesItselfCompletely()
{
Expand Down
Loading