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