From c311c7db9e5fbf78051caf88d4ef4d6dd6d2577a Mon Sep 17 00:00:00 2001 From: Slava Date: Fri, 16 Oct 2026 23:28:11 +0600 Subject: [PATCH] fix: read a date with its month in words, such as 17 SEP 1992 Closes #121 --- docs/KNOWN-ISSUES.md | 16 ++++-- src/TriasDev.Tabular/Analysis/DateReading.cs | 56 +++++++++++++++++-- .../Analysis/PartialDateTests.cs | 46 +++++++++++++++ 3 files changed, 110 insertions(+), 8 deletions(-) diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 3b9a50c..a725087 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -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 diff --git a/src/TriasDev.Tabular/Analysis/DateReading.cs b/src/TriasDev.Tabular/Analysis/DateReading.cs index 872066b..01384cd 100644 --- a/src/TriasDev.Tabular/Analysis/DateReading.cs +++ b/src/TriasDev.Tabular/Analysis/DateReading.cs @@ -24,9 +24,9 @@ namespace TriasDev.Tabular; /// names all three parts. /// /// -/// The cost is real and worth stating: 15 Jan 2023 has no separators and is refused, as is a -/// genuine 0001-01-01. 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: +/// 15 Jan 2023 is a date, 15 Jan 23 is text. A genuine 0001-01-01 is refused, +/// and is recorded in the known-issues register rather than pretended away. /// /// internal static class DateReading @@ -96,9 +96,17 @@ private static bool HasZone(ReadOnlySpan text) /// Whether the text is shaped like a date at all. /// /// + /// /// 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. + /// + /// + /// A month in words — 17 SEP 1992, September 17, 1992 — is set apart by spaces, which + /// the count above does not see. It cannot simply count them: Mon 17 Sep 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. + /// /// public static bool LooksLikeOne(string text) { @@ -110,6 +118,46 @@ public static bool LooksLikeOne(string text) ReadOnlySpan span = text.AsSpan(); int separators = span.Count('-') + span.Count('/') + span.Count('.'); - return separators >= 2; + return separators >= 2 || LooksLikeOneInWords(span, separators); + } + + /// + /// 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. + /// + /// + /// 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. + /// + private static bool LooksLikeOneInWords(ReadOnlySpan 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; } } diff --git a/tests/TriasDev.Tabular.Tests/Analysis/PartialDateTests.cs b/tests/TriasDev.Tabular.Tests/Analysis/PartialDateTests.cs index 2a7829d..ac98f5a 100644 --- a/tests/TriasDev.Tabular.Tests/Analysis/PartialDateTests.cs +++ b/tests/TriasDev.Tabular.Tests/Analysis/PartialDateTests.cs @@ -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 @@ -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 run = TabularImporter.Import( + stream, + "t.csv", + plan, + schema, + row => row[schema.Fields[0] as DateImportField ?? throw new InvalidOperationException()], cancellationToken: TestContext.Current.CancellationToken); + + ImportResult 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() {