diff --git a/.gitignore b/.gitignore
index 7476efd..05ca0b0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
-Tewl/Directory.Build.props
Tewl/Generated Code/
+Tests/Generated Code/
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
diff --git a/Directory.Build.props b/Directory.Build.props
index b3389b3..6dd7acc 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,5 +1,5 @@
-netstandard2.0
+net9.0
diff --git a/EWL ReSharper Settings.DotSettings b/EWL ReSharper Settings.DotSettings
index 686c627..d2399a6 100644
--- a/EWL ReSharper Settings.DotSettings
+++ b/EWL ReSharper Settings.DotSettings
@@ -478,6 +478,7 @@
Never
False
True
+ True
True
True
True
@@ -1311,6 +1312,7 @@
True
True
True
+ True
NotOverridden
NotOverridden
NotOverridden
diff --git a/TEWL.sln b/TEWL.sln
index 56f2daa..60d4b71 100644
--- a/TEWL.sln
+++ b/TEWL.sln
@@ -1,11 +1,11 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 16
-VisualStudioVersion = 16.0.29911.98
+# Visual Studio Version 18
+VisualStudioVersion = 18.1.11312.151 d18.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tewl", "Tewl\Tewl.csproj", "{C6158C5A-CD33-4193-80CC-A9BBD7EC60D7}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TewlTester", "TewlTester\TewlTester.csproj", "{F250853F-5A4E-46E8-BFFB-C142DBE996F6}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{F250853F-5A4E-46E8-BFFB-C142DBE996F6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
diff --git a/Tests/.editorconfig b/Tests/.editorconfig
new file mode 100644
index 0000000..631ffb9
--- /dev/null
+++ b/Tests/.editorconfig
@@ -0,0 +1,3 @@
+# generated by EWL
+[*.cs]
+csharp_default_internal_modifier = implicit
diff --git a/Tests/ByteFormattingTests.cs b/Tests/ByteFormattingTests.cs
new file mode 100644
index 0000000..cc7dd44
--- /dev/null
+++ b/Tests/ByteFormattingTests.cs
@@ -0,0 +1,23 @@
+namespace Tests;
+
+class ByteFormattingTests {
+ [ Test ]
+ public void Bytes() {
+ Assert.That( FormattingMethods.GetFormattedBytes( 64 ), Is.EqualTo( "64 bytes" ) );
+ }
+
+ [ Test ]
+ public void Kilo() {
+ Assert.That( FormattingMethods.GetFormattedBytes( 64_000 ), Is.EqualTo( "62 KiB" ) );
+ }
+
+ [ Test ]
+ public void Mega() {
+ Assert.That( FormattingMethods.GetFormattedBytes( 64_000_000 ), Is.EqualTo( "61 MiB" ) );
+ }
+
+ [ Test ]
+ public void Giga() {
+ Assert.That( FormattingMethods.GetFormattedBytes( 64_500_000_000 ), Is.EqualTo( "60.1 GiB" ) );
+ }
+}
\ No newline at end of file
diff --git a/Tests/Directory.Build.props b/Tests/Directory.Build.props
new file mode 100644
index 0000000..a5745e8
--- /dev/null
+++ b/Tests/Directory.Build.props
@@ -0,0 +1,36 @@
+
+
+
+Tests
+Tests
+28.0.41.0
+false
+TEWL
+TEWL - Tests
+0
+win-x64
+true
+enable
+true
+$(DefaultItemExcludesInProjectFolder);Directory.Build.props;Directory.Build.targets;**/*.ewlt.cs
+0
+direct
+true
+
+
+
+
+all
+runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+True
+
+
diff --git a/Tests/OldTester.cs b/Tests/OldTester.cs
new file mode 100644
index 0000000..c740efc
--- /dev/null
+++ b/Tests/OldTester.cs
@@ -0,0 +1,76 @@
+using Tewl.IO;
+using Tewl.IO.TabularDataParsing;
+
+namespace Tests;
+
+[ TestFixture ]
+class Program {
+ [ Test ]
+ public static void OldMain() {
+ testExcelWriting();
+ testCsvWriting();
+ testCsv();
+ testTabDelimitedWriting();
+ testXls();
+ }
+
+ private static void testExcelWriting() {
+ var excelFile = new ExcelFileWriter();
+ excelFile.DefaultWorksheet.AddHeaderToWorksheet( "ID", "Name", "Date", "Email", "Website" );
+ excelFile.DefaultWorksheet.AddRowToWorksheet( "123", "Greg", "1/1/2012", "greg.smalter@gmail.com", "https://www.google.com" );
+ excelFile.DefaultWorksheet.AddRowToWorksheet( "321", "", "12/19/2020", "", "https://microsoft.com" );
+
+ using var stream = File.OpenWrite( "tewlTestTabularWrite.xlsx" );
+ excelFile.SaveToStream( stream );
+ }
+
+ private static void testCsvWriting() {
+ var csvFile = new CsvFileWriter();
+
+ using var stream = new StreamWriter( File.OpenWrite( "tewlTestTabularWrite.csv" ) );
+ writeData( csvFile, stream );
+ }
+
+ private static void testTabDelimitedWriting() {
+ var csvFile = new TabDelimitedFileWriter();
+
+ using var stream = new StreamWriter( File.OpenWrite( "tewlTestTabularWrite.txt" ) );
+ writeData( csvFile, stream );
+ }
+
+ // It's sort of a failure that the Excel writer cannot be passed here. But between there being more than one worksheet and other problems, it's hard to
+ // have it implement the same interface.
+ private static void writeData( TextBasedTabularDataFileWriter writer, TextWriter stream ) {
+ writer.AddValuesToLine( "ID", "Name", "Date", "Email", "Website" );
+ writer.WriteCurrentLineToFile( stream );
+ writer.AddValuesToLine( "123", "Greg", "1/1/2012", "greg.smalter@gmail.com", "https://www.google.com" );
+ writer.WriteCurrentLineToFile( stream );
+ writer.AddValuesToLine( "123", "Greg", "1/1/2012", "greg.smalter@gmail.com", "https://www.google.com" );
+ writer.WriteCurrentLineToFile( stream );
+ }
+
+ private static void testCsv() {
+ var csvParser = TabularDataParser.CreateForCsvFile( @"..\..\..\..\TestFiles\TewlTestBook.csv", [ ] );
+ var validationErrors = new List();
+
+ csvParser.ParseAndProcessAllLines( importThing, validationErrors );
+
+ Console.WriteLine( $"CSV test: {csvParser.RowsWithoutValidationErrors} rows imported without error." );
+ }
+
+ private static void testXls() {
+ var xlsParser = TabularDataParser.CreateForExcelFile( @"..\..\..\..\TestFiles\TewlTestBook.xlsx", [ ] );
+ var validationErrors = new List();
+
+ xlsParser.ParseAndProcessAllLines( importThing, validationErrors );
+
+ Console.WriteLine( $"Excel test: {xlsParser.RowsWithoutValidationErrors} rows imported without error." );
+ }
+
+ private static void importThing( ParsedLine line, Tewl.InputValidation.Validator validator ) {
+ var value = line[ "dATe" ];
+ var email = line[ "email" ];
+ var website = line[ "website" ];
+ Console.WriteLine( line.LineNumber + ": Date: " + value + $", Email: {email}, Website: {website}" );
+ }
+}
\ No newline at end of file
diff --git a/TewlTester/TestFiles/TewlTestBook.csv b/Tests/TestFiles/TewlTestBook.csv
similarity index 100%
rename from TewlTester/TestFiles/TewlTestBook.csv
rename to Tests/TestFiles/TewlTestBook.csv
diff --git a/TewlTester/TestFiles/TewlTestBook.xlsx b/Tests/TestFiles/TewlTestBook.xlsx
similarity index 100%
rename from TewlTester/TestFiles/TewlTestBook.xlsx
rename to Tests/TestFiles/TewlTestBook.xlsx
diff --git a/TewlTester/TewlTester.csproj b/Tests/Tests.csproj
similarity index 66%
rename from TewlTester/TewlTester.csproj
rename to Tests/Tests.csproj
index 140636b..598e92c 100644
--- a/TewlTester/TewlTester.csproj
+++ b/Tests/Tests.csproj
@@ -1,10 +1,13 @@
-
+
- Exe
- net8.0-windows
+ net9.0-windows
+
+
+
+
diff --git a/Tewl/Directory.Build.props b/Tewl/Directory.Build.props
new file mode 100644
index 0000000..dcb58ae
--- /dev/null
+++ b/Tewl/Directory.Build.props
@@ -0,0 +1,28 @@
+
+
+
+Tewl
+Tewl
+28.0.41.0
+false
+TEWL
+TEWL
+0
+true
+enable
+true
+$(DefaultItemExcludesInProjectFolder);Directory.Build.props;Directory.Build.targets;**/*.ewlt.cs
+direct
+true
+
+
+
+
+
+
+
+
+
+True
+
+
diff --git a/Tewl/FormattingMethods.cs b/Tewl/FormattingMethods.cs
index 2b31d6a..77d5fa4 100644
--- a/Tewl/FormattingMethods.cs
+++ b/Tewl/FormattingMethods.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using JetBrains.Annotations;
using Tewl.InputValidation;
using Tewl.Tools;
diff --git a/Tewl/ICalendar/ICalendarTools.cs b/Tewl/ICalendar/ICalendarTools.cs
new file mode 100644
index 0000000..3320240
--- /dev/null
+++ b/Tewl/ICalendar/ICalendarTools.cs
@@ -0,0 +1,21 @@
+using Ical.Net.DataTypes;
+using NodaTime;
+
+namespace Tewl.ICalendar;
+
+///
+/// Static methods pertaining to the iCalendar format.
+///
+[ PublicAPI ]
+public static class ICalendarTools {
+ ///
+ /// Creates an iCalendar date/time from this ZonedDateTime.
+ ///
+ public static CalDateTime ToICalendarTime( this ZonedDateTime time ) =>
+ new( time.Year, time.Month, time.Day, time.Hour, time.Minute, time.Second, time.Zone.Id );
+
+ ///
+ /// Creates an iCalendar date from this LocalDate.
+ ///
+ public static CalDateTime ToICalendarDate( this LocalDate date ) => new( date.Year, date.Month, date.Day );
+}
\ No newline at end of file
diff --git a/Tewl/IO/CsvFileWriter.cs b/Tewl/IO/CsvFileWriter.cs
index 5b5789a..499c6ac 100644
--- a/Tewl/IO/CsvFileWriter.cs
+++ b/Tewl/IO/CsvFileWriter.cs
@@ -1,4 +1,4 @@
-using System.IO;
+using System.IO;
using JetBrains.Annotations;
namespace Tewl.IO {
diff --git a/Tewl/IO/ExcelWorksheet.cs b/Tewl/IO/ExcelWorksheet.cs
index 06d92ef..e6516b4 100644
--- a/Tewl/IO/ExcelWorksheet.cs
+++ b/Tewl/IO/ExcelWorksheet.cs
@@ -83,31 +83,26 @@ private void addRowToWorksheet( bool bold, params string[] cellValues ) {
}
private void putRowValueInCell( IXLCell cell, string value ) {
- var v = new Validator();
- var detectedDate = v.GetNullableDateTime(
- new ValidationErrorHandler( "" ),
- value,
- DateTimeTools.DayMonthYearFormats.Concat( DateTimeTools.MonthDayYearFormats ).ToArray(),
- false,
- DateTime.MinValue,
- DateTime.MaxValue );
- if( !v.ErrorsOccurred ) {
+ if( new Validator().GetNullableDateTime(
+ new ValidationErrorHandler( "" ),
+ value,
+ DateTimeTools.DayMonthYearFormats.Concat( DateTimeTools.MonthDayYearFormats ).ToArray(),
+ false,
+ DateTime.MinValue,
+ DateTime.MaxValue )
+ .Error( out var detectedDate ) is null ) {
setOrAddCellStyle( cell, date: true );
cell.Value = detectedDate;
return;
}
- v = new Validator();
- v.GetEmailAddress( new ValidationErrorHandler( "" ), value, false );
- if( !v.ErrorsOccurred ) {
+ if( new Validator().GetEmailAddress( new ValidationErrorHandler( "" ), value, false ).Error( out _ ) is null ) {
cell.Value = value;
cell.SetHyperlink( new XLHyperlink( "mailto:" + value ) );
return;
}
- v = new Validator();
- var validatedUrl = v.GetUrl( new ValidationErrorHandler( "" ), value, false );
- if( !v.ErrorsOccurred ) {
+ if( new Validator().GetUrl( new ValidationErrorHandler( "" ), value, false ).Error( out var validatedUrl ) is null ) {
cell.Value = value;
cell.SetHyperlink( new XLHyperlink( validatedUrl ) );
return;
diff --git a/Tewl/IO/FileReader.cs b/Tewl/IO/FileReader.cs
index c1764e0..f184c19 100644
--- a/Tewl/IO/FileReader.cs
+++ b/Tewl/IO/FileReader.cs
@@ -1,4 +1,4 @@
-using System.IO;
+using System.IO;
using System.Text;
using JetBrains.Annotations;
diff --git a/Tewl/IO/IoMethods.cs b/Tewl/IO/IoMethods.cs
index 9964a57..2b0a110 100644
--- a/Tewl/IO/IoMethods.cs
+++ b/Tewl/IO/IoMethods.cs
@@ -1,4 +1,5 @@
-using System.Net;
+using System.Net;
+using System.Text;
using System.Threading;
namespace Tewl.IO;
@@ -146,13 +147,16 @@ public static long GetFolderSize( string path ) =>
File.Exists( path ) ? new FileInfo( path ).Length : Directory.GetFileSystemEntries( path ).Sum( filePath => GetFolderSize( filePath ) );
///
- /// Returns a text writer for writing a new file or overwriting an existing file.
- /// Automatically creates any folders needed in the given path, if necessary.
- /// We recommend passing an absolute path. If a relative path is passed, the working folder
- /// is used as the root path.
- /// Caller is responsible for properly disposing the stream.
+ /// Returns a text writer for writing a new file or overwriting an existing file, using UTF-8 encoding. Automatically creates any folders needed in the given
+ /// path, if necessary. We recommend passing an absolute path. If a relative path is passed, the working folder is used as the root path. Caller is
+ /// responsible for properly disposing the stream.
///
- public static TextWriter GetTextWriterForWrite( string filePath ) => new StreamWriter( GetFileStreamForWrite( filePath ) );
+ ///
+ /// Pass true to include a byte-order mark (BOM) indicating that the file is encoded with UTF-8. This helps avoid misinterpreted
+ /// characters when reading the file, especially if it is plain text. Pass false for XML files or any other format that has its own encoding declaration.
+ ///
+ public static TextWriter GetTextWriterForWrite( string filePath, bool includeBom ) =>
+ new StreamWriter( GetFileStreamForWrite( filePath ), new UTF8Encoding( includeBom, true ) );
///
/// Returns a file stream for writing a new file or overwriting an existing file.
diff --git a/Tewl/IO/Output.cs b/Tewl/IO/Output.cs
index b4e4414..267a3ee 100644
--- a/Tewl/IO/Output.cs
+++ b/Tewl/IO/Output.cs
@@ -1,39 +1,19 @@
-using System;
-using System.IO;
-using JetBrains.Annotations;
-using Tewl.Tools;
+namespace Tewl.IO;
-namespace Tewl.IO {
+///
+/// Helps communicate with standard out, standard error, and files.
+///
+[ PublicAPI ]
+public class Output {
///
- /// Helps communicate with standard out, standard error, and files.
+ /// Permanently redirects standard output and error to file, with autoflushing enabled.
///
- [ PublicAPI ]
- public class Output {
- ///
- /// Permanently redirects standard output and error to file, with autoflushing enabled.
- ///
- public static void RedirectOutputToFile( string outputFileName, string errorFileName ) {
- var outputWriter = new StreamWriter( outputFileName, true );
- var errorWriter = new StreamWriter( errorFileName, true );
- outputWriter.AutoFlush = true;
- errorWriter.AutoFlush = true;
- Console.SetOut( outputWriter );
- Console.SetError( errorWriter );
- }
-
- ///
- /// Writes the message prepended by a timestamp.
- ///
- public static void WriteTimeStampedOutput( string message ) => Console.Out.WriteLine( $"{getTimestamp()} {message}" );
-
- ///
- /// Writes the error message prepended by a timestamp.
- ///
- public static void WriteTimeStampedError( string message ) => Console.Error.WriteLine( $"{getTimestamp()} {message}" );
-
- private static string getTimestamp() {
- var now = DateTime.Now;
- return $"{now.ToDayMonthYearString( true )}, {now.ToString( "HH:mm:ss", Cultures.EnglishUnitedStates )}";
- }
+ public static void RedirectOutputToFile( string outputFileName, string errorFileName ) {
+ var outputWriter = new StreamWriter( outputFileName, true );
+ var errorWriter = new StreamWriter( errorFileName, true );
+ outputWriter.AutoFlush = true;
+ errorWriter.AutoFlush = true;
+ Console.SetOut( outputWriter );
+ Console.SetError( errorWriter );
}
}
\ No newline at end of file
diff --git a/Tewl/IO/TabularDataParsing/CsvLineParser.cs b/Tewl/IO/TabularDataParsing/CsvLineParser.cs
index 8fbecdd..9d59952 100644
--- a/Tewl/IO/TabularDataParsing/CsvLineParser.cs
+++ b/Tewl/IO/TabularDataParsing/CsvLineParser.cs
@@ -1,116 +1,88 @@
-using System.Collections.Generic;
-using System.IO;
-using System.Text;
-using JetBrains.Annotations;
-using Tewl.Tools;
+using System.Text;
-namespace Tewl.IO.TabularDataParsing {
+namespace Tewl.IO.TabularDataParsing;
+
+///
+/// Parses a line of a Microsoft Excel CSV file using the definition of CSV at
+/// http://en.wikipedia.org/wiki/Comma-separated_values.
+///
+[ PublicAPI ]
+internal class CsvLineParser: TextBasedTabularDataParser {
///
- /// Parses a line of a Microsoft Excel CSV file using the definition of CSV at
- /// http://en.wikipedia.org/wiki/Comma-separated_values.
+ /// Creates a parser designed to parse a CSV file. Passing true for hasHeaderRow will result in the first row being used to map
+ /// header names to column indices. This will allow you to access fields using the header name in addition to the column index.
///
- [PublicAPI]
- internal class CsvLineParser: TextBasedTabularDataParser {
- private readonly Dictionary columnHeadersToIndexes = new Dictionary();
-
- ///
- /// Creates a line parser with no header row. Fields will be access via indexes rather than by column name.
- ///
- public CsvLineParser() { }
-
- ///
- /// Creates a parser designed to parse a CSV file. Passing true for hasHeaderRow will result in the first row being used to map
- /// header names to column indices. This will allow you to access fields using the header name in addition to the column index.
- ///
- public static TabularDataParser CreateWithFilePath( string filePath, bool hasHeaderRow ) {
- return new CsvLineParser { fileReader = new FileReader( filePath ), hasHeaderRow = hasHeaderRow };
- }
-
- ///
- /// Creates a parser designed to parse a CSV file. Passing true for hasHeaderRow will result in the first row being used to map
- /// header names to column indices. This will allow you to access fields using the header name in addition to the column index.
- ///
- public static TabularDataParser CreateWithStream( Stream stream, bool hasHeaderRow ) {
- return new CsvLineParser { fileReader = new FileReader( stream ), hasHeaderRow = hasHeaderRow };
- }
-
- ///
- /// Creates a line parser with a header row. The column names are extracted from the header row, and
- /// parsed CsvLines will allow field access through column name or column index.
- ///
- public CsvLineParser( string headerLine ) {
- var index = 0;
- foreach( var columnHeader in ( Parse( headerLine ) as TextBasedParsedLine ).Fields ) {
- columnHeadersToIndexes[ columnHeader.ToLower() ] = index;
- index++;
- }
- }
+ public CsvLineParser( string filePath ) {
+ fileReader = new FileReader( filePath );
+ }
- ///
- /// Parses a line of a Microsoft Excel CSV file and returns a collection of string fields.
- /// Internal use only.
- /// Use ParseAndProcessAllLines instead.
- ///
- internal override TextBasedParsedLine Parse( string line ) {
- var fields = new List();
- if( !line.IsNullOrWhiteSpace() ) {
- using( TextReader tr = new StringReader( line ) )
- parseCommaSeparatedFields( tr, fields );
- }
- var parsedLine = new TextBasedParsedLine( fields );
- parsedLine.ColumnHeadersToIndexes = columnHeadersToIndexes;
- return parsedLine;
- }
+ ///
+ /// Creates a parser designed to parse a CSV file. Passing true for hasHeaderRow will result in the first row being used to map
+ /// header names to column indices. This will allow you to access fields using the header name in addition to the column index.
+ ///
+ public CsvLineParser( Stream stream ) {
+ fileReader = new FileReader( stream );
+ }
- private static void parseCommaSeparatedFields( TextReader tr, List fields ) {
- parseCommaSeparatedField( tr, fields );
- while( tr.Peek() == ',' ) {
- tr.Read();
+ ///
+ /// Parses a line of a Microsoft Excel CSV file and returns a collection of string fields.
+ ///
+ protected override IReadOnlyList parseLine( string? line ) {
+ var fields = new List();
+ if( !line.IsNullOrWhiteSpace() )
+ using( TextReader tr = new StringReader( line ) )
parseCommaSeparatedFields( tr, fields );
- }
- }
+ return fields;
+ }
- private static void parseCommaSeparatedField( TextReader tr, List fields ) {
- if( tr.Peek() != -1 ) {
- string field;
- if( tr.Peek() != '"' )
- field = parseSimpleField( tr );
- else
- field = parseQuotedField( tr );
- fields.Add( field.Trim() );
- }
+ private void parseCommaSeparatedFields( TextReader tr, List fields ) {
+ parseCommaSeparatedField( tr, fields );
+ while( tr.Peek() == ',' ) {
+ tr.Read();
+ parseCommaSeparatedFields( tr, fields );
}
+ }
- private static string parseSimpleField( TextReader tr ) {
- var sb = new StringBuilder();
+ private void parseCommaSeparatedField( TextReader tr, List fields ) {
+ if( tr.Peek() != -1 ) {
+ string field;
+ if( tr.Peek() != '"' )
+ field = parseSimpleField( tr );
+ else
+ field = parseQuotedField( tr );
+ fields.Add( field.Trim() );
+ }
+ }
- var ch = tr.Peek();
- while( ch != -1 && ch != ',' ) {
- sb.Append( (char)tr.Read() );
- ch = tr.Peek();
- }
+ private string parseSimpleField( TextReader tr ) {
+ var sb = new StringBuilder();
- return sb.ToString();
+ var ch = tr.Peek();
+ while( ch != -1 && ch != ',' ) {
+ sb.Append( (char)tr.Read() );
+ ch = tr.Peek();
}
- private static string parseQuotedField( TextReader tr ) {
- var sb = new StringBuilder();
+ return sb.ToString();
+ }
- // Skip the opening quote
- tr.Read();
+ private string parseQuotedField( TextReader tr ) {
+ var sb = new StringBuilder();
- var ch = tr.Read();
- // Continue until the end of the file or until we reach an unescaped quote.
- while( ch != -1 && !( ch == '"' && tr.Peek() != '"' ) ) {
- // If we encounter an escaped double quote, skip one of the double quotes.
- if( ch == '"' && tr.Peek() == '"' )
- tr.Read();
+ // Skip the opening quote
+ tr.Read();
- sb.Append( (char)ch );
- ch = tr.Read();
- }
+ var ch = tr.Read();
+ // Continue until the end of the file or until we reach an unescaped quote.
+ while( ch != -1 && !( ch == '"' && tr.Peek() != '"' ) ) {
+ // If we encounter an escaped double quote, skip one of the double quotes.
+ if( ch == '"' && tr.Peek() == '"' )
+ tr.Read();
- return sb.ToString();
+ sb.Append( (char)ch );
+ ch = tr.Read();
}
+
+ return sb.ToString();
}
}
\ No newline at end of file
diff --git a/Tewl/IO/TabularDataParsing/DataValidationError.cs b/Tewl/IO/TabularDataParsing/DataValidationError.cs
new file mode 100644
index 0000000..6e9461a
--- /dev/null
+++ b/Tewl/IO/TabularDataParsing/DataValidationError.cs
@@ -0,0 +1,32 @@
+namespace Tewl.IO.TabularDataParsing;
+
+///
+/// Holds information about a validation error generated as a result of processing a parsed line.
+///
+[ PublicAPI ]
+public class DataValidationError {
+ ///
+ /// An explanation of the place in the original data that caused the error. For example, "Line 32".
+ ///
+ public string ErrorSource { get; set; }
+
+ ///
+ /// True if this is a fatal error.
+ ///
+ public bool IsFatal { get; }
+
+ ///
+ /// The error message.
+ ///
+ public string ErrorMessage { get; }
+
+ ///
+ /// Creates a validation error that occurred when processing the given line number.
+ /// Error source is an explanation of the place in the original data that caused the error. For example, "Line 32".
+ ///
+ public DataValidationError( string errorSource, bool isFatal, string errorMessage ) {
+ ErrorSource = errorSource;
+ ErrorMessage = errorMessage;
+ IsFatal = isFatal;
+ }
+}
\ No newline at end of file
diff --git a/Tewl/IO/TabularDataParsing/ExcelParsedLine.cs b/Tewl/IO/TabularDataParsing/ExcelParsedLine.cs
index 477fd23..1caecfa 100644
--- a/Tewl/IO/TabularDataParsing/ExcelParsedLine.cs
+++ b/Tewl/IO/TabularDataParsing/ExcelParsedLine.cs
@@ -1,26 +1,30 @@
-using System.Collections.Generic;
-using ClosedXML.Excel;
+using ClosedXML.Excel;
-namespace Tewl.IO.TabularDataParsing {
- internal class ExcelParsedLine: ParsedLine {
- private readonly List headerFields;
- private readonly IXLRangeRow row;
+namespace Tewl.IO.TabularDataParsing;
- public ExcelParsedLine( List headerFields, IXLRangeRow row ) {
- this.headerFields = headerFields;
- this.row = row;
- }
+internal class ExcelParsedLine: ParsedLine {
+ private readonly IReadOnlyDictionary columnIndicesByName;
+ private readonly IXLRangeRow row;
- public bool ContainsData => !row.IsEmpty();
+ public ExcelParsedLine( IReadOnlyDictionary columnIndicesByName, IXLRangeRow row ) {
+ this.columnIndicesByName = columnIndicesByName;
+ this.row = row;
+ }
- public int LineNumber => row.RowNumber();
+ bool ParsedLine.ContainsData => !row.IsEmpty();
- // Index is zero-based. The Cell() function on the IXLRangeRow is 1-based.
- public string this[ int index ] => row.Cell( index + 1 ).Value.ToString();
+ int ParsedLine.LineNumber => row.RowNumber();
- // Index is zero-based. The Cell() function on the IXLRangeRow is 1-based.
- public string this[ string columnName ] => row.Cell( headerFields.IndexOf( columnName.ToLower() ) + 1 ).Value.ToString();
+ public string this[ int index ] =>
+ // Index is zero-based. The Cell() function on the IXLRangeRow is 1-based.
+ row.Cell( index + 1 ).Value.ToString();
- public bool ContainsField( string fieldName ) => headerFields.Contains( fieldName );
- }
+
+ string ParsedLine.this[ string columnName ] =>
+ columnIndicesByName.TryGetValue( columnName, out var index )
+ ? this[ index ]
+ : throw new ArgumentException(
+ $"Column “{columnName}” does not exist. The columns are {StringTools.GetEnglishListPhrase( columnIndicesByName.Keys.Select( i => $"“{i}”" ), true )}." );
+
+ bool ParsedLine.ContainsField( string fieldName ) => columnIndicesByName.ContainsKey( fieldName );
}
\ No newline at end of file
diff --git a/Tewl/IO/TabularDataParsing/ExcelParser.cs b/Tewl/IO/TabularDataParsing/ExcelParser.cs
index b767492..2aac3a1 100644
--- a/Tewl/IO/TabularDataParsing/ExcelParser.cs
+++ b/Tewl/IO/TabularDataParsing/ExcelParser.cs
@@ -1,47 +1,48 @@
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using ClosedXML.Excel;
+using ClosedXML.Excel;
using Tewl.InputValidation;
-namespace Tewl.IO.TabularDataParsing {
- internal class ExcelParser: TabularDataParser {
- readonly XLWorkbook workbook;
-
- public ExcelParser( string filePath ) => workbook = new XLWorkbook( filePath );
-
- public ExcelParser( Stream fileStream ) => workbook = new XLWorkbook( fileStream );
-
- ///
- /// For every line (after headerRowsToSkip) in the file with the given path, calls the line handling method you pass.
- /// The validationErrors collection will hold all validation errors encountered during the processing of all lines.
- /// When processing extremely large data sets, accumulating validationErrors in one collection may result in high memory
- /// usage. To avoid
- /// this, use the overload without this collection.
- /// Each line handler method will be given a fresh validator to do its work with.
- ///
- public override void ParseAndProcessAllLines( LineProcessingMethod lineHandler, ICollection validationErrors ) {
- var ws1 = workbook.Worksheets.First();
- var rows = ws1.RangeUsed().RowsUsed().ToList();
- rows = rows.Where( r => !r.IsEmpty() ).ToList();
- var header = rows.First();
- var headerFields = header.Cells().ToList().Select( c => c.Value.ToString().ToLower() ).ToList();
- foreach( var row in rows.Skip( HeaderRows ) ) {
- var parsedLine = new ExcelParsedLine( headerFields, row );
- NonHeaderRows++;
- if( parsedLine.ContainsData ) {
- RowsContainingData++;
- var validator = new Validator();
- lineHandler( validator, parsedLine );
- if( validator.ErrorsOccurred ) {
- if( validationErrors != null ) {
- foreach( var error in validator.Errors )
- validationErrors.Add( new ValidationError( "Line " + parsedLine.LineNumber, error.UnusableValueReturned, error.Message ) );
- }
- }
- else
- RowsWithoutValidationErrors++;
- }
+namespace Tewl.IO.TabularDataParsing;
+
+internal class ExcelParser: TabularDataParser {
+ private readonly XLWorkbook workbook;
+
+ public ExcelParser( string filePath ) => workbook = new XLWorkbook( filePath );
+
+ public ExcelParser( Stream fileStream ) => workbook = new XLWorkbook( fileStream );
+
+ public override void ParseAndProcessAllLines(
+ LineProcessingMethod lineHandler, ICollection validationErrors, bool disableLineProcessingErrorAccumulation = false ) {
+ var ws1 = workbook.Worksheets.First();
+ var rows = ws1.RangeUsed().RowsUsed().ToList();
+ rows = rows.Where( r => !r.IsEmpty() ).ToList();
+ var header = rows.First();
+
+ var columnIndicesByName = header.Cells().Select( ( cell, index ) => ( cell.Value.ToString(), index ) ).ToDictionary( StringComparer.OrdinalIgnoreCase );
+
+ var missingColumns = requiredColumns!.Where( i => !columnIndicesByName.ContainsKey( i ) ).Materialize();
+ if( missingColumns.Any() ) {
+ var columnList = StringTools.GetEnglishListPhrase( missingColumns.Select( i => $"“{i}”" ), true );
+ var singularize = missingColumns.Count == 1;
+ validationErrors.Add(
+ new DataValidationError(
+ "Header row",
+ false,
+ $"The required {( singularize ? "column" : "columns" )} {columnList} {( singularize ? "is" : "are" )} missing." ) );
+ return;
+ }
+
+ foreach( var row in rows.Skip( HeaderRows ) ) {
+ ParsedLine parsedLine = new ExcelParsedLine( columnIndicesByName, row );
+ NonHeaderRows++;
+ if( parsedLine.ContainsData ) {
+ RowsContainingData++;
+ var validator = new Validator();
+ lineHandler( parsedLine, validator );
+ if( !validator.ErrorsOccurred )
+ RowsWithoutValidationErrors++;
+ else if( !disableLineProcessingErrorAccumulation )
+ foreach( var error in validator.Errors )
+ validationErrors.Add( new DataValidationError( "Row " + parsedLine.LineNumber, error.UnusableValueReturned, error.Message ) );
}
}
}
diff --git a/Tewl/IO/TabularDataParsing/FixedWidthParser.cs b/Tewl/IO/TabularDataParsing/FixedWidthParser.cs
index 313524c..6eaa41a 100644
--- a/Tewl/IO/TabularDataParsing/FixedWidthParser.cs
+++ b/Tewl/IO/TabularDataParsing/FixedWidthParser.cs
@@ -1,65 +1,57 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Text;
-using Tewl.Tools;
-
-namespace Tewl.IO.TabularDataParsing {
- internal class FixedWidthParser: TextBasedTabularDataParser {
- private int charactersToSkip;
- private int[] columnWidths; // Maps column indices to column widths
-
- public FixedWidthParser( int[] columnStartPositions ) => buildColumnWidths( columnStartPositions );
-
- ///
- /// Creates a parser designed to parse a file with fixed data column widths. Specify the starting position of each column (using one-based column index).
- /// Characters that take up more than 1 unit of width, such as tabs, can cause problems here.
- ///
- public static TabularDataParser CreateWithFilePath( string filePath, int headerRowsToSkip, params int[] columnStartPositions ) {
- return new FixedWidthParser( columnStartPositions ) { headerRowsToSkip = headerRowsToSkip, fileReader = new FileReader( filePath ) };
+using System.Text;
+
+namespace Tewl.IO.TabularDataParsing;
+
+internal class FixedWidthParser: TextBasedTabularDataParser {
+ ///
+ /// Creates a parser designed to parse a file with fixed data column widths. Specify the starting position of each column (using one-based column index).
+ /// Characters that take up more than 1 unit of width, such as tabs, can cause problems here.
+ ///
+ public static TabularDataParser CreateWithFilePath( string filePath, int headerRowsToSkip, params int[] columnStartPositions ) =>
+ new FixedWidthParser( columnStartPositions ) { headerRowsToSkip = headerRowsToSkip, fileReader = new FileReader( filePath ) };
+
+ private readonly int charactersToSkip;
+ private readonly int[] columnWidths; // Maps column indices to column widths
+
+ public FixedWidthParser( int[] columnStartPositions ) {
+ if( columnStartPositions.Length == 0 )
+ throw new ArgumentException( "Must have at least one column. " );
+
+ charactersToSkip = columnStartPositions[ 0 ] - 1;
+ if( charactersToSkip < 0 )
+ throw new ArgumentException( "The first column position must be positive. Column positions are 1-based." );
+
+ columnWidths = new int[ columnStartPositions.Length ];
+ for( var i = 1; i < columnStartPositions.Length; i++ ) {
+ var width = columnStartPositions[ i ] - columnStartPositions[ i - 1 ];
+ if( width < 1 )
+ throw new ArgumentException( "Column with zero or negative width detected. Column positions must be in ascending order." );
+ columnWidths[ i - 1 ] = width;
}
- private void buildColumnWidths( int[] columnStartPositions ) {
- if( columnStartPositions.Length == 0 )
- throw new ArgumentException( "Must have at least one column. " );
-
- charactersToSkip = columnStartPositions[ 0 ] - 1;
- if( charactersToSkip < 0 )
- throw new ArgumentException( "The first column position must be positive. Column positions are 1-based." );
-
- columnWidths = new int[ columnStartPositions.Length ];
- for( var i = 1; i < columnStartPositions.Length; i++ ) {
- var width = columnStartPositions[ i ] - columnStartPositions[ i - 1 ];
- if( width < 1 )
- throw new ArgumentException( "Column with zero or negative width detected. Column positions must be in ascending order." );
- columnWidths[ i - 1 ] = width;
- }
-
- columnWidths[ columnWidths.Length - 1 ] = int.MaxValue;
- // We don't know how wide the last column is, but we don't need to since we will just read to the end of the line
- }
+ columnWidths[ columnWidths.Length - 1 ] = int.MaxValue;
+ // We don't know how wide the last column is, but we don't need to since we will just read to the end of the line
+ }
- internal override TextBasedParsedLine Parse( string line ) {
- var fields = new List();
- if( !line.IsNullOrWhiteSpace() ) {
- using( TextReader tr = new StringReader( line ) ) {
- for( var i = 0; i < charactersToSkip; i++ )
- tr.Read();
+ protected override IReadOnlyList parseLine( string? line ) {
+ var fields = new List();
+ if( !line.IsNullOrWhiteSpace() )
+ using( TextReader tr = new StringReader( line ) ) {
+ for( var i = 0; i < charactersToSkip; i++ )
+ tr.Read();
- for( var i = 0; i < columnWidths.Length; i++ )
- fields.Add( parseFixedWidthField( tr, columnWidths[ i ] ).Trim() );
- }
+ for( var i = 0; i < columnWidths.Length; i++ )
+ fields.Add( parseFixedWidthField( tr, columnWidths[ i ] ).Trim() );
}
- return new TextBasedParsedLine( fields );
- }
+ return fields;
+ }
- private static string parseFixedWidthField( TextReader tr, int width ) {
- var sb = new StringBuilder();
+ private string parseFixedWidthField( TextReader tr, int width ) {
+ var sb = new StringBuilder();
- for( var i = 0; i < width && tr.Peek() != -1; i++ )
- sb.Append( (char)tr.Read() );
+ for( var i = 0; i < width && tr.Peek() != -1; i++ )
+ sb.Append( (char)tr.Read() );
- return sb.ToString();
- }
+ return sb.ToString();
}
}
\ No newline at end of file
diff --git a/Tewl/IO/TabularDataParsing/ParsedLine.cs b/Tewl/IO/TabularDataParsing/ParsedLine.cs
index e9a1158..f8866f8 100644
--- a/Tewl/IO/TabularDataParsing/ParsedLine.cs
+++ b/Tewl/IO/TabularDataParsing/ParsedLine.cs
@@ -1,34 +1,32 @@
-using JetBrains.Annotations;
+namespace Tewl.IO.TabularDataParsing;
-namespace Tewl.IO.TabularDataParsing {
+///
+/// Represents a line/row that been parsed into fields that are accessible through the indexers of this object.
+///
+[ PublicAPI ]
+public interface ParsedLine {
///
- /// Represents a line/row that been parsed into fields that are accessible through the indexers of this object.
+ /// Returns true if any field on this line has a non-empty, non-whitespace value.
///
- [PublicAPI]
- public interface ParsedLine {
- ///
- /// Returns true if any field on this line has a non-empty, non-whitespace value.
- ///
- bool ContainsData { get; }
+ bool ContainsData { get; }
- ///
- /// Returns the line number from the source document that this parsed line was created from.
- ///
- int LineNumber { get; }
+ ///
+ /// Returns the line number from the source document that this parsed line was created from.
+ ///
+ int LineNumber { get; }
- ///
- /// Returns the value of the field with the given zero-based column index.
- ///
- string this[ int index ] {get; }
+ ///
+ /// Returns the value of the field with the given zero-based column index.
+ ///
+ string this[ int index ] { get; }
- ///
- /// Returns the value of the field with the given column name.
- ///
- string this[ string columnName ] {get; }
+ ///
+ /// Returns the value of the field with the given column name.
+ ///
+ string this[ string columnName ] { get; }
- ///
- /// Returns true if the line contains the given field.
- ///
- bool ContainsField( string fieldName );
- }
+ ///
+ /// Returns true if the line contains the given field.
+ ///
+ bool ContainsField( string fieldName );
}
\ No newline at end of file
diff --git a/Tewl/IO/TabularDataParsing/TabularDataParser.cs b/Tewl/IO/TabularDataParsing/TabularDataParser.cs
index 0c7a4d7..7b87577 100644
--- a/Tewl/IO/TabularDataParsing/TabularDataParser.cs
+++ b/Tewl/IO/TabularDataParsing/TabularDataParser.cs
@@ -1,123 +1,127 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using JetBrains.Annotations;
-using Tewl.InputValidation;
-
-namespace Tewl.IO.TabularDataParsing {
- ///
- /// Use this to process several lines of any type of tabular data, such as CSVs, fixed-width data files, or Excel files.
- ///
- [ PublicAPI ]
- public class TabularDataParser {
- ///
- /// Method that knows how to process a line from a particular file. The validator is new for each row and has no errors,
- /// initially.
- ///
- public delegate void LineProcessingMethod( Validator validator, ParsedLine line );
-
- ///
- /// Header rows to skip, shared by all parsers.
- ///
- protected int headerRowsToSkip;
-
- ///
- /// True if there is a header row. Also implies header rows to skip is 1.
- ///
- protected bool hasHeaderRow;
-
- ///
- /// The number of rows in the file, not including the header rows that were skipped with headerRowsToSkip or hasHeaderRows
- /// = true.
- /// This is the number of rows in the file that were parsed.
- /// This properly only has meaning after ParseAndProcessAllLines has been called.
- ///
- public int NonHeaderRows { get; protected set; }
-
- ///
- /// The number of header rows in the file. This is equal to 1 if hasHeaderRow was passed as true, or equal to
- /// headerRowsToSkip otherwise.
- ///
- public int HeaderRows => hasHeaderRow ? 1 : headerRowsToSkip;
-
- ///
- /// The total number of rows in the file, including any header rows. This properly only has meaning after
- /// ParseAndProcessAllLines has been called.
- ///
- public int TotalRows => HeaderRows + NonHeaderRows;
-
- ///
- /// The number of rows in the file with at least one non-blank field.
- /// This properly only has meaning after ParseAndProcessAllLines has been called.
- /// This is the number of rows in that file that were processed (the lineHandler callback was performed).
- ///
- public int RowsContainingData { get; protected set; }
-
- ///
- /// The number of rows in the file that were processed without encountering any validation errors.
- /// This properly only has meaning after ParseAndProcessAllLines has been called.
- ///
- public int RowsWithoutValidationErrors { get; protected set; }
-
- ///
- /// The number of rows in the file that did encounter validation errors when processed.
- /// This properly only has meaning after ParseAndProcessAllLines has been called.
- ///
- public int RowsWithValidationErrors => RowsContainingData - RowsWithoutValidationErrors;
-
- ///
- /// Constructs a tabular data parser. Empty.
- ///
- protected TabularDataParser() { }
-
- ///
- /// Creates a parser designed to parse a file with fixed data column widths. Specify the starting position of each column
- /// (using one-based column index).
- /// Characters that take up more than 1 unit of width, such as tabs, can cause problems here.
- ///
- public static TabularDataParser CreateForFixedWidthFile( string filePath, int headerRowsToSkip, params int[] columnStartPositions ) =>
- FixedWidthParser.CreateWithFilePath( filePath, headerRowsToSkip, columnStartPositions );
-
- ///
- /// Creates a parser designed to parse a CSV file. Passing true for hasHeaderRow will result in the first row being used
- /// to map
- /// header names to column indices. This will allow you to access fields using the header name in addition to the column
- /// index.
- ///
- public static TabularDataParser CreateForCsvFile( string filePath, bool hasHeaderRow ) => CsvLineParser.CreateWithFilePath( filePath, hasHeaderRow );
-
- ///
- /// Creates a parser designed to parse a CSV file. Passing true for hasHeaderRow will result in the first row being used
- /// to map
- /// header names to column indices. This will allow you to access fields using the header name in addition to the column
- /// index.
- ///
- public static TabularDataParser CreateForCsvFile( Stream stream, bool hasHeaderRow ) => CsvLineParser.CreateWithStream( stream, hasHeaderRow );
-
- ///
- /// Assumes header row. Fields will always be accessible by name.
- ///
- public static TabularDataParser CreateForExcelFile( Stream stream ) => new ExcelParser( stream ) { hasHeaderRow = true };
-
- ///
- /// Assumes header row. Fields will always be accessible by name.
- ///
- public static TabularDataParser CreateForExcelFile( string filePath ) => new ExcelParser( filePath ) { hasHeaderRow = true };
-
- ///
- /// For every line (after headerRowsToSkip) in the file with the given path, calls the line handling method you pass.
- /// Each line handler method will be given a fresh validator to do its work with.
- ///
- public void ParseAndProcessAllLines( LineProcessingMethod lineHandler ) {
- ParseAndProcessAllLines( lineHandler, null );
- }
-
- ///
- /// For every line (after headerRowsToSkip) in the file with the given path, calls the line handling method you pass.
- /// Each line handler method will be given a fresh validator to do its work with.
- ///
- public virtual void ParseAndProcessAllLines( LineProcessingMethod lineHandler, ICollection validationErrors ) {
- throw new NotImplementedException( "Each parser must have a specific implementation of parse and process all lines." );
- }
- }
+using Tewl.InputValidation;
+
+namespace Tewl.IO.TabularDataParsing;
+
+///
+/// Use this to process several lines of any type of tabular data, such as CSVs, fixed-width data files, or Excel files.
+///
+[ PublicAPI ]
+public abstract class TabularDataParser {
+ ///
+ /// Method that knows how to process a line from a particular file. The validator is new for each row and has no errors,
+ /// initially.
+ ///
+ public delegate void LineProcessingMethod( ParsedLine line, Validator validator );
+
+ ///
+ /// Header rows to skip, shared by all parsers.
+ ///
+ protected int headerRowsToSkip;
+
+ ///
+ /// Has a value if there is a header row. Also implies header rows to skip is 1.
+ ///
+ protected IReadOnlyCollection? requiredColumns;
+
+ ///
+ /// The number of rows in the file, not including the header rows that were skipped with headerRowsToSkip or hasHeaderRows
+ /// = true.
+ /// This is the number of rows in the file that were parsed.
+ /// This properly only has meaning after ParseAndProcessAllLines has been called.
+ ///
+ public int NonHeaderRows { get; protected set; }
+
+ ///
+ /// The number of header rows in the file. This is equal to 1 if hasHeaderRow was passed as true, or equal to
+ /// headerRowsToSkip otherwise.
+ ///
+ public int HeaderRows => requiredColumns is not null ? 1 : headerRowsToSkip;
+
+ ///
+ /// The total number of rows in the file, including any header rows. This properly only has meaning after
+ /// ParseAndProcessAllLines has been called.
+ ///
+ public int TotalRows => HeaderRows + NonHeaderRows;
+
+ ///
+ /// The number of rows in the file with at least one non-blank field.
+ /// This properly only has meaning after ParseAndProcessAllLines has been called.
+ /// This is the number of rows in that file that were processed (the lineHandler callback was performed).
+ ///
+ public int RowsContainingData { get; protected set; }
+
+ ///
+ /// The number of rows in the file that were processed without encountering any validation errors.
+ /// This properly only has meaning after ParseAndProcessAllLines has been called.
+ ///
+ public int RowsWithoutValidationErrors { get; protected set; }
+
+ ///
+ /// The number of rows in the file that did encounter validation errors when processed.
+ /// This properly only has meaning after ParseAndProcessAllLines has been called.
+ ///
+ public int RowsWithValidationErrors => RowsContainingData - RowsWithoutValidationErrors;
+
+ ///
+ /// Constructs a tabular data parser. Empty.
+ ///
+ protected TabularDataParser() {}
+
+ ///
+ /// Creates a parser designed to parse a file with fixed data column widths. Specify the starting position of each column
+ /// (using one-based column index).
+ /// Characters that take up more than 1 unit of width, such as tabs, can cause problems here.
+ ///
+ public static TabularDataParser CreateForFixedWidthFile( string filePath, int headerRowsToSkip, params int[] columnStartPositions ) =>
+ FixedWidthParser.CreateWithFilePath( filePath, headerRowsToSkip, columnStartPositions );
+
+ ///
+ /// Creates a parser designed to parse a CSV file.
+ ///
+ ///
+ /// If the file has a header row, you must pass a collection (empty or not) for this parameter, which will allow you to access
+ /// fields using the column name in addition to the index. If the collection is nonempty and any of the specified columns are missing, ParseAndProcessAllLines
+ /// will generate validation errors and return without processing any lines. Pass null for this parameter if the file does not have a header row.
+ public static TabularDataParser CreateForCsvFile( string filePath, IReadOnlyCollection? requiredColumns ) =>
+ new CsvLineParser( filePath ) { requiredColumns = requiredColumns };
+
+ ///
+ /// Creates a parser designed to parse a CSV file.
+ ///
+ ///
+ /// If the file has a header row, you must pass a collection (empty or not) for this parameter, which will allow you to access
+ /// fields using the column name in addition to the index. If the collection is nonempty and any of the specified columns are missing, ParseAndProcessAllLines
+ /// will generate validation errors and return without processing any lines. Pass null for this parameter if the file does not have a header row.
+ public static TabularDataParser CreateForCsvFile( Stream stream, IReadOnlyCollection? requiredColumns ) =>
+ new CsvLineParser( stream ) { requiredColumns = requiredColumns };
+
+ ///
+ /// Assumes header row. Fields will always be accessible by name.
+ ///
+ ///
+ /// If any of the columns specified in this collection are missing, ParseAndProcessAllLines will generate validation errors and
+ /// return without processing any lines.
+ public static TabularDataParser CreateForExcelFile( Stream stream, IReadOnlyCollection requiredColumns ) =>
+ new ExcelParser( stream ) { requiredColumns = requiredColumns };
+
+ ///
+ /// Assumes header row. Fields will always be accessible by name.
+ ///
+ ///
+ /// If any of the columns specified in this collection are missing, ParseAndProcessAllLines will generate validation errors and
+ /// return without processing any lines.
+ public static TabularDataParser CreateForExcelFile( string filePath, IReadOnlyCollection requiredColumns ) =>
+ new ExcelParser( filePath ) { requiredColumns = requiredColumns };
+
+ ///
+ /// For every line (after headerRowsToSkip) in the file with the given path, calls the line handling method you pass.
+ /// The validationErrors collection will hold all validation errors encountered during the processing of all lines.
+ /// Each line handler method will be given a fresh validator to do its work with.
+ ///
+ ///
+ ///
+ /// Pass true to only use the error collection for missing columns. This is useful when processing
+ /// extremely large data sets, since accumulating all line-processing errors in one collection may result in high memory usage.
+ public abstract void ParseAndProcessAllLines(
+ LineProcessingMethod lineHandler, ICollection validationErrors, bool disableLineProcessingErrorAccumulation = false );
}
\ No newline at end of file
diff --git a/Tewl/IO/TabularDataParsing/TextBasedParsedLine.cs b/Tewl/IO/TabularDataParsing/TextBasedParsedLine.cs
index 50233bf..d5afb85 100644
--- a/Tewl/IO/TabularDataParsing/TextBasedParsedLine.cs
+++ b/Tewl/IO/TabularDataParsing/TextBasedParsedLine.cs
@@ -1,97 +1,68 @@
-using System;
-using System.Collections.Generic;
-using JetBrains.Annotations;
-using Tewl.Tools;
+namespace Tewl.IO.TabularDataParsing;
+
+///
+/// Represents a line of text from a CSV file that has been parsed into fields that
+/// are accessible through the indexers of this object.
+///
+[ PublicAPI ]
+internal class TextBasedParsedLine: ParsedLine {
+ private readonly IReadOnlyDictionary? columnIndicesByName;
+ private readonly int lineNumber;
+ private readonly IReadOnlyList fields;
-namespace Tewl.IO.TabularDataParsing {
///
- /// Represents a line of text from a CSV file that has been parsed into fields that
- /// are accessible through the indexers of this object.
+ /// Returns true if any field on this line has a non-empty, non-whitespace value.
///
- [PublicAPI]
- internal class TextBasedParsedLine : ParsedLine {
- private IDictionary columnHeadersToIndexes;
- private int? lineNumber;
-
- ///
- /// Returns true if any field on this line has a non-empty, non-whitespace value.
- ///
- public bool ContainsData { get; }
-
- ///
- /// Returns the line number from the source document that this parsed line was created from.
- ///
- public int LineNumber {
- get {
- if( lineNumber.HasValue )
- return lineNumber.Value;
- throw new ApplicationException( "Line number has not been initialized and has no meaning." );
- }
- internal set => lineNumber = value;
- }
+ public bool ContainsData { get; }
- internal IDictionary ColumnHeadersToIndexes { set => columnHeadersToIndexes = value ?? new Dictionary(); }
+ int ParsedLine.LineNumber => lineNumber;
- internal TextBasedParsedLine( List fields ) {
- Fields = fields;
- ContainsData = false;
- foreach( var field in fields ) {
- if( !field.IsNullOrWhiteSpace() ) {
- ContainsData = true;
- break;
- }
+ internal TextBasedParsedLine( IReadOnlyDictionary? columnIndicesByName, int lineNumber, IReadOnlyList fields ) {
+ this.columnIndicesByName = columnIndicesByName;
+ this.lineNumber = lineNumber;
+ this.fields = fields;
+ ContainsData = false;
+ foreach( var field in fields )
+ if( !field.IsNullOrWhiteSpace() ) {
+ ContainsData = true;
+ break;
}
- }
+ }
- internal List Fields { get; }
+ public string this[ int index ] {
+ get {
+ // Gracefully return empty string when over-indexed. This prevents problems with files that have no value in the last column.
+ if( index >= fields.Count )
+ return "";
- ///
- /// Returns the value of the field with the given column index.
- /// Gracefully return empty string when over-indexed. This prevents problems with files that have no value in the last column.
- ///
- public string this[ int index ] {
- get {
- if( index >= Fields.Count )
- return "";
- return Fields[ index ];
- }
+ return fields[ index ];
}
+ }
- ///
- /// Returns the value of the field with the given column name.
- ///
- public string this[ string columnName ] {
- get {
- if( columnHeadersToIndexes.Count == 0 )
- throw new InvalidOperationException( "The CSV parser returning this CsvLine was not created with a headerLine with which to populate column names." );
-
- if( columnName == null )
- throw new ArgumentException( "Column name cannot be null." );
+ string ParsedLine.this[ string columnName ] {
+ get {
+ if( columnIndicesByName is null )
+ throw new InvalidOperationException( "The CSV parser returning this CsvLine was not created with a headerLine with which to populate column names." );
- if( !columnHeadersToIndexes.TryGetValue( columnName.ToLower(), out var index ) ) {
- var keys = "";
- foreach( var key in columnHeadersToIndexes.Keys )
- keys += key + ", ";
- throw new ArgumentException( "Column '" + columnName + "' does not exist. The columns are: " + keys );
- }
+ if( columnName == null )
+ throw new ArgumentException( "Column name cannot be null." );
- return this[ index ];
- }
+ return columnIndicesByName.TryGetValue( columnName, out var index )
+ ? this[ index ]
+ : throw new ArgumentException(
+ $"Column “{columnName}” does not exist. The columns are {StringTools.GetEnglishListPhrase( columnIndicesByName.Keys.Select( i => $"“{i}”" ), true )}." );
}
+ }
- ///
- /// Returns a comma-delimited list of fields.
- ///
- public override string ToString() {
- var text = "";
- foreach( var field in Fields )
- text += ", " + field;
- return text.TruncateStart( text.Length - 2 );
- }
+ bool ParsedLine.ContainsField( string fieldName ) => columnIndicesByName is not null && columnIndicesByName.ContainsKey( fieldName );
- ///
- /// Returns true if the line contains the given field.
- ///
- public bool ContainsField( string fieldName ) => columnHeadersToIndexes.Keys.Contains( fieldName.ToLower() );
+ ///
+ /// Returns a comma-delimited list of fields.
+ ///
+ public override string ToString() {
+ var text = "";
+ foreach( var field in fields )
+ text += ", " + field;
+ return text.TruncateStart( text.Length - 2 );
}
}
\ No newline at end of file
diff --git a/Tewl/IO/TabularDataParsing/TextBasedTabularDataParser.cs b/Tewl/IO/TabularDataParsing/TextBasedTabularDataParser.cs
index 2d5f242..eb415d5 100644
--- a/Tewl/IO/TabularDataParsing/TextBasedTabularDataParser.cs
+++ b/Tewl/IO/TabularDataParsing/TextBasedTabularDataParser.cs
@@ -1,71 +1,59 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using Tewl.InputValidation;
-
-namespace Tewl.IO.TabularDataParsing {
- ///
- /// Data parser for text-based file formats of tabular data, such as CSV and fixed-width.
- ///
- internal class TextBasedTabularDataParser: TabularDataParser {
- internal virtual TextBasedParsedLine Parse( string line ) => throw new NotImplementedException( "Parsers must have a specific implementation of Parse." );
-
- protected FileReader fileReader;
-
- ///
- /// For every line (after headerRowsToSkip) in the file with the given path, calls the line handling method you pass.
- /// The validationErrors collection will hold all validation errors encountered during the processing of all lines.
- /// When processing extremely large data sets, accumulating validationErrors in one collection may result in high memory
- /// usage. To avoid
- /// this, use the overload without this collection.
- /// Each line handler method will be given a fresh validator to do its work with.
- ///
- public override void ParseAndProcessAllLines( LineProcessingMethod lineHandler, ICollection validationErrors ) {
- fileReader.ExecuteInStreamReader(
- delegate( StreamReader reader ) {
- IDictionary columnHeadersToIndexes = null;
-
- // This skips the header row and creates a name to index map out of it.
- if( hasHeaderRow )
- columnHeadersToIndexes = buildColumnHeadersToIndexesDictionary( reader.ReadLine() );
-
- // GMS NOTE: It may be time to just remove this feature. It seems like only FixedWidth uses it (can be created with a non-zero value) and there are probably better ways to do it for whatever program needed that.
- // This is a bit misleading. HeaderRowsToSkip will be 0 even when we've skipped the header row due to hasHeadRow, above.
- for( var i = 0; i < headerRowsToSkip; i++ )
- reader.ReadLine();
-
- string line;
- for( var lineNumber = HeaderRows + 1; ( line = reader.ReadLine() ) != null; lineNumber++ ) {
- NonHeaderRows++;
- var parsedLine = Parse( line );
- if( parsedLine.ContainsData ) {
- RowsContainingData++;
- parsedLine.LineNumber = lineNumber;
- parsedLine.ColumnHeadersToIndexes = columnHeadersToIndexes;
- var validator = new Validator();
- lineHandler( validator, parsedLine );
- if( validator.ErrorsOccurred ) {
- if( validationErrors != null ) {
- foreach( var error in validator.Errors )
- validationErrors.Add( new ValidationError( "Line " + lineNumber, error.UnusableValueReturned, error.Message ) );
- }
- }
- else
- RowsWithoutValidationErrors++;
- }
+using Tewl.InputValidation;
+
+namespace Tewl.IO.TabularDataParsing;
+
+///
+/// Data parser for text-based file formats of tabular data, such as CSV and fixed-width.
+///
+internal abstract class TextBasedTabularDataParser: TabularDataParser {
+ protected FileReader? fileReader;
+
+ protected abstract IReadOnlyList parseLine( string? line );
+
+ public override void ParseAndProcessAllLines(
+ LineProcessingMethod lineHandler, ICollection validationErrors, bool disableLineProcessingErrorAccumulation = false ) {
+ fileReader!.ExecuteInStreamReader(
+ reader => {
+ IReadOnlyDictionary? columnIndicesByName = null;
+ if( requiredColumns is not null ) {
+ // This skips the header row and creates a name to index map out of it.
+ columnIndicesByName = parseLine( reader.ReadLine() ).Select( ( name, index ) => ( name, index ) ).ToDictionary( StringComparer.OrdinalIgnoreCase );
+
+ var missingColumns = requiredColumns.Where( i => !columnIndicesByName.ContainsKey( i ) ).Materialize();
+ if( missingColumns.Any() ) {
+ var columnList = StringTools.GetEnglishListPhrase( missingColumns.Select( i => $"“{i}”" ), true );
+ var singularize = missingColumns.Count == 1;
+ validationErrors.Add(
+ new DataValidationError(
+ "Header line",
+ false,
+ $"The required {( singularize ? "column" : "columns" )} {columnList} {( singularize ? "is" : "are" )} missing." +
+ ( columnIndicesByName.Count == 1
+ ? " Also, only a single column was detected, so please confirm that fields are separated by commas and not semicolons."
+ : "" ) ) );
+ return;
}
- } );
- }
-
- private IDictionary buildColumnHeadersToIndexesDictionary( string headerLine ) {
- var columnHeadersToIndexes = new Dictionary();
- var index = 0;
- foreach( var columnHeader in Parse( headerLine ).Fields ) {
- columnHeadersToIndexes[ columnHeader.ToLower() ] = index;
- index++;
- }
-
- return columnHeadersToIndexes;
- }
+ }
+
+ // GMS NOTE: It may be time to just remove this feature. It seems like only FixedWidth uses it (can be created with a non-zero value) and there are probably better ways to do it for whatever program needed that.
+ // This is a bit misleading. HeaderRowsToSkip will be 0 even when we've skipped the header row due to hasHeadRow, above.
+ for( var i = 0; i < headerRowsToSkip; i++ )
+ reader.ReadLine();
+
+ for( var lineNumber = HeaderRows + 1; reader.ReadLine() is {} line; lineNumber++ ) {
+ NonHeaderRows++;
+ var parsedLine = new TextBasedParsedLine( columnIndicesByName, lineNumber, parseLine( line ) );
+ if( parsedLine.ContainsData ) {
+ RowsContainingData++;
+ var validator = new Validator();
+ lineHandler( parsedLine, validator );
+ if( !validator.ErrorsOccurred )
+ RowsWithoutValidationErrors++;
+ else if( !disableLineProcessingErrorAccumulation )
+ foreach( var error in validator.Errors )
+ validationErrors.Add( new DataValidationError( "Line " + lineNumber, error.UnusableValueReturned, error.Message ) );
+ }
+ }
+ } );
}
}
\ No newline at end of file
diff --git a/Tewl/IO/ValidationError.cs b/Tewl/IO/ValidationError.cs
deleted file mode 100644
index 81e2b8a..0000000
--- a/Tewl/IO/ValidationError.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using JetBrains.Annotations;
-
-namespace Tewl.IO {
- ///
- /// Holds information about a validation error generated as a result of processing a parsed line.
- ///
- [ PublicAPI ]
- public class ValidationError {
- ///
- /// Creates a validation error that occurred when processing the given line number.
- /// Error source is an explanation of the place in the original data that caused the error. For example, "Line 32".
- ///
- public ValidationError( string errorSource, bool isFatal, string errorMessage ) {
- ErrorSource = errorSource;
- ErrorMessage = errorMessage;
- IsFatal = isFatal;
- }
-
- ///
- /// An explanation of the place in the original data that caused the error. For example, "Line 32".
- ///
- public string ErrorSource { get; set; }
-
- ///
- /// True if this is a fatal error.
- ///
- public bool IsFatal { get; }
-
- ///
- /// The error message.
- ///
- public string ErrorMessage { get; }
- }
-}
\ No newline at end of file
diff --git a/Tewl/InputValidation/Error.cs b/Tewl/InputValidation/Error.cs
deleted file mode 100644
index 0b4364f..0000000
--- a/Tewl/InputValidation/Error.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using JetBrains.Annotations;
-
-namespace Tewl.InputValidation {
- ///
- /// Represents a validation error.
- ///
- [ PublicAPI ]
- public class Error {
- internal Error( string message, bool unusableValueReturned ) {
- Message = message;
- UnusableValueReturned = unusableValueReturned;
- }
-
- ///
- /// The error message.
- ///
- public string Message { get; }
-
- ///
- /// Returns true if the error resulted in an unusable value being returned.
- ///
- public bool UnusableValueReturned { get; }
- }
-}
\ No newline at end of file
diff --git a/Tewl/InputValidation/ErrorCondition.cs b/Tewl/InputValidation/ErrorCondition.cs
deleted file mode 100644
index b6f18fb..0000000
--- a/Tewl/InputValidation/ErrorCondition.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using JetBrains.Annotations;
-
-namespace Tewl.InputValidation {
- ///
- /// The list of possible error types.
- ///
- [ PublicAPI ]
- public enum ErrorCondition {
- ///
- /// NoError
- ///
- NoError,
-
- ///
- /// Empty
- ///
- Empty,
-
- ///
- /// Invalid
- ///
- Invalid,
-
- ///
- /// TooLong
- ///
- TooLong,
-
- ///
- /// TooShort
- ///
- TooShort,
-
- ///
- /// TooSmall
- ///
- TooSmall,
-
- ///
- /// TooLarge
- ///
- TooLarge,
-
- ///
- /// TooEarly
- ///
- TooEarly,
-
- ///
- /// TooLate
- ///
- TooLate
- }
-}
\ No newline at end of file
diff --git a/Tewl/InputValidation/PhoneNumber.cs b/Tewl/InputValidation/PhoneNumber.cs
index 23a9a7a..92b3b80 100644
--- a/Tewl/InputValidation/PhoneNumber.cs
+++ b/Tewl/InputValidation/PhoneNumber.cs
@@ -1,14 +1,11 @@
-using System;
-using JetBrains.Annotations;
-
-namespace Tewl.InputValidation {
+namespace Tewl.InputValidation {
///
/// Represents a phone number consisting of area code, number, and optional extension.
/// Also supports international numbers. If IsInternational is true, area code, number, and extension are irrelevant.
///
[ PublicAPI ]
public class PhoneNumber {
- private PhoneNumber() { }
+ private PhoneNumber() {}
///
/// Creates a phone number object from the individual parts. Strings are trimmed in case they came from SQL Server char
@@ -59,15 +56,7 @@ public static PhoneNumber CreateFromParts( string areaCode, string number, strin
public static PhoneNumber CreateFromStandardPhoneString( string phoneString ) {
var v = new Validator();
// NOTE: I don't like how this method is called, which calls a method in validator, which then calls one of the static constructors back here (but not this one! otherwise you are screwed)
- var p = v.GetPhoneNumberAsObject(
- new ValidationErrorHandler( "" ),
- phoneString,
- true,
- true,
- false,
- null );
- // pass "" for the error message subject because we should never get errors
- if( v.ErrorsOccurred )
+ if( v.GetPhoneNumberAsObject( null, phoneString, true, true, false, null ).Error( out var p ) is not null )
throw new ApplicationException( "Unparsable standard phone number string encountered." );
return p;
}
diff --git a/Tewl/InputValidation/Validation Methods/Boolean.cs b/Tewl/InputValidation/Validation Methods/Boolean.cs
new file mode 100644
index 0000000..3152897
--- /dev/null
+++ b/Tewl/InputValidation/Validation Methods/Boolean.cs
@@ -0,0 +1,31 @@
+namespace Tewl.InputValidation;
+
+partial class ValidatorExtensions {
+ ///
+ /// Accepts either true/false (case-sensitive) or 1/0.
+ /// Returns the validated boolean type from the given string and validation package.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ ///
+ public static ValidationResult GetBoolean( this Validator validator, ValidationErrorHandler? errorHandler, string input ) =>
+ validator.ExecuteValidation( errorHandler, input, false, validateBoolean );
+
+ ///
+ /// Accepts either true/false (case-sensitive) or 1/0.
+ /// Returns the validated boolean type from the given string and validation package.
+ /// If allowEmpty is true and the given string is empty, null will be returned.
+ ///
+ public static ValidationResult GetNullableBoolean( this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateBoolean( value => valueSetter( value ), trimmedInput ) );
+
+ private static ValidationError? validateBoolean( Action valueSetter, string trimmedInput ) {
+ if( trimmedInput != "1" && trimmedInput != "0" && trimmedInput != true.ToString() && trimmedInput != false.ToString() )
+ return ValidationError.Invalid();
+
+ valueSetter( trimmedInput == "1" || trimmedInput == true.ToString() );
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/Tewl/InputValidation/Validation Methods/DateAndTime.cs b/Tewl/InputValidation/Validation Methods/DateAndTime.cs
new file mode 100644
index 0000000..3197976
--- /dev/null
+++ b/Tewl/InputValidation/Validation Methods/DateAndTime.cs
@@ -0,0 +1,216 @@
+using System.Globalization;
+
+namespace Tewl.InputValidation;
+
+partial class ValidatorExtensions {
+ private static readonly DateTime sqlSmallDateTimeMinValue = new( 1900, 1, 1 );
+ private static readonly DateTime sqlSmallDateTimeMaxValue = new( 2079, 6, 6 );
+
+ ///
+ /// Returns the validated DateTime type from the given string and validation package.
+ /// It is restricted to the Sql SmallDateTime range of 1/1/1900 up to 6/6/2079.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ ///
+ public static ValidationResult GetSqlSmallDateTime( this Validator validator, ValidationErrorHandler? errorHandler, string input ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => validateDateTime( valueSetter, trimmedInput, null, sqlSmallDateTimeMinValue, sqlSmallDateTimeMaxValue ) );
+
+ ///
+ /// Returns the validated DateTime type from the given string and validation package.
+ /// It is restricted to the Sql SmallDateTime range of 1/1/1900 up to 6/6/2079.
+ /// If allowEmpty is true and the given string is empty, null will be returned.
+ ///
+ public static ValidationResult GetNullableSqlSmallDateTime(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateDateTime(
+ value => valueSetter( value ),
+ trimmedInput,
+ null,
+ sqlSmallDateTimeMinValue,
+ sqlSmallDateTimeMaxValue ) );
+
+ ///
+ /// Returns the validated DateTime type from the given date part strings and validation package.
+ /// It is restricted to the Sql SmallDateTime range of 1/1/1900 up to 6/6/2079.
+ /// Passing an empty string or null for each date part will result in ErrorCondition.Empty.
+ /// Passing an empty string or null for only some date parts will result in ErrorCondition.Invalid.
+ ///
+ public static ValidationResult GetSqlSmallDateTimeFromParts(
+ this Validator validator, ValidationErrorHandler? errorHandler, string month, string day, string year ) =>
+ validator.GetSqlSmallDateTime( errorHandler, makeDateFromParts( month, day, year ) );
+
+ ///
+ /// Returns the validated DateTime type from the given date part strings and validation package.
+ /// It is restricted to the Sql SmallDateTime range of 1/1/1900 up to 6/6/2079.
+ /// If allowEmpty is true and each date part string is empty, null will be returned.
+ /// Passing an empty string or null for only some date parts will result in ErrorCondition.Invalid.
+ ///
+ public static ValidationResult GetNullableSqlSmallDateTimeFromParts(
+ this Validator validator, ValidationErrorHandler? errorHandler, string month, string day, string year, bool allowEmpty ) =>
+ validator.GetNullableSqlSmallDateTime( errorHandler, makeDateFromParts( month, day, year ), allowEmpty );
+
+ private static string makeDateFromParts( string month, string day, string year ) {
+ var date = month + '/' + day + '/' + year;
+ if( date == "//" )
+ date = "";
+ return date;
+ }
+
+ ///
+ /// Returns the validated DateTime type from a date string and an exact match pattern.
+ /// Pattern specifies the date format, such as "MM/dd/yyyy".
+ ///
+ public static ValidationResult GetSqlSmallDateTimeExact(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, string pattern ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => validateSqlSmallDateTimeExact( valueSetter, trimmedInput, pattern ) );
+
+ ///
+ /// Returns the validated DateTime type from a date string and an exact match pattern.
+ /// Pattern specifies the date format, such as "MM/dd/yyyy".
+ ///
+ public static ValidationResult GetNullableSqlSmallDateTimeExact(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, string pattern, bool allowEmpty ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateSqlSmallDateTimeExact( value => valueSetter( value ), trimmedInput, pattern ) );
+
+ private static ValidationError? validateSqlSmallDateTimeExact( Action valueSetter, string trimmedInput, string pattern ) {
+ if( !DateTime.TryParseExact( trimmedInput, pattern, Cultures.EnglishUnitedStates, DateTimeStyles.None, out var date ) )
+ return ValidationError.Invalid();
+ if( validateNativeDateTime( date, false, sqlSmallDateTimeMinValue, sqlSmallDateTimeMaxValue ) is {} error )
+ return error;
+
+ valueSetter( date );
+ return null;
+ }
+
+ private static ValidationError? validateDateTime( Action valueSetter, string trimmedInput, string[]? formats, DateTime min, DateTime max ) {
+ DateTime date;
+ try {
+ date = formats is not null
+ ? DateTime.ParseExact( trimmedInput, formats, null, DateTimeStyles.None )
+ : DateTime.Parse( trimmedInput, Cultures.EnglishUnitedStates );
+ if( validateNativeDateTime( date, false, min, max ) is {} error )
+ return error;
+ }
+ catch( FormatException ) {
+ return ValidationError.Invalid();
+ }
+ catch( ArgumentOutOfRangeException ) {
+ // Undocumented exception that there are reports of being thrown
+ return ValidationError.Invalid();
+ }
+ catch( ArgumentNullException ) {
+ return ValidationError.Empty();
+ }
+
+ valueSetter( date );
+ return null;
+ }
+
+ private static ValidationError? validateNativeDateTime( DateTime? date, bool allowEmpty, DateTime minDate, DateTime maxDate ) {
+ if( date is null && !allowEmpty )
+ return ValidationError.Empty();
+ if( date.HasValue ) {
+ var minMaxMessage = " It must be between " + minDate.ToDayMonthYearString( false ) + " and " + maxDate.ToDayMonthYearString( false ) + ".";
+ if( date < minDate )
+ return ValidationError.Custom( ValidationErrorType.TooEarly, "The {0} is too early." + minMaxMessage );
+ if( date >= maxDate )
+ return ValidationError.Custom( ValidationErrorType.TooLate, "The {0} is too late." + minMaxMessage );
+ }
+ return null;
+ }
+
+ ///
+ /// Validates the date using given allowEmpty, min, and max constraints.
+ ///
+ public static ValidationResult GetNullableDateTime(
+ this Validator validator, ValidationErrorHandler? handler, string input, string[]? formats, bool allowEmpty, DateTime minDate, DateTime maxDate ) =>
+ validator.ExecuteValidation(
+ handler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateDateTime( value => valueSetter( value ), trimmedInput, formats, minDate, maxDate ) );
+
+ ///
+ /// Validates the date using given min and max constraints.
+ ///
+ public static ValidationResult GetDateTime(
+ this Validator validator, ValidationErrorHandler? handler, string input, string[]? formats, DateTime minDate, DateTime maxDate ) =>
+ validator.ExecuteValidation(
+ handler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => validateDateTime( valueSetter, trimmedInput, formats, minDate, maxDate ) );
+
+ ///
+ /// Validates the given time span.
+ ///
+ public static ValidationResult
+ GetNullableTimeSpan( this Validator validator, ValidationErrorHandler? handler, TimeSpan? input, bool allowEmpty ) =>
+ validator.ExecuteValidation(
+ handler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => {
+ valueSetter( trimmedInput!.Value );
+ return null;
+ } );
+
+ ///
+ /// Validates the given time span.
+ ///
+ public static ValidationResult GetTimeSpan( this Validator validator, ValidationErrorHandler? handler, TimeSpan? input ) =>
+ validator.ExecuteValidation(
+ handler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => {
+ valueSetter( trimmedInput!.Value );
+ return null;
+ } );
+
+ ///
+ /// Validates the given time span.
+ ///
+ public static ValidationResult GetNullableTimeOfDayTimeSpan(
+ this Validator validator, ValidationErrorHandler? handler, string input, string[]? formats, bool allowEmpty ) =>
+ validator.ExecuteValidation(
+ handler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateDateTime(
+ value => valueSetter( value.TimeOfDay ),
+ trimmedInput,
+ formats,
+ DateTime.MinValue,
+ DateTime.MaxValue ) );
+
+ ///
+ /// Validates the given time span.
+ ///
+ public static ValidationResult GetTimeOfDayTimeSpan( this Validator validator, ValidationErrorHandler? handler, string input, string[]? formats ) =>
+ validator.ExecuteValidation(
+ handler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => validateDateTime(
+ value => valueSetter( value.TimeOfDay ),
+ trimmedInput,
+ formats,
+ DateTime.MinValue,
+ DateTime.MaxValue ) );
+}
\ No newline at end of file
diff --git a/Tewl/InputValidation/Validation Methods/Numeric.cs b/Tewl/InputValidation/Validation Methods/Numeric.cs
new file mode 100644
index 0000000..859d371
--- /dev/null
+++ b/Tewl/InputValidation/Validation Methods/Numeric.cs
@@ -0,0 +1,247 @@
+namespace Tewl.InputValidation;
+
+partial class ValidatorExtensions {
+ ///
+ /// Returns the validated byte type from the given string and validation package.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ ///
+ public static ValidationResult GetByte( this Validator validator, ValidationErrorHandler? errorHandler, string input ) =>
+ validator.GetByte( errorHandler, input, byte.MinValue, byte.MaxValue );
+
+ ///
+ /// Returns the validated byte type from the given string and validation package.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ ///
+ public static ValidationResult GetByte( this Validator validator, ValidationErrorHandler? errorHandler, string input, byte min, byte max ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => validateGenericIntegerType( valueSetter, trimmedInput, min, max ) );
+
+ ///
+ /// Returns the validated byte type from the given string and validation package.
+ /// If allowEmpty is true and the given string is empty, null will be returned.
+ ///
+ public static ValidationResult GetNullableByte( this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateGenericIntegerType( value => valueSetter( value ), trimmedInput, byte.MinValue, byte.MaxValue ) );
+
+ ///
+ /// Returns the validated short type from the given string and validation package.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ ///
+ public static ValidationResult GetShort( this Validator validator, ValidationErrorHandler? errorHandler, string input ) =>
+ validator.GetShort( errorHandler, input, short.MinValue, short.MaxValue );
+
+ ///
+ /// Returns the validated short type from the given string and validation package.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ ///
+ public static ValidationResult GetShort( this Validator validator, ValidationErrorHandler? errorHandler, string input, short min, short max ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => validateGenericIntegerType( valueSetter, trimmedInput, min, max ) );
+
+ ///
+ /// Returns the validated short type from the given string and validation package.
+ /// If allowEmpty is true and the given string is empty, null will be returned.
+ ///
+ public static ValidationResult GetNullableShort( this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.GetNullableShort( errorHandler, input, allowEmpty, short.MinValue, short.MaxValue );
+
+ ///
+ /// Returns the validated short type from the given string and validation package.
+ /// If allowEmpty is true and the given string is empty, null will be returned.
+ ///
+ public static ValidationResult GetNullableShort(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty, short min, short max ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateGenericIntegerType( value => valueSetter( value ), trimmedInput, min, max ) );
+
+ ///
+ /// Returns the validated int type from the given string and validation package.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ ///
+ public static ValidationResult GetInt( this Validator validator, ValidationErrorHandler? errorHandler, string input ) =>
+ validator.GetInt( errorHandler, input, int.MinValue, int.MaxValue );
+
+ ///
+ /// Returns the validated int type from the given string and validation package.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ /// and are inclusive.
+ ///
+ public static ValidationResult GetInt( this Validator validator, ValidationErrorHandler? errorHandler, string input, int min, int max ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => validateGenericIntegerType( valueSetter, trimmedInput, min, max ) );
+
+ ///
+ /// Returns the validated int type from the given string and validation package.
+ /// If allowEmpty is true and the given string is empty, null will be returned.
+ ///
+ public static ValidationResult GetNullableInt(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty, int min = int.MinValue, int max = int.MaxValue ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateGenericIntegerType( value => valueSetter( value ), trimmedInput, min, max ) );
+
+ ///
+ /// Returns the validated long type from the given string and validation package.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ /// and are inclusive.
+ ///
+ public static ValidationResult GetLong(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, long min = long.MinValue, long max = long.MaxValue ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => validateGenericIntegerType( valueSetter, trimmedInput, min, max ) );
+
+ ///
+ /// Returns the validated long type from the given string and validation package.
+ /// If allowEmpty is true and the given string is empty, null will be returned.
+ ///
+ public static ValidationResult GetNullableLong(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty, long min = long.MinValue, long max = long.MaxValue ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateGenericIntegerType( value => valueSetter( value ), trimmedInput, min, max ) );
+
+ private static ValidationError? validateGenericIntegerType( Action valueSetter, string trimmedInput, long minValue, long maxValue ) {
+ long intResult;
+
+ try {
+ intResult = Convert.ToInt64( trimmedInput );
+
+ if( intResult > maxValue )
+ return ValidationError.TooLarge( minValue, maxValue );
+ if( intResult < minValue )
+ return ValidationError.TooSmall( minValue, maxValue );
+ }
+ catch( FormatException ) {
+ return ValidationError.Invalid();
+ }
+ catch( OverflowException ) {
+ return ValidationError.Invalid();
+ }
+
+ valueSetter( (T)Convert.ChangeType( intResult, typeof( T ) ) );
+ return null;
+ }
+
+ ///
+ /// Returns a validated float type from the given string, validation package, and min/max restrictions.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ ///
+ public static ValidationResult GetFloat( this Validator validator, ValidationErrorHandler? errorHandler, string input, float min, float max ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => validateFloat( valueSetter, trimmedInput, min, max ) );
+
+ ///
+ /// Returns a validated float type from the given string, validation package, and min/max restrictions.
+ /// If allowEmpty is true and the given string is empty, null will be returned.
+ ///
+ public static ValidationResult GetNullableFloat(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty, float min, float max ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateFloat( value => valueSetter( value ), trimmedInput, min, max ) );
+
+ private static ValidationError? validateFloat( Action valueSetter, string trimmedInput, float min, float max ) {
+ float floatValue;
+ try {
+ floatValue = float.Parse( trimmedInput );
+ }
+ catch( FormatException ) {
+ return ValidationError.Invalid();
+ }
+ catch( OverflowException ) {
+ return ValidationError.Invalid();
+ }
+
+ if( floatValue < min )
+ return ValidationError.TooSmall( min, max );
+ if( floatValue > max )
+ return ValidationError.TooLarge( min, max );
+
+ valueSetter( floatValue );
+ return null;
+ }
+
+ ///
+ /// Returns a validated decimal type from the given string and validation package.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ ///
+ public static ValidationResult GetDecimal( this Validator validator, ValidationErrorHandler? errorHandler, string input ) =>
+ validator.GetDecimal( errorHandler, input, decimal.MinValue, decimal.MaxValue );
+
+ ///
+ /// Returns a validated decimal type from the given string, validation package, and min/max restrictions.
+ /// Passing an empty string or null will result in ErrorCondition.Empty.
+ ///
+ public static ValidationResult
+ GetDecimal( this Validator validator, ValidationErrorHandler? errorHandler, string input, decimal min, decimal max ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ false,
+ ( valueSetter, trimmedInput ) => validateDecimal( valueSetter, trimmedInput, min, max ) );
+
+ ///
+ /// Returns a validated decimal type from the given string and validation package.
+ /// If allowEmpty is true and the given string is empty, null will be returned.
+ ///
+ public static ValidationResult
+ GetNullableDecimal( this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.GetNullableDecimal( errorHandler, input, allowEmpty, decimal.MinValue, decimal.MaxValue );
+
+ ///
+ /// Returns a validated decimal type from the given string, validation package, and min/max restrictions.
+ /// If allowEmpty is true and the given string is empty, null will be returned.
+ ///
+ public static ValidationResult GetNullableDecimal(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty, decimal min, decimal max ) =>
+ validator.ExecuteValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => validateDecimal( value => valueSetter( value ), trimmedInput, min, max ) );
+
+ private static ValidationError? validateDecimal( Action valueSetter, string trimmedInput, decimal min, decimal max ) {
+ decimal decimalVal;
+ try {
+ decimalVal = decimal.Parse( trimmedInput );
+ if( decimalVal < min )
+ return ValidationError.TooSmall( min, max );
+ if( decimalVal > max )
+ return ValidationError.TooLarge( min, max );
+ }
+ catch {
+ return ValidationError.Invalid();
+ }
+
+ valueSetter( decimalVal );
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/Tewl/InputValidation/Validation Methods/Phone.cs b/Tewl/InputValidation/Validation Methods/Phone.cs
new file mode 100644
index 0000000..f3d8c52
--- /dev/null
+++ b/Tewl/InputValidation/Validation Methods/Phone.cs
@@ -0,0 +1,134 @@
+using System.Text.RegularExpressions;
+
+namespace Tewl.InputValidation;
+
+partial class ValidatorExtensions {
+ ///
+ /// The same as GetPhoneNumber, except the given default area code will be prepended on the phone number if necessary.
+ /// This is useful when working with data that had the area code omitted because the number was local.
+ ///
+ public static ValidationResult GetPhoneNumberWithDefaultAreaCode(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowExtension, bool allowEmpty, bool allowSurroundingGarbage,
+ string defaultAreaCode ) {
+ if( new Validator().GetPhoneNumber( null, input, allowExtension, allowEmpty, allowSurroundingGarbage ).Error( out _ ) is not null )
+ // If the phone number was invalid without the area code, but is valid with the area code, we really validate using the default
+ // area code and then return. In all other cases, we return what would have happened without tacking on the default area code.
+ if( new Validator().GetPhoneNumber( null, defaultAreaCode + input, allowExtension, allowEmpty, allowSurroundingGarbage ).Error( out _ ) is null )
+ return validator.GetPhoneNumber( errorHandler, defaultAreaCode + input, allowExtension, allowEmpty, allowSurroundingGarbage );
+
+ return validator.GetPhoneNumber( errorHandler, input, allowExtension, allowEmpty, allowSurroundingGarbage );
+ }
+
+ ///
+ /// Returns a validated phone number as a standard phone number string given the complete phone number with optional
+ /// extension as a string. If allow empty is true and an empty string or null is given, the empty string is returned.
+ /// Pass true for allow surrounding garbage if you want to allow "The phone number is 585-455-6476yadayada." to be parsed
+ /// into 585-455-6476
+ /// and count as a valid phone number.
+ ///
+ public static ValidationResult GetPhoneNumber(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowExtension, bool allowEmpty, bool allowSurroundingGarbage ) =>
+ validator.GetPhoneWithLastFiveMapping( errorHandler, input, allowExtension, allowEmpty, allowSurroundingGarbage, null );
+
+ ///
+ /// Returns a validated phone number as a standard phone number string given the complete phone number with optional
+ /// extension or the last five digits of the number and a dictionary of single
+ /// digits to five-digit groups that become the first five digits of the full number. If allow empty is true and an empty
+ /// string or null is given, the empty string is returned.
+ ///
+ public static ValidationResult GetPhoneWithLastFiveMapping(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowExtension, bool allowEmpty, bool allowSurroundingGarbage,
+ Dictionary? firstFives ) {
+ return validator.executeStringValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => {
+ if( new Validator().GetPhoneNumberAsObject( null, trimmedInput, allowExtension, allowEmpty, allowSurroundingGarbage, firstFives )
+ .Error( out var value ) is {} error )
+ return error;
+
+ valueSetter( value.StandardPhoneString );
+ return null;
+ } );
+ }
+
+ internal static ValidationResult GetPhoneNumberAsObject(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowExtension, bool allowEmpty, bool allowSurroundingGarbage,
+ Dictionary? firstFives ) {
+ return validator.ExecuteValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => {
+ var invalidPrefix = "The {0} (" + trimmedInput + ") is invalid.";
+ // Remove all of the valid delimiter characters so we can just deal with numbers and whitespace
+ trimmedInput = trimmedInput.RemoveCharacters( '-', '(', ')', '.' ).Trim();
+
+ var invalidMessage = invalidPrefix +
+ " Phone numbers may be entered in any format, such as or xxx-xxx-xxxx, with an optional extension up to 5 digits long. International numbers should begin with a '+' sign.";
+ var phoneNumber = PhoneNumber.CreateFromParts( "", "", "" );
+
+ // NOTE: AllowSurroundingGarbage does not apply to first five or international numbers.
+
+ // First-five shortcut (intra-org phone numbers)
+ if( firstFives != null && Regex.IsMatch( trimmedInput, @"^\d{5}$" ) ) {
+ if( firstFives.ContainsKey( trimmedInput.Substring( 0, 1 ) ) ) {
+ var firstFive = firstFives[ trimmedInput.Substring( 0, 1 ) ];
+ phoneNumber = PhoneNumber.CreateFromParts( firstFive.Substring( 0, 3 ), firstFive.Substring( 3 ) + trimmedInput, "" );
+ }
+ else
+ return ValidationError.Custom( ValidationErrorType.Invalid, "The five digit phone number you entered isn't recognized." );
+ }
+ // International phone numbers
+ // We require a country code and then at least 7 digits (but if country code is more than one digit, we require fewer subsequent digits).
+ // We feel this is a reasonable limit to ensure that they are entering an actual phone number, but there is no source for this limit.
+ // We have no idea why we ever began accepting letters, but it's risky to stop accepting them and the consequences of accepting them are small.
+ else if( Regex.IsMatch( trimmedInput, @"\+\s*[0|2-9]([a-zA-Z,#/ \.\(\)\*]*[0-9]){7}" ) )
+ phoneNumber = PhoneNumber.CreateInternational( trimmedInput );
+ // Validated it as a North American Numbering Plan phone number
+ else {
+ var regex = @"(?\+?1)?\s*(?\d{3})\s*(?\d{3})\s*(?\d{4})\s*?(?:(?:x|\s|ext|ext\.|extension)\s*(?\d{1,5}))?\s*";
+ if( !allowSurroundingGarbage )
+ regex = "^" + regex + "$";
+
+ var match = Regex.Match( trimmedInput, regex );
+
+ if( match.Success ) {
+ var areaCode = match.Groups[ "ac" ].Value;
+ var number = match.Groups[ "num1" ].Value + match.Groups[ "num2" ].Value;
+ var extension = match.Groups[ "ext" ].Value;
+ phoneNumber = PhoneNumber.CreateFromParts( areaCode, number, extension );
+ if( !allowExtension && phoneNumber.Extension.Length > 0 )
+ return ValidationError.Custom(
+ ValidationErrorType.Invalid,
+ invalidPrefix + " Extensions are not permitted in this field. Use the separate extension field." );
+ }
+ else
+ return ValidationError.Custom( ValidationErrorType.Invalid, invalidMessage );
+ }
+
+ valueSetter( phoneNumber );
+ return null;
+ },
+ emptyValue: PhoneNumber.CreateFromParts( "", "", "" ) )!;
+ }
+
+ ///
+ /// Returns a validated phone number extension as a string.
+ /// If allow empty is true and the empty string or null is given, the empty string is returned.
+ ///
+ public static ValidationResult GetPhoneNumberExtension(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.executeStringValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => {
+ if( !Regex.IsMatch( trimmedInput, @"^ *(?\d{1,5}) *$" ) )
+ return ValidationError.Invalid();
+
+ valueSetter( trimmedInput );
+ return null;
+ } );
+}
\ No newline at end of file
diff --git a/Tewl/InputValidation/Validation Methods/Text.cs b/Tewl/InputValidation/Validation Methods/Text.cs
new file mode 100644
index 0000000..b588494
--- /dev/null
+++ b/Tewl/InputValidation/Validation Methods/Text.cs
@@ -0,0 +1,175 @@
+using System.Text.RegularExpressions;
+
+namespace Tewl.InputValidation;
+
+partial class ValidatorExtensions {
+ ///
+ /// Returns a validated string from the given string and restrictions.
+ /// If allowEmpty true and an empty string or null is given, the empty string is returned.
+ /// Automatically trims whitespace from edges of returned string.
+ ///
+ public static ValidationResult GetString( this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.GetString( errorHandler, input, allowEmpty, int.MaxValue );
+
+ ///
+ /// Returns a validated string from the given string and restrictions.
+ /// If allowEmpty true and an empty string or null is given, the empty string is returned.
+ /// Automatically trims whitespace from edges of returned string.
+ ///
+ public static ValidationResult GetString(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty, int maxLength ) =>
+ validator.GetString( errorHandler, input, allowEmpty, 0, maxLength );
+
+ ///
+ /// Returns a validated string from the given string and restrictions.
+ /// If allowEmpty true and an empty string or null is given, the empty string is returned.
+ /// Automatically trims whitespace from edges of returned string.
+ ///
+ public static ValidationResult GetString(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty, int minLength, int maxLength ) =>
+ validator.executeStringValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => {
+ var errorMessage = "The length of the {0} must be between " + minLength + " and " + maxLength + " characters.";
+ if( trimmedInput.Length > maxLength )
+ return ValidationError.Custom( ValidationErrorType.TooLong, errorMessage );
+ if( trimmedInput.Length < minLength )
+ return ValidationError.Custom( ValidationErrorType.TooShort, errorMessage );
+
+ valueSetter( trimmedInput );
+ return null;
+ } );
+
+ ///
+ /// Returns a validated email address from the given string and restrictions.
+ /// If allowEmpty true and the empty string or null is given, the empty string is returned.
+ /// Automatically trims whitespace from edges of returned string.
+ /// The maxLength defaults to 254 per this source: http://en.wikipedia.org/wiki/E-mail_address#Syntax
+ /// If you pass a different value for maxLength, you'd better have a good reason.
+ ///
+ public static ValidationResult GetEmailAddress(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty, int maxLength = 254 ) =>
+ validator.executeStringValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => {
+ // Validate as a string with same restrictions - if it fails on that, return
+ if( new Validator().GetString( null, trimmedInput, allowEmpty, maxLength ).Error( out _ ) is {} error )
+ return error;
+
+ // [^@ \n] means any character but a @ or a newline or a space. This forces only one @ to exist.
+ //([^@ \n\.]+\.)+ forces any positive number of (anything.)s to exist in a row. Doesn't allow "..".
+ // Allows anything.anything123-anything@anything.anything123.anything
+ const string localPartUnconditionallyPermittedCharacters = @"[a-z0-9!#\$%&'\*\+\-/=\?\^_`\{\|}~]";
+ const string localPart = "(" + localPartUnconditionallyPermittedCharacters + @"+\.?)*" + localPartUnconditionallyPermittedCharacters + "+";
+ const string domainUnconditionallyPermittedCharacters = "[a-z0-9-]";
+ const string domain = "(" + domainUnconditionallyPermittedCharacters + @"+\.)+" + domainUnconditionallyPermittedCharacters + "+";
+ // The first two conditions are for performance only.
+ if( !trimmedInput.Contains( "@" ) || !trimmedInput.Contains( "." ) || !Regex.IsMatch(
+ trimmedInput,
+ "^" + localPart + "@" + domain + "$",
+ RegexOptions.IgnoreCase ) )
+ return ValidationError.Invalid();
+ // Max length is already checked by the string validation
+ // NOTE: We should really enforce the max length of the domain portion and the local portion individually as well.
+
+ valueSetter( trimmedInput );
+ return null;
+ } );
+
+ ///
+ /// Returns a validated URL.
+ ///
+ public static ValidationResult GetUrl( this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.GetUrl( errorHandler, input, allowEmpty, 2048 );
+
+ ///
+ /// Returns a validated URL. Note that you may run into problems with certain browsers if you pass a length longer than
+ /// 2048.
+ ///
+ public static ValidationResult GetUrl(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty, int maxUrlLength ) =>
+ validator.executeStringValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => {
+ /* If the string is just a number, reject it right out. */
+ if( int.TryParse( trimmedInput, out _ ) || double.TryParse( trimmedInput, out _ ) )
+ return ValidationError.Invalid();
+
+ /* If it's an email, it's not an URL. */
+ if( new Validator().GetEmailAddress( null, trimmedInput, allowEmpty ).Error( out _ ) is null )
+ return ValidationError.Invalid();
+
+ /* If it doesn't start with one of our whitelisted schemes, add in the best guess. */
+ var validSchemes = new[] { "http", "https", "ftp" };
+ if( new Validator().GetString(
+ null,
+ validSchemes.Any( s => trimmedInput.StartsWithIgnoreCase( s ) ) ? trimmedInput : "http://" + trimmedInput,
+ true,
+ maxUrlLength )
+ .Error( out trimmedInput ) is {} error )
+ return error;
+
+ /* If the getstring didn't fail, keep on keepin keepin on. */
+ try {
+ if( !Uri.IsWellFormedUriString( trimmedInput, UriKind.Absolute ) )
+ throw new UriFormatException();
+
+ // Don't allow relative URLs
+ var uri = new Uri( trimmedInput, UriKind.Absolute );
+
+ // Must be a valid DNS-style hostname or IP address
+ // Must contain at least one '.', to prevent just host names
+ // Must be one of the common web browser-accessible schemes
+ if( uri.HostNameType != UriHostNameType.Dns && uri.HostNameType != UriHostNameType.IPv4 && uri.HostNameType != UriHostNameType.IPv6 ||
+ uri.Host.All( c => c != '.' ) || validSchemes.All( s => s != uri.Scheme ) )
+ throw new UriFormatException();
+ }
+ catch( UriFormatException ) {
+ return ValidationError.Invalid();
+ }
+
+ valueSetter( trimmedInput );
+ return null;
+ } );
+
+ ///
+ /// Returns a validated social security number from the given string and restrictions.
+ /// If allowEmpty true and an empty string or null is given, the empty string is returned.
+ ///
+ public static ValidationResult GetSocialSecurityNumber(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.GetNumericString( errorHandler, input, 9, allowEmpty, "-" );
+
+ ///
+ /// Gets a string of the given length whose characters are only numeric values, after throwing out all acceptable garbage
+ /// characters.
+ /// Example: A social security number (987-65-4321) would be GetNumber( errorHandler, ssn, 9, true, "-" ).
+ ///
+ public static ValidationResult GetNumericString(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, int numberOfDigits, bool allowEmpty,
+ params string[] acceptableGarbageStrings ) =>
+ validator.executeStringValidation(
+ errorHandler,
+ input,
+ allowEmpty,
+ ( valueSetter, trimmedInput ) => {
+ foreach( var garbageString in acceptableGarbageStrings )
+ trimmedInput = trimmedInput.Replace( garbageString, "" );
+ trimmedInput = trimmedInput.Trim();
+ if( !Regex.IsMatch( trimmedInput, @"^\d{" + numberOfDigits + "}$" ) )
+ return ValidationError.Invalid();
+ valueSetter( trimmedInput );
+ return null;
+ } );
+
+ private static ValidationResult executeStringValidation(
+ this Validator validator, ValidationErrorHandler? handler, InputType input, bool allowEmpty,
+ Validator.ValidationMethod validationMethod ) =>
+ validator.ExecuteValidation( handler, input, allowEmpty, validationMethod, emptyValue: "" )!;
+}
\ No newline at end of file
diff --git a/Tewl/InputValidation/Validation Methods/ZipCode.cs b/Tewl/InputValidation/Validation Methods/ZipCode.cs
new file mode 100644
index 0000000..5ea1a08
--- /dev/null
+++ b/Tewl/InputValidation/Validation Methods/ZipCode.cs
@@ -0,0 +1,16 @@
+namespace Tewl.InputValidation;
+
+partial class ValidatorExtensions {
+ ///
+ /// Gets a validated United States zip code object given the complete zip code with optional +4 digits.
+ ///
+ public static ValidationResult GetZipCode( this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.ExecuteValidation( errorHandler, input, allowEmpty, ZipCode.CreateUsZipCode, emptyValue: new ZipCode() )!;
+
+ ///
+ /// Gets a validated US or Canadian zip code.
+ ///
+ public static ValidationResult GetUsOrCanadianZipCode(
+ this Validator validator, ValidationErrorHandler? errorHandler, string input, bool allowEmpty ) =>
+ validator.ExecuteValidation( errorHandler, input, allowEmpty, ZipCode.CreateUsOrCanadianZipCode, emptyValue: new ZipCode() )!;
+}
\ No newline at end of file
diff --git a/Tewl/InputValidation/ValidationError.cs b/Tewl/InputValidation/ValidationError.cs
new file mode 100644
index 0000000..b0aa751
--- /dev/null
+++ b/Tewl/InputValidation/ValidationError.cs
@@ -0,0 +1,36 @@
+namespace Tewl.InputValidation;
+
+///
+/// A validation error.
+///
+public class ValidationError {
+ internal static ValidationError Custom( ValidationErrorType errorType, string errorMessage ) => new( errorType, errorMessage );
+
+ internal static ValidationError Invalid() => new( ValidationErrorType.Invalid, "Please enter a valid {0}." );
+
+ internal static ValidationError Empty() => new( ValidationErrorType.Empty, "Please enter the {0}." );
+
+ internal static ValidationError TooSmall( object min, object max ) =>
+ new( ValidationErrorType.TooLong, "The {0} must be between " + min + " and " + max + " (inclusive)." );
+
+ internal static ValidationError TooLarge( object min, object max ) =>
+ new( ValidationErrorType.TooLarge, "The {0} must be between " + min + " and " + max + " (inclusive)." );
+
+ ///
+ /// Gets the error type.
+ ///
+ public ValidationErrorType Type { get; }
+
+ private readonly string errorMessage;
+
+ private ValidationError( ValidationErrorType type, string message ) {
+ Type = type;
+ errorMessage = message;
+ }
+
+ ///
+ /// Returns the standard message for this error.
+ ///
+ /// The name of the result value. If this is used to start a sentence, it will be automatically capitalized.
+ public string GetMessage( string valueName ) => string.Format( errorMessage, valueName );
+}
\ No newline at end of file
diff --git a/Tewl/InputValidation/ValidationErrorHandler.cs b/Tewl/InputValidation/ValidationErrorHandler.cs
index 943c781..d36a44d 100644
--- a/Tewl/InputValidation/ValidationErrorHandler.cs
+++ b/Tewl/InputValidation/ValidationErrorHandler.cs
@@ -1,97 +1,67 @@
-using System;
-using System.Collections.Generic;
-using JetBrains.Annotations;
-using Tewl.Tools;
+namespace Tewl.InputValidation;
-namespace Tewl.InputValidation {
+///
+/// This class allows you to control what happens when a validation method generates an error. Every validation method takes a ValidationErrorHandler object
+/// as the first parameter.
+///
+[ PublicAPI ]
+public class ValidationErrorHandler {
///
- /// This class allows you to control what happens when a validation method generates an error. Every validation method
- /// takes a ValidationErrorHandler object
- /// as the first parameter. Currently you can't re-use these objects for more than one validation call since most
- /// validation methods don't reset LastResult.
+ /// Method that handles errors instead of the default handling mechanism.
///
- [ PublicAPI ]
- public class ValidationErrorHandler {
- ///
- /// Method that handles errors instead of the default handling mechanism.
- ///
- public delegate void CustomHandler( Validator validator, ErrorCondition errorCondition );
+ public delegate void CustomHandler( ValidationErrorType errorType );
- private readonly CustomHandler customHandler;
- private readonly Dictionary customMessages = new Dictionary();
- private ValidationResult validationResult = ValidationResult.NoError();
-
- ///
- /// Creates an error handler that adds standard error messages, based on the specified subject, to the validator. If the
- /// subject is used to start a
- /// sentence, it will be automatically capitalized.
- ///
- public ValidationErrorHandler( string subject ) => Subject = subject;
+ ///
+ /// The subject of the error message, if one needs to be generated.
+ ///
+ internal string Subject { get; } = "field";
- ///
- /// Creates an object that invokes code of your choice if an error occurs. The given custom handler will
- /// only be invoked in the case of an error, and it will prevent the default error message from being
- /// added to the validator's error collection. Even if the handler does not add an error to the validator,
- /// validator.HasErrors will return true because an error has still occurred.
- ///
- public ValidationErrorHandler( CustomHandler customHandler ) => this.customHandler = customHandler;
+ private readonly CustomHandler? customHandler;
+ private readonly Dictionary customMessages = new();
- ///
- /// Modifies this error handler to use a custom message if any errors occur with the specified conditions. If no error
- /// conditions are passed, the message
- /// will be used for all errors. This method has no effect if a custom handler has been specified.
- ///
- public void AddCustomErrorMessage( string message, params ErrorCondition[] errorConditions ) {
- if( errorConditions.Length > 0 ) {
- foreach( var e in errorConditions )
- customMessages.Add( e, message );
- }
- else {
- foreach( var e in EnumTools.GetValues() )
- customMessages.Add( e, message );
- }
- }
+ ///
+ /// Creates an error handler that adds standard error messages, based on the specified subject, to the validator. If the
+ /// subject is used to start a
+ /// sentence, it will be automatically capitalized.
+ ///
+ public ValidationErrorHandler( string subject ) => Subject = subject;
- ///
- /// The subject of the error message, if one needs to be generated.
- ///
- internal string Subject { get; } = "field";
+ ///
+ /// Creates an object that invokes code of your choice if an error occurs. The given custom handler will
+ /// only be invoked in the case of an error, and it will prevent the default error message from being
+ /// added to the validator's error collection. Even if the handler does not add an error to the validator,
+ /// validator.HasErrors will return true because an error has still occurred.
+ ///
+ public ValidationErrorHandler( CustomHandler customHandler ) => this.customHandler = customHandler;
- private bool used;
+ ///
+ /// Modifies this error handler to use a custom message if any errors occur with the specified types. If no error types are passed, the message will be used
+ /// for all errors. This method has no effect if a custom handler has been specified.
+ ///
+ public void AddCustomErrorMessage( string message, params ValidationErrorType[] errorTypes ) {
+ if( errorTypes.Length > 0 )
+ foreach( var e in errorTypes )
+ customMessages.Add( e, message );
+ else
+ foreach( var e in EnumTools.GetValues() )
+ customMessages.Add( e, message );
+ }
- internal void SetValidationResult( ValidationResult validationResult ) {
- if( used )
- throw new ApplicationException( "Validation error handlers cannot be re-used." );
- used = true;
- this.validationResult = validationResult;
+ ///
+ /// Invokes the appropriate behavior according to how this error handler was created.
+ ///
+ internal string HandleError( ValidationError error ) {
+ // if there is a custom handler, run it and do nothing else
+ if( customHandler is not null ) {
+ customHandler( error.Type );
+ return "";
}
- ///
- /// Returns the ErrorCondition resulting from the validation of the data associated with this package.
- ///
- public ErrorCondition LastResult => validationResult.ErrorCondition;
+ // build the error message
+ if( !customMessages.TryGetValue( error.Type, out var message ) )
+ // NOTE: Do we really need custom message, or can the custom handler manage that?
+ message = error.GetMessage( Subject );
- ///
- /// If LastResult is not NoError, this method invokes the appropriate behavior according to how this error handler was
- /// created.
- ///
- internal void HandleResult( Validator validator, bool errorWouldResultInUnusableReturnValue ) {
- if( validationResult.ErrorCondition == ErrorCondition.NoError )
- return;
-
- // if there is a custom handler, run it and do nothing else
- if( customHandler != null ) {
- validator.NoteError();
- customHandler( validator, validationResult.ErrorCondition );
- return;
- }
-
- // build the error message
- if( !customMessages.TryGetValue( validationResult.ErrorCondition, out var message ) )
- // NOTE: Do we really need custom message, or can the custom handler manage that?
- message = validationResult.GetErrorMessage( Subject );
-
- validator.AddError( new Error( message, errorWouldResultInUnusableReturnValue ) );
- }
+ return message;
}
}
\ No newline at end of file
diff --git a/Tewl/InputValidation/ValidationErrorType.cs b/Tewl/InputValidation/ValidationErrorType.cs
new file mode 100644
index 0000000..523c81e
--- /dev/null
+++ b/Tewl/InputValidation/ValidationErrorType.cs
@@ -0,0 +1,47 @@
+namespace Tewl.InputValidation;
+
+///
+/// The list of possible error types.
+///
+[ PublicAPI ]
+public enum ValidationErrorType {
+ ///
+ /// Empty
+ ///
+ Empty,
+
+ ///
+ /// Invalid
+ ///
+ Invalid,
+
+ ///
+ /// TooLong
+ ///
+ TooLong,
+
+ ///
+ /// TooShort
+ ///
+ TooShort,
+
+ ///
+ /// TooSmall
+ ///
+ TooSmall,
+
+ ///
+ /// TooLarge
+ ///
+ TooLarge,
+
+ ///
+ /// TooEarly
+ ///
+ TooEarly,
+
+ ///
+ /// TooLate
+ ///
+ TooLate
+}
\ No newline at end of file
diff --git a/Tewl/InputValidation/ValidationPackage.cs b/Tewl/InputValidation/ValidationPackage.cs
deleted file mode 100644
index d901d9e..0000000
--- a/Tewl/InputValidation/ValidationPackage.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System.Collections;
-using JetBrains.Annotations;
-
-namespace Tewl.InputValidation {
- ///
- /// Common data required to validate and build an error message for a piece of data.
- ///
- [ PublicAPI ]
- public class ValidationPackage {
- ///
- /// The subject of the error message, if one needs to be generated.
- ///
- public string Subject { get; }
-
- ///
- /// The map of ErrorConditions to error messages overrides.
- ///
- public IDictionary CustomMessages { get; }
-
- ///
- /// Returns the ErrorCondition resulting from the validation of the data
- /// associated with this package.
- ///
- public ErrorCondition ValidationResult { set; get; } = ErrorCondition.NoError;
-
- ///
- /// Create a new package with which to validate a piece of data.
- ///
- /// The subject of the error message, if one needs to be generated.
- /// The map of ErrorConditions to error messages overrides. Use null for no overrides.
- public ValidationPackage( string subject, IDictionary customMessages ) {
- Subject = subject;
- CustomMessages = customMessages;
- }
-
- ///
- /// Create a new package with which to validate a piece of data without
- /// specifying any custom messages overrides.
- ///
- /// The subject of the error message, if one needs to be generated.
- public ValidationPackage( string subject ): this( subject, null ) { }
- }
-}
\ No newline at end of file
diff --git a/Tewl/InputValidation/ValidationResult.cs b/Tewl/InputValidation/ValidationResult.cs
index 5a5a9bc..c1c4509 100644
--- a/Tewl/InputValidation/ValidationResult.cs
+++ b/Tewl/InputValidation/ValidationResult.cs
@@ -1,23 +1,32 @@
-namespace Tewl.InputValidation {
- internal class ValidationResult {
- private string errorMessage = "";
-
- private ValidationResult() { }
-
- public string GetErrorMessage( string subject ) => string.Format( errorMessage, subject );
-
- public ErrorCondition ErrorCondition { get; private set; } = ErrorCondition.NoError;
-
- public static ValidationResult Custom( ErrorCondition errorCondition, string errorMessage ) => new ValidationResult { ErrorCondition = errorCondition, errorMessage = errorMessage };
-
- public static ValidationResult NoError() => new ValidationResult();
-
- public static ValidationResult Invalid() => new ValidationResult { ErrorCondition = ErrorCondition.Invalid, errorMessage = "Please enter a valid {0}." };
-
- public static ValidationResult Empty() => new ValidationResult { ErrorCondition = ErrorCondition.Empty, errorMessage = "Please enter the {0}." };
-
- public static ValidationResult TooSmall( object min, object max ) => new ValidationResult { ErrorCondition = ErrorCondition.TooLong, errorMessage = "The {0} must be between " + min + " and " + max + " (inclusive)." };
+namespace Tewl.InputValidation;
+
+///
+/// The result of a validation.
+///
+[ PublicAPI ]
+public class ValidationResult {
+ ///
+ /// Gets the validated value. This is sometimes unusable if there was a validation error, and in that case will
+ /// be true.
+ ///
+ public T Value { get; }
+
+ private readonly ValidationError? error;
+
+ ///
+ /// Validator.ExecuteValidation use only.
+ ///
+ internal ValidationResult( T value, ValidationError? error ) {
+ Value = value;
+ this.error = error;
+ }
- public static ValidationResult TooLarge( object min, object max ) => new ValidationResult { ErrorCondition = ErrorCondition.TooLarge, errorMessage = "The {0} must be between " + min + " and " + max + " (inclusive)." };
+ ///
+ /// Returns the validation error, or null if validation was successful.
+ ///
+ /// The validated value.
+ public ValidationError? Error( out T value ) {
+ value = Value;
+ return error;
}
}
\ No newline at end of file
diff --git a/Tewl/InputValidation/Validator.cs b/Tewl/InputValidation/Validator.cs
index bcaeda6..bd1442f 100644
--- a/Tewl/InputValidation/Validator.cs
+++ b/Tewl/InputValidation/Validator.cs
@@ -1,7 +1,4 @@
-using System.Globalization;
-using System.Text.RegularExpressions;
-
-namespace Tewl.InputValidation;
+namespace Tewl.InputValidation;
///
/// Contains high-level validation methods. Each validation method returns an object that is the value of the validated
@@ -12,881 +9,123 @@ namespace Tewl.InputValidation;
///
[ PublicAPI ]
public class Validator {
- // NOTE: There is already something called this. Also, put it in its own file.
- private delegate T ValidationMethod();
-
- internal static readonly DateTime SqlSmallDateTimeMinValue = new DateTime( 1900, 1, 1 );
- internal static readonly DateTime SqlSmallDateTimeMaxValue = new DateTime( 2079, 6, 6 );
-
///
- /// Most of our SQL Server decimal columns are specified as (9,2). This is the minimum value that will fit in such a
- /// column.
+ /// Most of our SQL Server decimal columns are specified as (9,2). This is the minimum value that will fit in such a column.
///
public const decimal SqlDecimalDefaultMin = -9999999.99m;
///
- /// Most of our SQL Server decimal columns are specified as (9,2). This is the maximum value that will fit in such a
- /// column.
+ /// Most of our SQL Server decimal columns are specified as (9,2). This is the maximum value that will fit in such a column.
///
public const decimal SqlDecimalDefaultMax = 9999999.99m;
- private readonly List errors = new List();
-
- ///
- /// The maximum length for a URL as dictated by the limitations of Internet Explorer. This is safely the maximum size for a
- /// URL.
- ///
- public const int MaxUrlLength = 2048;
-
- ///
- /// Returns true if any errors have been encountered during validation so far. This can be true even while
- /// ErrorMessages.Count == 0
- /// and Errors.Count == 0, since NoteError may have been called.
- ///
- public bool ErrorsOccurred { get; private set; }
-
- ///
- /// Returns true if at least one unusable value has been returned since this Validator was created. An unusable value
- /// return
- /// is defined as any time a Get... fails validation and the Validator is forced to return something other than
- /// a good default value. An example of an unusable value would be a call to GetInt that fails validation. An
- /// example of a usable value is a call to GetNullableInt, with allowEmpty = true, that fails validation.
- ///
- public bool UnusableValuesReturned {
- get {
- foreach( var error in errors )
- if( error.UnusableValueReturned )
- return true;
-
- return false;
- }
- }
-
- ///
- /// Returns a deep copy of the list of error messages associated with the validation performed by this validator so far.
- /// It's possible for ErrorsOccurred to
- /// be true while ErrorsMessages.Count == 0, since NoteError may have been called.
- ///
- public List ErrorMessages {
- get {
- var errorMessages = new List();
- foreach( var error in errors )
- errorMessages.Add( error.Message );
- return errorMessages;
- }
- }
-
- ///
- /// Returns a deep copy of the list of errors associated with the validation performed by this validator so far. It's
- /// possible for ErrorsOccurred to
- /// be true while Errors.Count == 0, since NoteError may have been called.
- ///
- public List Errors => new List( errors );
-
- ///
- /// Sets the ErrorsOccurred flag.
- ///
- public void NoteError() => ErrorsOccurred = true;
-
- ///
- /// Sets the ErrorsOccurred flag and adds an error message to this validator. Use this if you want to add your own error
- /// message to the same collection
- /// that the error handlers use.
- ///
- public void NoteErrorAndAddMessage( string message ) => AddError( new Error( message, false ) );
-
- ///
- /// Sets the ErrorsOccurred flag and add the given error messages to this validator. Use this if you want to add your own
- /// error messages to the same collection
- /// that the error handlers use.
- ///
- public void NoteErrorAndAddMessages( params string[] messages ) {
- foreach( var message in messages )
- NoteErrorAndAddMessage( message );
- }
-
- internal void AddError( Error error ) {
- NoteError();
- errors.Add( error );
- }
-
- ///
- /// Accepts either true/false (case-sensitive) or 1/0.
- /// Returns the validated boolean type from the given string and validation package.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- ///
- public bool GetBoolean( ValidationErrorHandler errorHandler, string booleanAsString ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- booleanAsString,
- false,
- delegate { return validateBoolean( booleanAsString, errorHandler ); } );
-
- ///
- /// Accepts either true/false (case-sensitive) or 1/0.
- /// Returns the validated boolean type from the given string and validation package.
- /// If allowEmpty is true and the given string is empty, null will be returned.
- ///
- public bool? GetNullableBoolean( ValidationErrorHandler errorHandler, string booleanAsString, bool allowEmpty ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- booleanAsString,
- allowEmpty,
- () => validateBoolean( booleanAsString, errorHandler ) );
-
- private static bool validateBoolean( string booleanAsString, ValidationErrorHandler errorHandler ) {
- if( booleanAsString.IsNullOrWhiteSpace() )
- errorHandler.SetValidationResult( ValidationResult.Empty() );
- else if( booleanAsString != "1" && booleanAsString != "0" && booleanAsString != true.ToString() && booleanAsString != false.ToString() )
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
-
- return booleanAsString == "1" || booleanAsString == true.ToString();
- }
-
- ///
- /// Returns the validated byte type from the given string and validation package.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- ///
- public byte GetByte( ValidationErrorHandler errorHandler, string byteAsString ) => GetByte( errorHandler, byteAsString, byte.MinValue, byte.MaxValue );
-
- ///
- /// Returns the validated byte type from the given string and validation package.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- ///
- public byte GetByte( ValidationErrorHandler errorHandler, string byteAsString, byte min, byte max ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- byteAsString,
- false,
- delegate { return validateGenericIntegerType( errorHandler, byteAsString, min, max ); } );
-
- ///
- /// Returns the validated byte type from the given string and validation package.
- /// If allowEmpty is true and the given string is empty, null will be returned.
- ///
- public byte? GetNullableByte( ValidationErrorHandler errorHandler, string byteAsString, bool allowEmpty ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- byteAsString,
- allowEmpty,
- () => validateGenericIntegerType( errorHandler, byteAsString, byte.MinValue, byte.MaxValue ) );
-
- ///
- /// Returns the validated short type from the given string and validation package.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- ///
- public short GetShort( ValidationErrorHandler errorHandler, string shortAsString ) => GetShort( errorHandler, shortAsString, short.MinValue, short.MaxValue );
-
- ///
- /// Returns the validated short type from the given string and validation package.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- ///
- public short GetShort( ValidationErrorHandler errorHandler, string shortAsString, short min, short max ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- shortAsString,
- false,
- () => validateGenericIntegerType( errorHandler, shortAsString, min, max ) );
-
- ///
- /// Returns the validated short type from the given string and validation package.
- /// If allowEmpty is true and the given string is empty, null will be returned.
- ///
- public short? GetNullableShort( ValidationErrorHandler errorHandler, string shortAsString, bool allowEmpty ) =>
- GetNullableShort( errorHandler, shortAsString, allowEmpty, short.MinValue, short.MaxValue );
-
- ///
- /// Returns the validated short type from the given string and validation package.
- /// If allowEmpty is true and the given string is empty, null will be returned.
- ///
- public short? GetNullableShort( ValidationErrorHandler errorHandler, string shortAsString, bool allowEmpty, short min, short max ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- shortAsString,
- allowEmpty,
- () => validateGenericIntegerType( errorHandler, shortAsString, min, max ) );
-
- ///
- /// Returns the validated int type from the given string and validation package.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- ///
- public int GetInt( ValidationErrorHandler errorHandler, string intAsString ) => GetInt( errorHandler, intAsString, int.MinValue, int.MaxValue );
-
- ///
- /// Returns the validated int type from the given string and validation package.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- /// and are inclusive.
- ///
- public int GetInt( ValidationErrorHandler errorHandler, string intAsString, int min, int max ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- intAsString,
- false,
- () => validateGenericIntegerType( errorHandler, intAsString, min, max ) );
-
- ///
- /// Returns the validated int type from the given string and validation package.
- /// If allowEmpty is true and the given string is empty, null will be returned.
- ///
- public int? GetNullableInt( ValidationErrorHandler errorHandler, string intAsString, bool allowEmpty, int min = int.MinValue, int max = int.MaxValue ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- intAsString,
- allowEmpty,
- () => validateGenericIntegerType( errorHandler, intAsString, min, max ) );
-
- ///
- /// Returns the validated long type from the given string and validation package.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- /// and are inclusive.
- ///
- public long GetLong( ValidationErrorHandler errorHandler, string longAsString, long min = long.MinValue, long max = long.MaxValue ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- longAsString,
- false,
- () => validateGenericIntegerType( errorHandler, longAsString, min, max ) );
-
- ///
- /// Returns the validated long type from the given string and validation package.
- /// If allowEmpty is true and the given string is empty, null will be returned.
- ///
- public long?
- GetNullableLong( ValidationErrorHandler errorHandler, string longAsString, bool allowEmpty, long min = long.MinValue, long max = long.MaxValue ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- longAsString,
- allowEmpty,
- () => validateGenericIntegerType( errorHandler, longAsString, min, max ) );
-
- private static T validateGenericIntegerType( ValidationErrorHandler errorHandler, string valueAsString, long minValue, long maxValue ) {
- long intResult = 0;
-
- if( valueAsString.IsNullOrWhiteSpace() )
- errorHandler.SetValidationResult( ValidationResult.Empty() );
- else
- try {
- intResult = Convert.ToInt64( valueAsString );
-
- if( intResult > maxValue )
- errorHandler.SetValidationResult( ValidationResult.TooLarge( minValue, maxValue ) );
- else if( intResult < minValue )
- errorHandler.SetValidationResult( ValidationResult.TooSmall( minValue, maxValue ) );
- }
- catch( FormatException ) {
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- }
- catch( OverflowException ) {
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- }
-
- if( errorHandler.LastResult != ErrorCondition.NoError )
- return default;
- return (T)Convert.ChangeType( intResult, typeof( T ) );
- }
-
- ///
- /// Returns a validated float type from the given string, validation package, and min/max restrictions.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- ///
- public float GetFloat( ValidationErrorHandler errorHandler, string floatAsString, float min, float max ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- floatAsString,
- false,
- () => validateFloat( floatAsString, errorHandler, min, max ) );
-
- ///
- /// Returns a validated float type from the given string, validation package, and min/max restrictions.
- /// If allowEmpty is true and the given string is empty, null will be returned.
- ///
- public float? GetNullableFloat( ValidationErrorHandler errorHandler, string floatAsString, bool allowEmpty, float min, float max ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- floatAsString,
- allowEmpty,
- () => validateFloat( floatAsString, errorHandler, min, max ) );
-
- private static float validateFloat( string floatAsString, ValidationErrorHandler errorHandler, float min, float max ) {
- float floatValue = 0;
- if( floatAsString.IsNullOrWhiteSpace() )
- errorHandler.SetValidationResult( ValidationResult.Empty() );
- else {
- try {
- floatValue = float.Parse( floatAsString );
- }
- catch( FormatException ) {
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- }
- catch( OverflowException ) {
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- }
-
- if( floatValue < min )
- errorHandler.SetValidationResult( ValidationResult.TooSmall( min, max ) );
- else if( floatValue > max )
- errorHandler.SetValidationResult( ValidationResult.TooLarge( min, max ) );
- }
-
- return floatValue;
- }
-
- ///
- /// Returns a validated decimal type from the given string and validation package.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- ///
- public decimal GetDecimal( ValidationErrorHandler errorHandler, string decimalAsString ) =>
- GetDecimal( errorHandler, decimalAsString, decimal.MinValue, decimal.MaxValue );
-
///
- /// Returns a validated decimal type from the given string, validation package, and min/max restrictions.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
+ /// An error in a .
///
- public decimal GetDecimal( ValidationErrorHandler errorHandler, string decimalAsString, decimal min, decimal max ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- decimalAsString,
- false,
- () => validateDecimal( decimalAsString, errorHandler, min, max ) );
+ /// The error message.
+ /// Whether the error resulted in an unusable value being returned.
+ public record Error( string Message, bool UnusableValueReturned );
- ///
- /// Returns a validated decimal type from the given string and validation package.
- /// If allowEmpty is true and the given string is empty, null will be returned.
- ///
- public decimal? GetNullableDecimal( ValidationErrorHandler errorHandler, string decimalAsString, bool allowEmpty ) =>
- GetNullableDecimal( errorHandler, decimalAsString, allowEmpty, decimal.MinValue, decimal.MaxValue );
+ internal delegate ValidationError? ValidationMethod( Action valueSetter, InputType trimmedInput );
- ///
- /// Returns a validated decimal type from the given string, validation package, and min/max restrictions.
- /// If allowEmpty is true and the given string is empty, null will be returned.
- ///
- public decimal? GetNullableDecimal( ValidationErrorHandler errorHandler, string decimalAsString, bool allowEmpty, decimal min, decimal max ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- decimalAsString,
- allowEmpty,
- () => validateDecimal( decimalAsString, errorHandler, min, max ) );
+ private static bool isEmpty( InputType input, out InputType trimmedInput ) {
+ var type = typeof( InputType );
- private static decimal validateDecimal( string decimalAsString, ValidationErrorHandler errorHandler, decimal min, decimal max ) {
- if( decimalAsString.IsNullOrWhiteSpace() ) {
- errorHandler.SetValidationResult( ValidationResult.Empty() );
- return 0;
- }
-
- decimal decimalVal = 0;
- try {
- decimalVal = decimal.Parse( decimalAsString );
- if( decimalVal < min )
- errorHandler.SetValidationResult( ValidationResult.TooSmall( min, max ) );
- else if( decimalVal > max )
- errorHandler.SetValidationResult( ValidationResult.TooLarge( min, max ) );
- }
- catch {
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
+ if( type == typeof( string ) ) {
+ var trimmedString = ( (string)(object)input! ).Trim();
+ trimmedInput = (InputType)(object)trimmedString;
+ return trimmedString.Length == 0;
}
- return decimalVal;
+ trimmedInput = input;
+ return input is null;
}
///
- /// Returns a validated string from the given string and restrictions.
- /// If allowEmpty true and an empty string or null is given, the empty string is returned.
- /// Automatically trims whitespace from edges of returned string.
+ /// Returns true if any errors have been encountered during validation so far. This can be true even while ErrorMessages.Count == 0 and Errors.Count == 0,
+ /// since NoteError may have been called.
///
- public string GetString( ValidationErrorHandler errorHandler, string text, bool allowEmpty ) => GetString( errorHandler, text, allowEmpty, int.MaxValue );
-
- ///
- /// Returns a validated string from the given string and restrictions.
- /// If allowEmpty true and an empty string or null is given, the empty string is returned.
- /// Automatically trims whitespace from edges of returned string.
- ///
- public string GetString( ValidationErrorHandler errorHandler, string text, bool allowEmpty, int maxLength ) =>
- GetString( errorHandler, text, allowEmpty, 0, maxLength );
-
- ///
- /// Returns a validated string from the given string and restrictions.
- /// If allowEmpty true and an empty string or null is given, the empty string is returned.
- /// Automatically trims whitespace from edges of returned string.
- ///
- public string GetString( ValidationErrorHandler errorHandler, string text, bool allowEmpty, int minLength, int maxLength ) =>
- handleEmptyAndReturnEmptyStringIfInvalid(
- errorHandler,
- text,
- allowEmpty,
- () => {
- var errorMessage = "The length of the " + errorHandler.Subject + " must be between " + minLength + " and " + maxLength + " characters.";
- if( text.Length > maxLength )
- errorHandler.SetValidationResult( ValidationResult.Custom( ErrorCondition.TooLong, errorMessage ) );
- else if( text.Length < minLength )
- errorHandler.SetValidationResult( ValidationResult.Custom( ErrorCondition.TooShort, errorMessage ) );
-
- return text.Trim();
- } );
-
- ///
- /// Returns a validated email address from the given string and restrictions.
- /// If allowEmpty true and the empty string or null is given, the empty string is returned.
- /// Automatically trims whitespace from edges of returned string.
- /// The maxLength defaults to 254 per this source: http://en.wikipedia.org/wiki/E-mail_address#Syntax
- /// If you pass a different value for maxLength, you'd better have a good reason.
- ///
- public string GetEmailAddress( ValidationErrorHandler errorHandler, string emailAddress, bool allowEmpty, int maxLength = 254 ) =>
- handleEmptyAndReturnEmptyStringIfInvalid(
- errorHandler,
- emailAddress,
- allowEmpty,
- () => {
- // Validate as a string with same restrictions - if it fails on that, return
- emailAddress = GetString( errorHandler, emailAddress, allowEmpty, maxLength );
- if( errorHandler.LastResult == ErrorCondition.NoError ) {
- // [^@ \n] means any character but a @ or a newline or a space. This forces only one @ to exist.
- //([^@ \n\.]+\.)+ forces any positive number of (anything.)s to exist in a row. Doesn't allow "..".
- // Allows anything.anything123-anything@anything.anything123.anything
- const string localPartUnconditionallyPermittedCharacters = @"[a-z0-9!#\$%&'\*\+\-/=\?\^_`\{\|}~]";
- const string localPart = "(" + localPartUnconditionallyPermittedCharacters + @"+\.?)*" + localPartUnconditionallyPermittedCharacters + "+";
- const string domainUnconditionallyPermittedCharacters = "[a-z0-9-]";
- const string domain = "(" + domainUnconditionallyPermittedCharacters + @"+\.)+" + domainUnconditionallyPermittedCharacters + "+";
- // The first two conditions are for performance only.
- if( !emailAddress.Contains( "@" ) || !emailAddress.Contains( "." ) || !Regex.IsMatch(
- emailAddress,
- "^" + localPart + "@" + domain + "$",
- RegexOptions.IgnoreCase ) )
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- // Max length is already checked by the string validation
- // NOTE: We should really enforce the max length of the domain portion and the local portion individually as well.
- }
+ public bool ErrorsOccurred { get; private set; }
- return emailAddress;
- } );
+ private readonly List errors = [ ];
///
- /// Returns a validated URL.
+ /// Returns true if at least one unusable value has been returned since this Validator was created. An unusable value return is defined as any time a Get…
+ /// fails validation and the Validator is forced to return something other than an allowed empty value. An example of an unusable value would be a call to
+ /// GetInt that fails validation. An example of a usable value is a call to GetNullableInt, with allowEmpty = true, that fails validation.
///
- public string GetUrl( ValidationErrorHandler errorHandler, string url, bool allowEmpty ) => GetUrl( errorHandler, url, allowEmpty, MaxUrlLength );
-
- private static readonly string[] validSchemes = { "http", "https", "ftp" };
+ public bool UnusableValuesReturned => errors.Any( i => i.UnusableValueReturned );
///
- /// Returns a validated URL. Note that you may run into problems with certain browsers if you pass a length longer than
- /// 2048.
+ /// Returns a deep copy of the list of error messages associated with the validation performed by this validator so far. It’s possible for ErrorsOccurred to
+ /// be true while ErrorsMessages.Count == 0, since NoteError may have been called.
///
- public string GetUrl( ValidationErrorHandler errorHandler, string url, bool allowEmpty, int maxUrlLength ) =>
- handleEmptyAndReturnEmptyStringIfInvalid(
- errorHandler,
- url,
- allowEmpty,
- () => {
- /* If the string is just a number, reject it right out. */
- if( int.TryParse( url, out _ ) || double.TryParse( url, out _ ) ) {
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- return url;
- }
-
- /* If it's an email, it's not an URL. */
- var testingValidator = new Validator();
- testingValidator.GetEmailAddress( new ValidationErrorHandler( "" ), url, allowEmpty );
- if( !testingValidator.ErrorsOccurred ) {
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- return url;
- }
-
- /* If it doesn't start with one of our whitelisted schemes, add in the best guess. */
- url = GetString( errorHandler, validSchemes.Any( s => url.StartsWithIgnoreCase( s ) ) ? url : "http://" + url, true, maxUrlLength );
-
- /* If the getstring didn't fail, keep on keepin keepin on. */
- if( errorHandler.LastResult == ErrorCondition.NoError )
- try {
- if( !Uri.IsWellFormedUriString( url, UriKind.Absolute ) )
- throw new UriFormatException();
-
- // Don't allow relative URLs
- var uri = new Uri( url, UriKind.Absolute );
-
- // Must be a valid DNS-style hostname or IP address
- // Must contain at least one '.', to prevent just host names
- // Must be one of the common web browser-accessible schemes
- if( uri.HostNameType != UriHostNameType.Dns && uri.HostNameType != UriHostNameType.IPv4 && uri.HostNameType != UriHostNameType.IPv6 ||
- uri.Host.All( c => c != '.' ) || validSchemes.All( s => s != uri.Scheme ) )
- throw new UriFormatException();
- }
- catch( UriFormatException ) {
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- }
-
- return url;
- } );
+ public IReadOnlyCollection ErrorMessages => errors.Select( i => i.Message ).Materialize();
///
- /// The same as GetPhoneNumber, except the given default area code will be prepended on the phone number if necessary.
- /// This is useful when working with data that had the area code omitted because the number was local.
+ /// Returns a deep copy of the list of errors associated with the validation performed by this validator so far. It’s possible for ErrorsOccurred to be true
+ /// while Errors.Count == 0, since NoteError may have been called.
///
- public string GetPhoneNumberWithDefaultAreaCode(
- ValidationErrorHandler errorHandler, string completePhoneNumber, bool allowExtension, bool allowEmpty, bool allowSurroundingGarbage,
- string defaultAreaCode ) {
- var validator = new Validator(); // We need to use a separate one so that erroneous error messages don't get left in the collection
- var fakeHandler = new ValidationErrorHandler( "" );
-
- validator.GetPhoneNumber( fakeHandler, completePhoneNumber, allowExtension, allowEmpty, allowSurroundingGarbage );
- if( fakeHandler.LastResult != ErrorCondition.NoError ) {
- fakeHandler = new ValidationErrorHandler( "" );
- validator.GetPhoneNumber( fakeHandler, defaultAreaCode + completePhoneNumber, allowExtension, allowEmpty, allowSurroundingGarbage );
- // If the phone number was invalid without the area code, but is valid with the area code, we really validate using the default
- // area code and then return. In all other cases, we return what would have happened without tacking on the default area code.
- if( fakeHandler.LastResult == ErrorCondition.NoError )
- return GetPhoneNumber( errorHandler, defaultAreaCode + completePhoneNumber, allowExtension, allowEmpty, allowSurroundingGarbage );
- }
-
- return GetPhoneNumber( errorHandler, completePhoneNumber, allowExtension, allowEmpty, allowSurroundingGarbage );
- }
+ public IReadOnlyCollection Errors => [ ..errors ];
///
- /// Returns a validated phone number as a standard phone number string given the complete phone number with optional
- /// extension as a string. If allow empty is true and an empty string or null is given, the empty string is returned.
- /// Pass true for allow surrounding garbage if you want to allow "The phone number is 585-455-6476yadayada." to be parsed
- /// into 585-455-6476
- /// and count as a valid phone number.
+ /// Sets the ErrorsOccurred flag.
///
- public string GetPhoneNumber(
- ValidationErrorHandler errorHandler, string completePhoneNumber, bool allowExtension, bool allowEmpty, bool allowSurroundingGarbage ) =>
- GetPhoneWithLastFiveMapping( errorHandler, completePhoneNumber, allowExtension, allowEmpty, allowSurroundingGarbage, null );
+ public void NoteError() => ErrorsOccurred = true;
///
- /// Returns a validated phone number as a standard phone number string given the complete phone number with optional
- /// extension or the last five digits of the number and a dictionary of single
- /// digits to five-digit groups that become the first five digits of the full number. If allow empty is true and an empty
- /// string or null is given, the empty string is returned.
+ /// Sets the ErrorsOccurred flag and adds an error message to this validator. Use this if you want to add your own error message to the same collection that
+ /// the error handlers use.
///
- public string GetPhoneWithLastFiveMapping(
- ValidationErrorHandler errorHandler, string input, bool allowExtension, bool allowEmpty, bool allowSurroundingGarbage,
- Dictionary firstFives ) =>
- GetPhoneNumberAsObject( errorHandler, input, allowExtension, allowEmpty, allowSurroundingGarbage, firstFives ).StandardPhoneString;
-
- internal PhoneNumber GetPhoneNumberAsObject(
- ValidationErrorHandler errorHandler, string input, bool allowExtension, bool allowEmpty, bool allowSurroundingGarbage,
- Dictionary firstFives ) {
- return executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- input,
- allowEmpty,
- () => {
- var invalidPrefix = "The " + errorHandler.Subject + " (" + input + ") is invalid.";
- // Remove all of the valid delimiter characters so we can just deal with numbers and whitespace
- input = input.RemoveCharacters( '-', '(', ')', '.' ).Trim();
-
- var invalidMessage = invalidPrefix +
- " Phone numbers may be entered in any format, such as or xxx-xxx-xxxx, with an optional extension up to 5 digits long. International numbers should begin with a '+' sign.";
- var phoneNumber = PhoneNumber.CreateFromParts( "", "", "" );
-
- // NOTE: AllowSurroundingGarbage does not apply to first five or international numbers.
-
- // First-five shortcut (intra-org phone numbers)
- if( firstFives != null && Regex.IsMatch( input, @"^\d{5}$" ) ) {
- if( firstFives.ContainsKey( input.Substring( 0, 1 ) ) ) {
- var firstFive = firstFives[ input.Substring( 0, 1 ) ];
- phoneNumber = PhoneNumber.CreateFromParts( firstFive.Substring( 0, 3 ), firstFive.Substring( 3 ) + input, "" );
- }
- else
- errorHandler.SetValidationResult( ValidationResult.Custom( ErrorCondition.Invalid, "The five digit phone number you entered isn't recognized." ) );
- }
- // International phone numbers
- // We require a country code and then at least 7 digits (but if country code is more than one digit, we require fewer subsequent digits).
- // We feel this is a reasonable limit to ensure that they are entering an actual phone number, but there is no source for this limit.
- // We have no idea why we ever began accepting letters, but it's risky to stop accepting them and the consequences of accepting them are small.
- else if( Regex.IsMatch( input, @"\+\s*[0|2-9]([a-zA-Z,#/ \.\(\)\*]*[0-9]){7}" ) )
- phoneNumber = PhoneNumber.CreateInternational( input );
- // Validated it as a North American Numbering Plan phone number
- else {
- var regex = @"(?\+?1)?\s*(?\d{3})\s*(?\d{3})\s*(?\d{4})\s*?(?:(?:x|\s|ext|ext\.|extension)\s*(?\d{1,5}))?\s*";
- if( !allowSurroundingGarbage )
- regex = "^" + regex + "$";
-
- var match = Regex.Match( input, regex );
-
- if( match.Success ) {
- var areaCode = match.Groups[ "ac" ].Value;
- var number = match.Groups[ "num1" ].Value + match.Groups[ "num2" ].Value;
- var extension = match.Groups[ "ext" ].Value;
- phoneNumber = PhoneNumber.CreateFromParts( areaCode, number, extension );
- if( !allowExtension && phoneNumber.Extension.Length > 0 )
- errorHandler.SetValidationResult(
- ValidationResult.Custom(
- ErrorCondition.Invalid,
- invalidPrefix + " Extensions are not permitted in this field. Use the separate extension field." ) );
- }
- else
- errorHandler.SetValidationResult( ValidationResult.Custom( ErrorCondition.Invalid, invalidMessage ) );
- }
-
- return phoneNumber;
- },
- PhoneNumber.CreateFromParts( "", "", "" ) );
+ public void NoteErrorAndAddMessage( string message ) {
+ NoteError();
+ errors.Add( new Error( message, false ) );
}
///
- /// Returns a validated phone number extension as a string.
- /// If allow empty is true and the empty string or null is given, the empty string is returned.
- ///
- public string GetPhoneNumberExtension( ValidationErrorHandler errorHandler, string extension, bool allowEmpty ) =>
- handleEmptyAndReturnEmptyStringIfInvalid(
- errorHandler,
- extension,
- allowEmpty,
- () => {
- extension = extension.Trim();
- if( !Regex.IsMatch( extension, @"^ *(?\d{1,5}) *$" ) )
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
-
- return extension;
- } );
-
- ///
- /// Returns a validated social security number from the given string and restrictions.
- /// If allowEmpty true and an empty string or null is given, the empty string is returned.
- ///
- public string GetSocialSecurityNumber( ValidationErrorHandler errorHandler, string ssn, bool allowEmpty ) =>
- GetNumber( errorHandler, ssn, 9, allowEmpty, "-" );
-
- ///
- /// Gets a string of the given length whose characters are only numeric values, after throwing out all acceptable garbage
- /// characters.
- /// Example: A social security number (987-65-4321) would be GetNumber( errorHandler, ssn, 9, true, "-" ).
- ///
- public string GetNumber( ValidationErrorHandler errorHandler, string text, int numberOfDigits, bool allowEmpty, params string[] acceptableGarbageStrings ) =>
- handleEmptyAndReturnEmptyStringIfInvalid(
- errorHandler,
- text,
- allowEmpty,
- delegate {
- foreach( var garbageString in acceptableGarbageStrings )
- text = text.Replace( garbageString, "" );
- text = text.Trim();
- if( !Regex.IsMatch( text, @"^\d{" + numberOfDigits + "}$" ) )
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- return text;
- } );
-
- ///
- /// Gets a validated United States zip code object given the complete zip code with optional +4 digits.
- ///
- public ZipCode GetZipCode( ValidationErrorHandler errorHandler, string zipCode, bool allowEmpty ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- zipCode,
- allowEmpty,
- () => ZipCode.CreateUsZipCode( errorHandler, zipCode ),
- new ZipCode() );
-
- ///
- /// Gets a validated US or Canadian zip code.
- ///
- public ZipCode GetUsOrCanadianZipCode( ValidationErrorHandler errorHandler, string zipCode, bool allowEmpty ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- zipCode,
- allowEmpty,
- () => ZipCode.CreateUsOrCanadianZipCode( errorHandler, zipCode ),
- new ZipCode() );
-
- ///
- /// Returns the validated DateTime type from the given string and validation package.
- /// It is restricted to the Sql SmallDateTime range of 1/1/1900 up to 6/6/2079.
- /// Passing an empty string or null will result in ErrorCondition.Empty.
- ///
- public DateTime GetSqlSmallDateTime( ValidationErrorHandler errorHandler, string dateAsString ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- dateAsString,
- false,
- delegate { return validateDateTime( errorHandler, dateAsString, null, SqlSmallDateTimeMinValue, SqlSmallDateTimeMaxValue ); } );
-
- ///
- /// Returns the validated DateTime type from the given string and validation package.
- /// It is restricted to the Sql SmallDateTime range of 1/1/1900 up to 6/6/2079.
- /// If allowEmpty is true and the given string is empty, null will be returned.
- ///
- public DateTime? GetNullableSqlSmallDateTime( ValidationErrorHandler errorHandler, string dateAsString, bool allowEmpty ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- dateAsString,
- allowEmpty,
- () => validateDateTime( errorHandler, dateAsString, null, SqlSmallDateTimeMinValue, SqlSmallDateTimeMaxValue ) );
-
- ///
- /// Returns the validated DateTime type from the given date part strings and validation package.
- /// It is restricted to the Sql SmallDateTime range of 1/1/1900 up to 6/6/2079.
- /// Passing an empty string or null for each date part will result in ErrorCondition.Empty.
- /// Passing an empty string or null for only some date parts will result in ErrorCondition.Invalid.
- ///
- public DateTime GetSqlSmallDateTimeFromParts( ValidationErrorHandler errorHandler, string month, string day, string year ) =>
- GetSqlSmallDateTime( errorHandler, makeDateFromParts( month, day, year ) );
-
- ///
- /// Returns the validated DateTime type from the given date part strings and validation package.
- /// It is restricted to the Sql SmallDateTime range of 1/1/1900 up to 6/6/2079.
- /// If allowEmpty is true and each date part string is empty, null will be returned.
- /// Passing an empty string or null for only some date parts will result in ErrorCondition.Invalid.
+ /// Sets the ErrorsOccurred flag and add the given error messages to this validator. Use this if you want to add your own error messages to the same
+ /// collection that the error handlers use.
///
- public DateTime? GetNullableSqlSmallDateTimeFromParts( ValidationErrorHandler errorHandler, string month, string day, string year, bool allowEmpty ) =>
- GetNullableSqlSmallDateTime( errorHandler, makeDateFromParts( month, day, year ), allowEmpty );
-
- private static string makeDateFromParts( string month, string day, string year ) {
- var date = month + '/' + day + '/' + year;
- if( date == "//" )
- date = "";
- return date;
+ public void NoteErrorAndAddMessages( params string[] messages ) {
+ foreach( var message in messages )
+ NoteErrorAndAddMessage( message );
}
///
- /// Returns the validated DateTime type from a date string and an exact match pattern.'
- /// Pattern specifies the date format, such as "MM/dd/yyyy".
+ /// Executes a validation and returns the result.
///
- public DateTime GetSqlSmallDateTimeExact( ValidationErrorHandler errorHandler, string dateAsString, string pattern ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- dateAsString,
- false,
- () => validateSqlSmallDateTimeExact( errorHandler, dateAsString, pattern ) );
+ /// Pass null if you’re only validating a single value and don’t need to distinguish it from others in error messages.
+ ///
+ ///
+ ///
+ /// The result value that will be used if the input value is empty or if there is a validation error.
+ internal ValidationResult ExecuteValidation(
+ ValidationErrorHandler? handler, InputType input, bool allowEmpty, ValidationMethod validationMethod, ValType? emptyValue = default ) {
+ handler ??= new ValidationErrorHandler( "value" );
- ///
- /// Returns the validated DateTime type from a date string and an exact match pattern.'
- /// Pattern specifies the date format, such as "MM/dd/yyyy".
- ///
- public DateTime? GetNullableSqlSmallDateTimeExact( ValidationErrorHandler errorHandler, string dateAsString, string pattern, bool allowEmpty ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- errorHandler,
- dateAsString,
- allowEmpty,
- delegate { return validateSqlSmallDateTimeExact( errorHandler, dateAsString, pattern ); } );
+ if( isEmpty( input, out var trimmedInput ) )
+ return allowEmpty ? new ValidationResult( emptyValue, null ) : handleError( ValidationError.Empty() );
- private static DateTime validateSqlSmallDateTimeExact( ValidationErrorHandler errorHandler, string dateAsString, string pattern ) {
- if( !DateTime.TryParseExact( dateAsString, pattern, Cultures.EnglishUnitedStates, DateTimeStyles.None, out var date ) )
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- else
- validateNativeDateTime( errorHandler, date, false, SqlSmallDateTimeMinValue, SqlSmallDateTimeMaxValue );
+ var result = emptyValue;
+ if( validationMethod( value => result = value, trimmedInput ) is {} validationMethodError )
+ return handleError( validationMethodError );
- return date;
- }
+ return new ValidationResult( result, null );
- private static DateTime validateDateTime( ValidationErrorHandler errorHandler, string dateAsString, string[]? formats, DateTime min, DateTime max ) {
- var date = DateTime.Now;
- try {
- date = formats is not null
- ? DateTime.ParseExact( dateAsString, formats, null, DateTimeStyles.None )
- : DateTime.Parse( dateAsString, Cultures.EnglishUnitedStates );
- validateNativeDateTime( errorHandler, date, false, min, max );
- }
- catch( FormatException ) {
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- }
- catch( ArgumentOutOfRangeException ) {
- // Undocumented exception that there are reports of being thrown
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- }
- catch( ArgumentNullException ) {
- errorHandler.SetValidationResult( ValidationResult.Empty() );
- }
+ ValidationResult handleError( ValidationError error ) {
+ NoteError();
- return date;
- }
+ var message = handler.HandleError( error );
+ if( message.Length > 0 )
+ errors.Add( new Error( message, !allowEmpty ) );
- private static void validateNativeDateTime( ValidationErrorHandler errorHandler, DateTime? date, bool allowEmpty, DateTime minDate, DateTime maxDate ) {
- if( date == null && !allowEmpty )
- errorHandler.SetValidationResult( ValidationResult.Empty() );
- else if( date.HasValue ) {
- var minMaxMessage = " It must be between " + minDate.ToDayMonthYearString( false ) + " and " + maxDate.ToDayMonthYearString( false ) + ".";
- if( date < minDate )
- errorHandler.SetValidationResult(
- ValidationResult.Custom( ErrorCondition.TooEarly, "The " + errorHandler.Subject + " is too early." + minMaxMessage ) );
- else if( date >= maxDate )
- errorHandler.SetValidationResult( ValidationResult.Custom( ErrorCondition.TooLate, "The " + errorHandler.Subject + " is too late." + minMaxMessage ) );
+ return new ValidationResult( emptyValue, error );
}
}
+}
- ///
- /// Validates the date using given allowEmpty, min, and max constraints.
- ///
- public DateTime? GetNullableDateTime(
- ValidationErrorHandler handler, string dateAsString, string[]? formats, bool allowEmpty, DateTime minDate, DateTime maxDate ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- handler,
- dateAsString,
- allowEmpty,
- () => validateDateTime( handler, dateAsString, formats, minDate, maxDate ) );
-
- ///
- /// Validates the date using given min and max constraints.
- ///
- public DateTime GetDateTime( ValidationErrorHandler handler, string dateAsString, string[]? formats, DateTime minDate, DateTime maxDate ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- handler,
- dateAsString,
- false,
- () => validateDateTime( handler, dateAsString, formats, minDate, maxDate ) );
-
- ///
- /// Validates the given time span.
- ///
- public TimeSpan? GetNullableTimeSpan( ValidationErrorHandler handler, TimeSpan? timeSpan, bool allowEmpty ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid( handler, timeSpan, allowEmpty, () => timeSpan );
-
- ///
- /// Validates the given time span.
- ///
- public TimeSpan GetTimeSpan( ValidationErrorHandler handler, TimeSpan? timeSpan ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid( handler, timeSpan, false, () => timeSpan ?? default );
-
- ///
- /// Validates the given time span.
- ///
- public TimeSpan? GetNullableTimeOfDayTimeSpan( ValidationErrorHandler handler, string timeSpanAsString, string[]? formats, bool allowEmpty ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- handler,
- timeSpanAsString,
- allowEmpty,
- () => validateDateTime( handler, timeSpanAsString, formats, DateTime.MinValue, DateTime.MaxValue ).TimeOfDay );
-
- ///
- /// Validates the given time span.
- ///
- public TimeSpan GetTimeOfDayTimeSpan( ValidationErrorHandler handler, string timeSpanAsString, string[]? formats ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- handler,
- timeSpanAsString,
- false,
- () => validateDateTime( handler, timeSpanAsString, formats, DateTime.MinValue, DateTime.MaxValue ).TimeOfDay );
-
- private T executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid(
- ValidationErrorHandler handler, object valueAsObject, bool allowEmpty, ValidationMethod method, T customDefaultReturnValue = default ) {
- var result = customDefaultReturnValue;
- if( !isEmpty( handler, valueAsObject, allowEmpty ) )
- result = method();
-
- // If there was an error of any kind, the result becomes the default value
- if( handler.LastResult != ErrorCondition.NoError )
- result = customDefaultReturnValue;
-
- handler.HandleResult( this, !allowEmpty );
- return result;
- }
-
- private string handleEmptyAndReturnEmptyStringIfInvalid(
- ValidationErrorHandler handler, object valueAsObject, bool allowEmpty, ValidationMethod method ) =>
- executeValidationMethodAndHandleEmptyAndReturnDefaultIfInvalid( handler, valueAsObject, allowEmpty, method, "" );
-
- ///
- /// Determines if the given field is empty, and if it is empty, it
- /// assigns the correct ErrorCondition to the validation package's ValidationResult
- /// and adds an error message to the errors collection.
- /// Returns true if val is empty and should not be validated further. Returns false
- /// if value is not empty and validation should continue. Validation methods should
- /// return immediately with no further action if this method returns true.
- ///
- private static bool isEmpty( ValidationErrorHandler errorHandler, object valueAsObject, bool allowEmpty ) {
- var isEmpty = valueAsObject.ObjectToString( true ).Trim().Length == 0;
-
- if( !allowEmpty && isEmpty )
- errorHandler.SetValidationResult( ValidationResult.Empty() );
-
- return isEmpty;
- }
-}
\ No newline at end of file
+///
+/// Validation methods for .
+///
+[ PublicAPI ]
+public static partial class ValidatorExtensions;
\ No newline at end of file
diff --git a/Tewl/InputValidation/ZipCode.cs b/Tewl/InputValidation/ZipCode.cs
index 16dd49c..ab4cacd 100644
--- a/Tewl/InputValidation/ZipCode.cs
+++ b/Tewl/InputValidation/ZipCode.cs
@@ -1,7 +1,4 @@
-using System;
-using System.Text.RegularExpressions;
-using JetBrains.Annotations;
-using Tewl.Tools;
+using System.Text.RegularExpressions;
namespace Tewl.InputValidation {
///
@@ -27,31 +24,32 @@ public class ZipCode {
///
public string FullZipCode => StringTools.ConcatenateWithDelimiter( "-", Zip, Plus4 );
- internal ZipCode() { }
+ internal ZipCode() {}
- internal static ZipCode CreateUsZipCode( ValidationErrorHandler errorHandler, string entireZipCode ) {
- var match = Regex.Match( entireZipCode, usPattern );
- if( match.Success )
- return getZipCodeFromValidUsMatch( match );
+ internal static ValidationError? CreateUsZipCode( Action valueSetter, string trimmedInput ) {
+ var match = Regex.Match( trimmedInput, usPattern );
+ if( match.Success ) {
+ valueSetter( getZipCodeFromValidUsMatch( match ) );
+ return null;
+ }
- return getZipCodeForFailure( errorHandler );
+ return ValidationError.Invalid();
}
- internal static ZipCode CreateUsOrCanadianZipCode( ValidationErrorHandler errorHandler, string entireZipCode ) {
- var match = Regex.Match( entireZipCode, usPattern );
- if( match.Success )
- return getZipCodeFromValidUsMatch( match );
- if( ( match = Regex.Match( entireZipCode, canadianPattern, RegexOptions.IgnoreCase ) ).Success )
- return new ZipCode { Zip = match.Groups[ "caZip" ].Value };
-
- return getZipCodeForFailure( errorHandler );
- }
-
- private static ZipCode getZipCodeForFailure( ValidationErrorHandler errorHandler ) {
- errorHandler.SetValidationResult( ValidationResult.Invalid() );
- return new ZipCode();
+ internal static ValidationError? CreateUsOrCanadianZipCode( Action valueSetter, string trimmedInput ) {
+ var match = Regex.Match( trimmedInput, usPattern );
+ if( match.Success ) {
+ valueSetter( getZipCodeFromValidUsMatch( match ) );
+ return null;
+ }
+ if( ( match = Regex.Match( trimmedInput, canadianPattern, RegexOptions.IgnoreCase ) ).Success ) {
+ valueSetter( new ZipCode { Zip = match.Groups[ "caZip" ].Value } );
+ return null;
+ }
+
+ return ValidationError.Invalid();
}
- private static ZipCode getZipCodeFromValidUsMatch( Match match ) => new ZipCode { Zip = match.Groups[ "zip" ].Value, Plus4 = match.Groups[ "plus4" ].Value };
+ private static ZipCode getZipCodeFromValidUsMatch( Match match ) => new() { Zip = match.Groups[ "zip" ].Value, Plus4 = match.Groups[ "plus4" ].Value };
}
}
\ No newline at end of file
diff --git a/Tewl/PatternString.cs b/Tewl/PatternString.cs
new file mode 100644
index 0000000..1c5fdfd
--- /dev/null
+++ b/Tewl/PatternString.cs
@@ -0,0 +1,43 @@
+using System.Text.RegularExpressions;
+
+namespace Tewl;
+
+///
+/// A search pattern string.
+///
+[ PublicAPI ]
+public sealed class PatternString: IEquatable {
+ ///
+ /// Gets the pattern.
+ ///
+ public string Pattern { get; }
+
+ ///
+ /// Creates a search pattern string.
+ ///
+ public PatternString( string pattern ) {
+ Pattern = pattern;
+ }
+
+ ///
+ /// Returns true if this pattern matches the specified text. Always returns true if the pattern is empty. Ignores case.
+ /// Examples: Given the text “Example”, this method returns true for the patterns “ex” and “example”.
+ ///
+ ///
+ /// Prevents the pattern “ge” from matching “General Mills”. Only “general mills” will match.
+ public bool Matches( string text, bool requireFullMatch = false ) {
+ if( Pattern.Length == 0 )
+ return true;
+
+ var pat = Regex.Escape( Pattern );
+ if( requireFullMatch )
+ pat = $"^{pat}$";
+ return Regex.IsMatch( text, pat, RegexOptions.IgnoreCase );
+ }
+
+#pragma warning disable CS1591
+ public override bool Equals( object? obj ) => Equals( obj as PatternString );
+ public bool Equals( PatternString? other ) => other is not null && Pattern.Equals( other.Pattern, StringComparison.Ordinal );
+ public override int GetHashCode() => Pattern.GetHashCode();
+#pragma warning restore CS1591
+}
\ No newline at end of file
diff --git a/Tewl/RateLimiter.cs b/Tewl/RateLimiter.cs
new file mode 100644
index 0000000..5955259
--- /dev/null
+++ b/Tewl/RateLimiter.cs
@@ -0,0 +1,73 @@
+using System.Threading;
+using NodaTime;
+
+namespace Tewl;
+
+///
+/// A leaky-bucket rate limiter. See https://en.wikipedia.org/wiki/Leaky_bucket.
+///
+[ PublicAPI ]
+public class RateLimiter {
+ ///
+ /// A method executed if the rate limit has been exceeded.
+ ///
+ /// The time remaining until an action can execute within the rate limit.
+ public delegate void LimitExceededMethod( Duration waitDuration );
+
+ private readonly Duration interval;
+ private readonly uint maxBurstSize;
+ private readonly Func timeGetter;
+ private readonly Lock actionLock = new();
+
+ private uint count;
+ private Instant lastDecrementTime;
+
+ ///
+ /// Creates a rate limiter.
+ ///
+ ///
+ ///
+ /// A function that gets the time instant for an action.
+ public RateLimiter( Duration interval, uint maxBurstSize, Func timeGetter ) {
+ this.interval = interval;
+ this.maxBurstSize = maxBurstSize;
+ this.timeGetter = timeGetter;
+
+ count = 0;
+ lastDecrementTime = timeGetter();
+ }
+
+ ///
+ /// Executes one of the specified actions based on the state of this rate limiter. This method is thread safe, but the action executes after the lock is
+ /// released.
+ ///
+ ///
+ ///
+ /// The method executed if the rate limit has been exceeded.
+ public void RequestAction( Action actionMethod, Action atLimitMethod, LimitExceededMethod limitExceededMethod ) {
+ Action method;
+ lock( actionLock ) {
+ // Decrement the count as time passes.
+ var currentTime = timeGetter();
+ if( currentTime > lastDecrementTime ) {
+ uint intervalsPassed;
+ checked {
+ intervalsPassed = (uint)Math.Floor( ( currentTime - lastDecrementTime ) / interval );
+ }
+ count = intervalsPassed < count ? count - intervalsPassed : 0;
+ lastDecrementTime += interval * intervalsPassed;
+ }
+
+ if( count < maxBurstSize ) {
+ count += 1;
+ method = count < maxBurstSize ? actionMethod : atLimitMethod;
+ }
+ else {
+ var remainingTime = lastDecrementTime + interval - currentTime;
+ method = () => limitExceededMethod( remainingTime );
+ }
+ }
+
+ method();
+ }
+}
\ No newline at end of file
diff --git a/Tewl/Tewl.csproj b/Tewl/Tewl.csproj
index bbf486c..ad71200 100644
--- a/Tewl/Tewl.csproj
+++ b/Tewl/Tewl.csproj
@@ -9,6 +9,7 @@
+
diff --git a/Tewl/Tewl.csproj.DotSettings b/Tewl/Tewl.csproj.DotSettings
new file mode 100644
index 0000000..bd89b5d
--- /dev/null
+++ b/Tewl/Tewl.csproj.DotSettings
@@ -0,0 +1,2 @@
+
+ True
\ No newline at end of file
diff --git a/Tewl/Tools/CollectionTools.cs b/Tewl/Tools/CollectionTools.cs
index d609620..aa43680 100644
--- a/Tewl/Tools/CollectionTools.cs
+++ b/Tewl/Tools/CollectionTools.cs
@@ -1,4 +1,4 @@
-using JetBrains.Annotations;
+using JetBrains.Annotations;
namespace Tewl.Tools;
diff --git a/Tewl/Tools/DateTimeTools.cs b/Tewl/Tools/DateTimeTools.cs
index 61ab8c4..ca8834f 100644
--- a/Tewl/Tools/DateTimeTools.cs
+++ b/Tewl/Tools/DateTimeTools.cs
@@ -78,6 +78,7 @@ public static string ToHourAndMinuteString( this DateTime? dateTime, string stri
///
/// Returns the date that the given week starts on.
///
+ [ Obsolete( "Please use LocalDate.WeekBeginDate, which does not assume a first day of the week." ) ]
public static DateTime WeekBeginDate( this DateTime dateTime ) => dateTime.AddDays( -(int)dateTime.DayOfWeek ).Date;
///
diff --git a/Tewl/Tools/DurationTools.cs b/Tewl/Tools/DurationTools.cs
new file mode 100644
index 0000000..03e9476
--- /dev/null
+++ b/Tewl/Tools/DurationTools.cs
@@ -0,0 +1,17 @@
+using Humanizer;
+using Humanizer.Localisation;
+using NodaTime;
+
+namespace Tewl.Tools;
+
+///
+/// Duration extensions.
+///
+[ PublicAPI ]
+public static class DurationTools {
+ ///
+ /// Returns a phrase representing this duration in seconds (rounded up), e.g. 93 seconds.
+ ///
+ public static string ToSecondsPhrase( this Duration duration ) =>
+ duration.Minus( Duration.Epsilon ).Plus( Duration.FromSeconds( 1 ) ).ToTimeSpan().Humanize( minUnit: TimeUnit.Second, maxUnit: TimeUnit.Second );
+}
\ No newline at end of file
diff --git a/Tewl/Tools/EnumTools.cs b/Tewl/Tools/EnumTools.cs
index 6663d4b..3c0629c 100644
--- a/Tewl/Tools/EnumTools.cs
+++ b/Tewl/Tools/EnumTools.cs
@@ -1,34 +1,31 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using JetBrains.Annotations;
+namespace Tewl.Tools;
-namespace Tewl.Tools {
+///
+/// Extension methods and other static tools pertaining to Enum types.
+///
+[ PublicAPI ]
+public static class EnumTools {
///
- /// Extension methods and other static tools pertaining to Enum types.
+ /// Converts this string to a given Enum value. Case sensitive.
///
- [ PublicAPI ]
- public static class EnumTools {
- ///
- /// Converts this string to a given Enum value. Case sensitive.
- /// This method does not enforce valid Enum values.
- ///
- /// C# doesn't allow constraining the value to an Enum
- public static T ToEnum( this string s ) => (T)Enum.Parse( typeof( T ), s );
+ public static T ToEnum( this string s ) where T: struct, Enum {
+ var e = (T)Enum.Parse( typeof( T ), s );
+ if( !Enum.IsDefined( e ) )
+ throw new ArgumentException( $"{s} does not exist in the enumeration" );
+ return e;
+ }
- ///
- /// Gets the values of the specified enumeration type.
- ///
- // C# doesn't allow constraining the type to an Enum.
- public static IEnumerable GetValues() => Enum.GetValues( typeof( T ) ).Cast();
+ ///
+ /// Gets the values of the specified enumeration type.
+ ///
+ public static IEnumerable GetValues() where T: struct, Enum => Enum.GetValues( typeof( T ) ).Cast();
- ///
- /// Looks for and if available, returns its value.
- /// Otherwise, returns the name of the enum value after applying .
- ///
- public static string ToEnglish( this Enum e ) {
- var name = e.GetAttribute();
- return name != null ? name.English : Enum.GetName( e.GetType(), e ).CamelToEnglish();
- }
+ ///
+ /// Looks for and if available, returns its value.
+ /// Otherwise, returns the name of the enum value after applying .
+ ///
+ public static string ToEnglish( this Enum e ) {
+ var name = e.GetAttribute();
+ return name != null ? name.English : e.ToString().CamelToEnglish();
}
}
\ No newline at end of file
diff --git a/Tewl/Tools/LocalDateTools.cs b/Tewl/Tools/LocalDateTools.cs
new file mode 100644
index 0000000..88e3d2b
--- /dev/null
+++ b/Tewl/Tools/LocalDateTools.cs
@@ -0,0 +1,60 @@
+using NodaTime;
+
+namespace Tewl.Tools;
+
+///
+/// LocalDate extensions.
+///
+[ PublicAPI ]
+public static class LocalDateTools {
+ ///
+ /// Returns whether this date is between (inclusive) the specified dates. Passing null for either of the two dates is considered to be infinity in that
+ /// direction. Therefore, passing null for both dates will always return true.
+ ///
+ public static bool IsBetween( this LocalDate date, LocalDate? begin, LocalDate? end ) =>
+ new DateInterval( begin ?? LocalDate.MinIsoValue, end ?? LocalDate.MaxIsoValue ).Contains( date );
+
+ ///
+ /// Returns whether this date is between (inclusive) the specified dates. Passing null for either of the two dates is considered to be infinity in that
+ /// direction. Therefore, passing null for both dates will always return true.
+ ///
+ [ Obsolete( "Please use IsBetween instead, which has exactly the same behavior." ) ]
+ public static bool IsBetweenDates( this LocalDate date, LocalDate? begin, LocalDate? end ) => date.IsBetween( begin, end );
+
+ ///
+ /// Returns the first date in this date’s month.
+ ///
+ public static LocalDate MonthBeginDate( this LocalDate date ) => date.ToYearMonth().OnDayOfMonth( 1 );
+
+ ///
+ /// Returns the first date in this date’s week.
+ ///
+ public static LocalDate WeekBeginDate( this LocalDate date, IsoDayOfWeek firstDayOfWeek ) => date.PlusDays( 1 ).Previous( firstDayOfWeek );
+
+ ///
+ /// Formats this date in "day month year" style, e.g. 5 Apr 2008. Returns stringIfNull if the date is null.
+ ///
+ public static string ToDayMonthYearString( this LocalDate? date, string stringIfNull, bool useLeadingZero, bool includeDayOfWeek = false ) =>
+ date.HasValue ? date.Value.ToDayMonthYearString( useLeadingZero, includeDayOfWeek: includeDayOfWeek ) : stringIfNull;
+
+ ///
+ /// Formats this date in "day month year" style, e.g. 5 Apr 2008.
+ ///
+ public static string ToDayMonthYearString( this LocalDate date, bool useLeadingZero, bool includeDayOfWeek = false ) =>
+ date.ToDateTimeUnspecified().ToDayMonthYearString( useLeadingZero, includeDayOfWeek: includeDayOfWeek );
+
+ ///
+ /// Formats this date in "01/01/2001" style. Returns stringIfNull if the date is null.
+ ///
+ public static string ToMonthDayYearString( this LocalDate? date, string stringIfNull ) => date.HasValue ? date.Value.ToMonthDayYearString() : stringIfNull;
+
+ ///
+ /// Formats this date in "01/01/2001" style.
+ ///
+ public static string ToMonthDayYearString( this LocalDate date ) => date.ToDateTimeUnspecified().ToMonthDayYearString();
+
+ ///
+ /// Formats this date in "month year" style, e.g. April 2008.
+ ///
+ public static string ToMonthYearString( this LocalDate date ) => new DateTimeOffset( date.ToDateTimeUnspecified() ).ToMonthYearString();
+}
\ No newline at end of file
diff --git a/Tewl/Tools/ReflectionTools.cs b/Tewl/Tools/ReflectionTools.cs
index 4e9cb02..ef73dfb 100644
--- a/Tewl/Tools/ReflectionTools.cs
+++ b/Tewl/Tools/ReflectionTools.cs
@@ -1,32 +1,29 @@
-using System;
-using System.Linq;
-using System.Linq.Expressions;
-using JetBrains.Annotations;
+using System.Linq.Expressions;
-namespace Tewl.Tools {
+namespace Tewl.Tools;
+
+///
+/// Reflection-based utilities.
+///
+[ PublicAPI ]
+public static class ReflectionTools {
///
- /// Reflection-based utilities.
+ /// Returns the name of the property in the provided expression.
///
- [ PublicAPI ]
- public static class ReflectionTools {
- ///
- /// Returns the name of the property in the provided expression.
- ///
- public static string GetPropertyName( Expression> propertyExpression ) => ( (MemberExpression)propertyExpression.Body ).Member.Name;
-
- ///
- /// Returns the attribute for the given object if it's available. Otherwise, returns null.
- /// Improve performance by declaring your attribute as sealed.
- ///
- public static T GetAttribute( this object e ) where T: Attribute {
- var memberInfo = e.GetType().GetMember( e.ToString() );
- if( memberInfo.Any() ) {
- var attributes = memberInfo[ 0 ].GetCustomAttributes( typeof( T ), false );
- if( attributes.Any() )
- return attributes[ 0 ] as T;
- }
+ public static string GetPropertyName( Expression> propertyExpression ) => ( (MemberExpression)propertyExpression.Body ).Member.Name;
- return null;
+ ///
+ /// Returns the attribute for the given object if it's available. Otherwise, returns null.
+ /// Improve performance by declaring your attribute as sealed.
+ ///
+ public static T? GetAttribute( this object e ) where T: Attribute {
+ var memberInfo = e.GetType().GetMember( e.ToString()! );
+ if( memberInfo.Any() ) {
+ var attributes = memberInfo[ 0 ].GetCustomAttributes( typeof( T ), false );
+ if( attributes.Any() )
+ return attributes[ 0 ] as T;
}
+
+ return null;
}
}
\ No newline at end of file
diff --git a/Tewl/Tools/StringTools.cs b/Tewl/Tools/StringTools.cs
index e5c7e13..e1b7851 100644
--- a/Tewl/Tools/StringTools.cs
+++ b/Tewl/Tools/StringTools.cs
@@ -1,4 +1,5 @@
-using System.Globalization;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using AvsAnLib;
@@ -249,7 +250,7 @@ public static string OracleToEnglish( this string text ) =>
/// unless you understand its
/// appropriate and inappropriate uses as documented in coding standards.
///
- public static bool IsNullOrWhiteSpace( this string text ) => text == null || text.IsWhitespace();
+ public static bool IsNullOrWhiteSpace( [ NotNullWhen( false ) ] this string? text ) => text is null || text.IsWhitespace();
///
/// Returns true if the string is empty or made up entirely of whitespace characters (as defined by the Trim method).
@@ -444,6 +445,8 @@ public static string GetEnglishListPhrase( IEnumerable items, bool useSe
/// If true, the pattern "ge" will match "General Mills". Otherwise, only "general mills"
/// will match.
///
+ [ Obsolete(
+ "Please use PatternString.Matches instead. If you were relying on ignoreSurroundingWhitespace, please call Trim yourself on the pattern and/or the text as needed. Do not pass null for text." ) ]
public static bool IsLike( this string s, string pattern, bool ignoreSurroundingWhitespace = true, bool allowPartialMatches = true ) {
s ??= "";
if( ignoreSurroundingWhitespace ) {
@@ -467,7 +470,7 @@ public static bool IsLike( this string s, string pattern, bool ignoreSurrounding
///
public static bool MatchesSearch( this string text, string searchTerms ) {
var terms = searchTerms.Separate();
- return terms.All( term => text.IsLike( term, ignoreSurroundingWhitespace: false ) );
+ return terms.All( term => new PatternString( term ).Matches( text ) );
}
///
diff --git a/Tewl/TrustedHtmlString.cs b/Tewl/TrustedHtmlString.cs
new file mode 100644
index 0000000..262cd18
--- /dev/null
+++ b/Tewl/TrustedHtmlString.cs
@@ -0,0 +1,20 @@
+namespace Tewl;
+
+///
+/// A trusted HTML string.
+///
+[ PublicAPI ]
+public sealed class TrustedHtmlString {
+ ///
+ /// Gets the HTML.
+ ///
+ public string Html { get; }
+
+ ///
+ /// Creates a trusted HTML string.
+ ///
+ /// Do not pass null.
+ public TrustedHtmlString( string html ) {
+ Html = html;
+ }
+}
\ No newline at end of file
diff --git a/TewlTester/Program.cs b/TewlTester/Program.cs
deleted file mode 100644
index 7a02e05..0000000
--- a/TewlTester/Program.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using Tewl.InputValidation;
-using Tewl.IO;
-using Tewl.IO.TabularDataParsing;
-
-namespace TewlTester {
- class Program {
- static void Main( string[] args ) {
- testExcelWriting();
- testCsvWriting();
- testCsv();
- testTabDelimitedWriting();
- testXls();
- }
-
- private static void testExcelWriting() {
- var excelFile = new ExcelFileWriter();
- excelFile.DefaultWorksheet.AddHeaderToWorksheet( "ID", "Name", "Date", "Email", "Website" );
- excelFile.DefaultWorksheet.AddRowToWorksheet( "123", "Greg", "1/1/2012", "greg.smalter@gmail.com", "https://www.google.com" );
- excelFile.DefaultWorksheet.AddRowToWorksheet( "321", "", "12/19/2020", "", "https://microsoft.com" );
-
- using( var stream = File.OpenWrite( "tewlTestTabularWrite.xlsx" ) )
- excelFile.SaveToStream( stream );
- }
-
- private static void testCsvWriting() {
- var csvFile = new CsvFileWriter();
-
- using( var stream = new StreamWriter( File.OpenWrite( "tewlTestTabularWrite.csv" ) ) )
- writeData( csvFile, stream );
- }
-
- private static void testTabDelimitedWriting() {
- var csvFile = new TabDelimitedFileWriter();
-
- using( var stream = new StreamWriter( File.OpenWrite( "tewlTestTabularWrite.txt" ) ) )
- writeData( csvFile, stream );
- }
-
- // It's sort of a failure that the Excel writer cannot be passed here. But between there being more than one worksheet and other problems, it's hard to
- // have it implement the same interface.
- private static void writeData( TextBasedTabularDataFileWriter writer, TextWriter stream ) {
- writer.AddValuesToLine( "ID", "Name", "Date", "Email", "Website" );
- writer.WriteCurrentLineToFile( stream );
- writer.AddValuesToLine("123", "Greg", "1/1/2012", "greg.smalter@gmail.com", "https://www.google.com" );
- writer.WriteCurrentLineToFile( stream );
- writer.AddValuesToLine("123", "Greg", "1/1/2012", "greg.smalter@gmail.com", "https://www.google.com" );
- writer.WriteCurrentLineToFile( stream );
- }
-
- private static void testCsv() {
- var csvParser = TabularDataParser.CreateForCsvFile( @"..\..\..\TestFiles\TewlTestBook.csv", true );
- var validationErrors = new List();
-
- csvParser.ParseAndProcessAllLines( importThing, validationErrors );
-
- Console.WriteLine( $"CSV test: {csvParser.RowsWithoutValidationErrors} rows imported without error." );
- }
-
- private static void testXls() {
- var xlsParser = TabularDataParser.CreateForExcelFile( @"..\..\..\TestFiles\TewlTestBook.xlsx" );
- var validationErrors = new List();
-
- xlsParser.ParseAndProcessAllLines( importThing, validationErrors );
-
- Console.WriteLine( $"Excel test: {xlsParser.RowsWithoutValidationErrors} rows imported without error." );
- }
-
- private static void importThing( Validator validator, ParsedLine line ) {
- var value = line["dATe"];
- var email = line[ "email" ];
- var website = line[ "website" ];
- Console.WriteLine( line.LineNumber + ": Date: " + value + $", Email: {email}, Website: {website}" );
- }
- }
-}
\ No newline at end of file