diff --git a/.gitignore b/.gitignore index 04096015..c2977fea 100644 --- a/.gitignore +++ b/.gitignore @@ -88,4 +88,5 @@ Desktop.ini docs/Gemfile.lock docs/.sass-cache/ docs/_site/ -_site/ \ No newline at end of file +_site/ +appsettings.local.json \ No newline at end of file diff --git a/DBScripts/Oracle.sql b/DBScripts/Oracle.sql new file mode 100644 index 00000000..1aad75c3 --- /dev/null +++ b/DBScripts/Oracle.sql @@ -0,0 +1,109 @@ +declare +name_exists_exception exception; +index_exists_exception exception; +column_exists_exception exception; +column_not_exists_exception exception; +pragma exception_init( name_exists_exception, -955 ); +pragma exception_init( index_exists_exception, -1408 ); +pragma exception_init( column_exists_exception, -1430 ); +pragma exception_init( column_not_exists_exception, -904 ); +begin + begin + execute immediate 'CREATE TABLE Exceptions( + Id number generated always as Identity(start with 1 increment by 1), + GUID varchar2(36) NOT NULL, + ApplicationName varchar2(50) NOT NULL, + MachineName varchar2(50) NOT NULL, + CreationDate timestamp NOT NULL, + "TYPE" varchar2(100) NOT NULL, + IsProtected number(1) default 0 NOT NULL, + Host varchar2(100) NULL, + Url varchar2(500) NULL, + HTTPMethod varchar2(10) NULL, + IPAddress varchar2(40) NULL, + "SOURCE" varchar2(100) NULL, + Message varchar2(1000) NULL, + Detail Nclob NULL, + StatusCode number NULL, + DeletionDate timestamp NULL, + FullJson Nclob NULL, + ErrorHash number NULL, + DuplicateCount number default 1 NOT NULL, + LastLogDate timestamp NULL, + Category varchar2(100) NULL + )'; + exception + when name_exists_exception then + null; + end; + + begin + execute immediate('CREATE INDEX IX_Exceptions_One ON Exceptions(GUID, ApplicationName, DeletionDate, CreationDate DESC)'); + exception + when index_exists_exception then + null; + when name_exists_exception then + null; + end; + + begin + execute immediate('CREATE INDEX IX_Exceptions_Two ON Exceptions(ErrorHash, ApplicationName, CreationDate DESC, DeletionDate)'); + exception + when index_exists_exception then + null; + when name_exists_exception then + null; + end; + + --Begin V2 Schema changes + + begin + execute immediate('ALTER TABLE Exceptions ADD LastLogDate timestamp NULL'); + exception + when column_exists_exception then + null; + end; + + begin + execute immediate('ALTER TABLE Exceptions ADD Category varchar2(100) NULL'); + exception + when column_exists_exception then + null; + end; + + begin + execute immediate('ALTER TABLE Exceptions DROP COLUMN SQL'); + exception + when column_not_exists_exception then + null; + end; +end; +/ + +create or replace + function ExceptionsLogUpdate(p_DuplicateCount in number, + p_CreationDate in timestamp, + p_ErrorHash in number, + p_ApplicationName in varchar2, + p_MinDate in timestamp) + return varchar2 is + pragma autonomous_transaction; + begin + declare + v_guid varchar2(36); + begin + + update Exceptions + set DuplicateCount = DuplicateCount + p_DuplicateCount, + LastLogDate = (Case When LastLogDate Is Null Or p_CreationDate > LastLogDate Then p_CreationDate Else LastLogDate End) + where ErrorHash = p_ErrorHash + and ApplicationName = p_ApplicationName + and DeletionDate Is Null + and CreationDate >= p_MinDate + and rownum = 1 + returning GUID into v_guid; + commit; + + return v_guid; + end; + end; \ No newline at end of file diff --git a/StackExchange.Exceptional.sln b/StackExchange.Exceptional.sln index 06e8cefe..676c68bf 100644 --- a/StackExchange.Exceptional.sln +++ b/StackExchange.Exceptional.sln @@ -35,6 +35,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DBScripts", "DBScripts", "{7669A017-BBDA-45B0-89B1-A9E0F2FD0BF8}" ProjectSection(SolutionItems) = preProject DBScripts\MySQL.sql = DBScripts\MySQL.sql + DBScripts\Oracle.sql = DBScripts\Oracle.sql DBScripts\PostgreSql.sql = DBScripts\PostgreSql.sql DBScripts\SqlServer.sql = DBScripts\SqlServer.sql EndProjectSection @@ -56,7 +57,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StackExchange.Exceptional.P EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StackExchange.Exceptional.MongoDB", "src\StackExchange.Exceptional.MongoDB\StackExchange.Exceptional.MongoDB.csproj", "{8CFA59A5-5180-4466-A32E-507F3D541163}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples.ConsoleNetCore", "samples\Samples.ConsoleNetCore\Samples.ConsoleNetCore.csproj", "{6CE269E1-6DC9-43A4-B6B8-683CE8A19E6A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Samples.ConsoleNetCore", "samples\Samples.ConsoleNetCore\Samples.ConsoleNetCore.csproj", "{6CE269E1-6DC9-43A4-B6B8-683CE8A19E6A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StackExchange.Exceptional.Oracle", "src\StackExchange.Exceptional.Oracle\StackExchange.Exceptional.Oracle.csproj", "{A51A932D-67DB-4A1C-89D2-886DE2258E58}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -112,6 +115,10 @@ Global {6CE269E1-6DC9-43A4-B6B8-683CE8A19E6A}.Debug|Any CPU.Build.0 = Debug|Any CPU {6CE269E1-6DC9-43A4-B6B8-683CE8A19E6A}.Release|Any CPU.ActiveCfg = Release|Any CPU {6CE269E1-6DC9-43A4-B6B8-683CE8A19E6A}.Release|Any CPU.Build.0 = Release|Any CPU + {A51A932D-67DB-4A1C-89D2-886DE2258E58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A51A932D-67DB-4A1C-89D2-886DE2258E58}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A51A932D-67DB-4A1C-89D2-886DE2258E58}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A51A932D-67DB-4A1C-89D2-886DE2258E58}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -129,6 +136,7 @@ Global {0A412433-C2CE-4EBE-BB5A-C3E48983E941} = {8B91532F-A112-4F73-80BD-A95B7359BBC3} {8CFA59A5-5180-4466-A32E-507F3D541163} = {8B91532F-A112-4F73-80BD-A95B7359BBC3} {6CE269E1-6DC9-43A4-B6B8-683CE8A19E6A} = {001E5AA4-42C8-4AC3-B14A-AF1DFA02E9FB} + {A51A932D-67DB-4A1C-89D2-886DE2258E58} = {8B91532F-A112-4F73-80BD-A95B7359BBC3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7F066A86-14D8-4A2C-848A-D4371BB704FA} diff --git a/src/StackExchange.Exceptional.Oracle/OracleClobParameter.cs b/src/StackExchange.Exceptional.Oracle/OracleClobParameter.cs new file mode 100644 index 00000000..2b47c7c7 --- /dev/null +++ b/src/StackExchange.Exceptional.Oracle/OracleClobParameter.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Dapper; +using Oracle.ManagedDataAccess.Client; +using Oracle.ManagedDataAccess.Types; + +namespace StackExchange.Exceptional.Stores +{ + /// + /// Needed because long strings can not be passed. + /// + /// + /// https://github.com/StackExchange/Dapper/issues/142 + /// + internal class OracleClobParameter : SqlMapper.ICustomQueryParameter + { + private readonly string value; + + public OracleClobParameter(string value) + { + this.value = value; + } + + public void AddParameter(IDbCommand command, string name) + { + // accesing the connection in open state. + var connection = command.Connection as OracleConnection; + + connection.StateChange += (object sender, StateChangeEventArgs e) => + { + if (e.CurrentState != ConnectionState.Open) + return; + + var clob = new OracleClob(connection); + + // It should be Unicode oracle throws an exception when + // the length is not even. + var bytes = System.Text.Encoding.Unicode.GetBytes(value); + var length = System.Text.Encoding.Unicode.GetByteCount(value); + + int pos = 0; + int chunkSize = 1024; // Oracle does not allow large chunks. + + while (pos < length) + { + chunkSize = chunkSize > (length - pos) ? chunkSize = length - pos : chunkSize; + clob.Write(bytes, pos, chunkSize); + pos += chunkSize; + } + + var param = new OracleParameter(name, OracleDbType.Clob); + param.Value = clob; + + command.Parameters.Add(param); + }; + } + } +} diff --git a/src/StackExchange.Exceptional.Oracle/OracleErrorStore.cs b/src/StackExchange.Exceptional.Oracle/OracleErrorStore.cs new file mode 100644 index 00000000..aacb6c05 --- /dev/null +++ b/src/StackExchange.Exceptional.Oracle/OracleErrorStore.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Dapper; +using StackExchange.Exceptional.Internal; +using Oracle.ManagedDataAccess.Client; + +namespace StackExchange.Exceptional.Stores +{ + /// + /// An implementation that uses Oracle as its backing store. + /// + public sealed class OracleErrorStore : ErrorStore + { + /// + /// Name for this error store. + /// + public override string Name => "Oracle Error Store"; + + private readonly string _tableName; + private readonly int _displayCount; + private readonly string _connectionString; + + /// + /// The maximum count of errors to show. + /// + public const int MaximumDisplayCount = 500; + + /// + /// Creates a new instance of with the specified connection string. + /// The default table name is "Exceptions". + /// + /// The database connection string to use. + /// The application name to use when logging. + public OracleErrorStore(string connectionString, string applicationName) + : this(new ErrorStoreSettings() + { + ApplicationName = applicationName, + ConnectionString = connectionString + }) + { } + + /// + /// Creates a new instance of with the given configuration. + /// The default table name is "Exceptions". + /// + /// The for this store. + public OracleErrorStore(ErrorStoreSettings settings) : base(settings) + { + _displayCount = Math.Min(settings.Size, MaximumDisplayCount); + _connectionString = settings.ConnectionString; + _tableName = settings.TableName ?? "Exceptions"; + + if (_connectionString.IsNullOrEmpty()) + throw new ArgumentOutOfRangeException(nameof(settings), "A connection string or connection string name must be specified when using a Oracle error store"); + + this.ChangeGuidHandler(); + } + + private string _sqlProtectError; + private string SqlProtectError => _sqlProtectError ?? (_sqlProtectError = $@" +Update {_tableName} + Set IsProtected = 1, DeletionDate = Null + Where lower(GUID) = :guid"); + + /// + /// Protects an error from deletion, by making IsProtected = 1 in the database. + /// + /// The GUID of the error to protect. + /// true if the error was found and protected, false otherwise. + protected override async Task ProtectErrorAsync(Guid guid) + { + using (var c = GetConnection()) + { + return await c.ExecuteAsync(SqlProtectError, new { guid = this.GuidToSting(guid) }).ConfigureAwait(false) > 0; + } + } + + private string _sqlProtectErrors; + private string SqlProtectErrors => _sqlProtectErrors ?? (_sqlProtectErrors = $@" +Update {_tableName} + Set IsProtected = 1, DeletionDate = Null + Where lower(GUID) In :guids"); + + /// + /// Protects errors from deletion, by making IsProtected = 1 in the database. + /// + /// The GUIDs of the errors to protect. + /// true if the errors were found and protected, false otherwise. + protected override async Task ProtectErrorsAsync(IEnumerable guids) + { + using (var c = GetConnection()) + { + return await c.ExecuteAsync(SqlProtectErrors, new { guids = this.GuidsToStings(guids) }).ConfigureAwait(false) > 0; + } + } + + private string _sqlDeleteError; + private string SqlDeleteError => _sqlDeleteError ?? (_sqlDeleteError = $@" +Update {_tableName} + Set DeletionDate = CURRENT_TIMESTAMP + Where lower(GUID) = :guid + And DeletionDate Is Null"); + + /// + /// Deletes an error, by setting DeletionDate = UTC_DATE() in SQL. + /// + /// The GUID of the error to delete. + /// true if the error was found and deleted, false otherwise. + protected override async Task DeleteErrorAsync(Guid guid) + { + using (var c = GetConnection()) + { + return await c.ExecuteAsync(SqlDeleteError, new { guid = this.GuidToSting(guid), ApplicationName }).ConfigureAwait(false) > 0; + } + } + + private string _sqlDeleteErrors; + private string SqlDeleteErrors => _sqlDeleteErrors ?? (_sqlDeleteErrors = $@" +Update {_tableName} + Set DeletionDate = CURRENT_TIMESTAMP + Where lower(GUID) In :guids + And DeletionDate Is Null"); + + /// + /// Deletes errors, by setting DeletionDate = UTC_DATE() in SQL. + /// + /// The GUIDs of the errors to delete. + /// true if the errors were found and deleted, false otherwise. + protected override async Task DeleteErrorsAsync(IEnumerable guids) + { + using (var c = GetConnection()) + { + return await c.ExecuteAsync(SqlDeleteErrors, new { guids = this.GuidsToStings(guids) }).ConfigureAwait(false) > 0; + } + } + + private string _sqlHardDeleteErrors; + private string SqlHardDeleteErrors => _sqlHardDeleteErrors ?? (_sqlHardDeleteErrors = $@" +Delete From {_tableName} + Where lower(GUID) = :guid + And ApplicationName = :ApplicationName"); + + /// + /// Hard deletes an error, actually deletes the row from SQL rather than setting . + /// This is used to cleanup when testing the error store when attempting to come out of retry/failover mode after losing connection to SQL. + /// + /// The GUID of the error to hard delete. + /// true if the error was found and deleted, false otherwise. + protected override async Task HardDeleteErrorAsync(Guid guid) + { + using (var c = GetConnection()) + { + return await c.ExecuteAsync(SqlHardDeleteErrors, new { guid = this.GuidToSting(guid), ApplicationName }).ConfigureAwait(false) > 0; + } + } + + private string _sqlDeleteAllErrors; + private string SqlDeleteAllErrors => _sqlDeleteAllErrors ?? (_sqlDeleteAllErrors = $@" +Update {_tableName} + Set DeletionDate = CURRENT_TIMESTAMP + Where DeletionDate Is Null + And IsProtected = 0 + And ApplicationName = :ApplicationName"); + + /// + /// Deleted all errors in the log, by setting = UTC_DATE() in SQL. + /// + /// The name of the application to delete all errors for. + /// true if any errors were deleted, false otherwise. + protected override async Task DeleteAllErrorsAsync(string applicationName = null) + { + using (var c = GetConnection()) + { + return await c.ExecuteAsync(SqlDeleteAllErrors, new { ApplicationName = applicationName ?? ApplicationName }).ConfigureAwait(false) > 0; + } + } + + private string _sqlLogUpdate; + private string SqlLogUpdate => _sqlLogUpdate ?? (_sqlLogUpdate = $@"Select {_tableName}LogUpdate(:DuplicateCount, :CreationDate, :ErrorHash, :ApplicationName, :minDate) from Dual"); + + private string _sqlLogInsert; + private string SqlLogInsert => _sqlLogInsert ?? (_sqlLogInsert = $@" +Insert Into {_tableName} (GUID, ApplicationName, Category, MachineName, CreationDate, Type, IsProtected, Host, Url, HTTPMethod, IPAddress, Source, Message, Detail, StatusCode, FullJson, ErrorHash, DuplicateCount, LastLogDate) +Values (:GUID, :ApplicationName, :Category, :MachineName, :CreationDate, :Type, :IsProtected, :Host, :Url, :HTTPMethod, :IPAddress, :Source, :Message, :Detail, :StatusCode, :FullJson, :ErrorHash, :DuplicateCount, :LastLogDate)"); + + private DynamicParameters GetUpdateParams(Error error) => + new DynamicParameters(new + { + error.DuplicateCount, + error.ErrorHash, + error.CreationDate, + ApplicationName = error.ApplicationName.Truncate(50), + minDate = DateTime.UtcNow.Subtract(Settings.RollupPeriod.Value) + }); + + private object GetInsertParams(Error error) => new + { + Guid = this.GuidToSting(error.GUID), + ApplicationName = error.ApplicationName.Truncate(50), + Category = error.Category.Truncate(100), + MachineName = error.MachineName.Truncate(50), + error.CreationDate, + Type = error.Type.Truncate(100), + IsProtected = error.IsProtected ? 1 : 0, + Host = error.Host.Truncate(100), + Url = error.UrlPath.Truncate(500), + HTTPMethod = error.HTTPMethod.Truncate(10), + error.IPAddress, + Source = error.Source.Truncate(100), + Message = error.Message.Truncate(1000), + Detail = new OracleClobParameter(error.Detail) , + error.StatusCode, + FullJson = new OracleClobParameter(error.FullJson), + error.ErrorHash, + error.DuplicateCount, + error.LastLogDate + }; + + /// + /// Logs the error to SQL. + /// If the roll-up conditions are met, then the matching error will have a + /// DuplicateCount += @DuplicateCount (usually 1, unless in retry) rather than a distinct new row for the error. + /// + /// The error to log. + protected override bool LogError(Error error) + { + using (var c = GetConnection()) + { + if (Settings.RollupPeriod.HasValue && error.ErrorHash.HasValue) + { + var queryParams = GetUpdateParams(error); + var guid = c.QueryFirstOrDefault(SqlLogUpdate, queryParams); + // if we found an exception that's a duplicate, jump out + if (guid != null) + { + error.GUID = Guid.Parse(guid); + return true; + } + } + + error.FullJson = error.ToJson(); + return c.Execute(SqlLogInsert, GetInsertParams(error)) > 0; + } + } + + /// + /// Asynchronously logs the error to SQL. + /// If the roll-up conditions are met, then the matching error will have a + /// DuplicateCount += @DuplicateCount (usually 1, unless in retry) rather than a distinct new row for the error. + /// + /// The error to log. + protected override async Task LogErrorAsync(Error error) + { + using (var c = GetConnection()) + { + if (Settings.RollupPeriod.HasValue && error.ErrorHash.HasValue) + { + var queryParams = GetUpdateParams(error); + var guid = await c.QueryFirstOrDefaultAsync(SqlLogUpdate, queryParams).ConfigureAwait(false); + // if we found an exception that's a duplicate, jump out + if (guid != null) + { + error.GUID = Guid.Parse(guid); + return true; + } + } + + error.FullJson = error.ToJson(); + + return (await c.ExecuteAsync(SqlLogInsert, GetInsertParams(error)).ConfigureAwait(false)) > 0; + } + } + + private string _sqlGetError; + private string SqlGetError => _sqlGetError ?? (_sqlGetError = $@" +Select * + From {_tableName} + Where lower(GUID) = :guid"); + + /// + /// Gets the error with the specified GUID from SQL. + /// This can return a deleted error as well, there's no filter based on . + /// + /// The GUID of the error to retrieve. + /// The error object if found, null otherwise. + protected override async Task GetErrorAsync(Guid guid) + { + Error sqlError; + using (var c = GetConnection()) + { + sqlError = await c.QueryFirstOrDefaultAsync(SqlGetError, new { guid = this.GuidToSting(guid) }).ConfigureAwait(false); + } + if (sqlError == null) return null; + + // everything is in the JSON, but not the columns and we have to deserialize for collections anyway + // so use that deserialized version and just get the properties that might change on the SQL side and apply them + var result = Error.FromJson(sqlError.FullJson); + result.DuplicateCount = sqlError.DuplicateCount; + result.DeletionDate = sqlError.DeletionDate; + result.IsProtected = sqlError.IsProtected; + result.LastLogDate = sqlError.LastLogDate; + return result; + } + + private string _sqlGetAllErrors; + private string SqlGetAllErrors => _sqlGetAllErrors ?? (_sqlGetAllErrors = $@" +Select * + From {_tableName} + Where DeletionDate Is Null + And ApplicationName = :ApplicationName +Order By CreationDate Desc fetch first :max rows only"); + + /// + /// Retrieves all non-deleted application errors in the database. + /// + /// The name of the application to get all errors for. + protected override async Task> GetAllErrorsAsync(string applicationName = null) + { + using (var c = GetConnection()) + { + return (await c.QueryAsync(SqlGetAllErrors, new { max = _displayCount, ApplicationName = applicationName ?? ApplicationName }).ConfigureAwait(false)).AsList(); + } + } + + private string _sqlGetErrorCount; + private string SqlGetErrorCount => _sqlGetErrorCount ?? (_sqlGetErrorCount = $@" +Select Count(*) + From {_tableName} + Where DeletionDate Is Null + And ApplicationName = :ApplicationName"); + + private string _sqlGetErrorCountWithSince; + private string SqlGetErrorCountWithSince => _sqlGetErrorCountWithSince ?? (_sqlGetErrorCountWithSince = $@" +Select Count(*) + From {_tableName} + Where DeletionDate Is Null + And ApplicationName = :ApplicationName + And CreationDate > :since"); + + /// + /// Retrieves a count of application errors since the specified date, or all time if null. + /// + /// The date to get errors since. + /// The application name to get an error count for. + protected override async Task GetErrorCountAsync(DateTime? since = null, string applicationName = null) + { + using (var c = GetConnection()) + { + return await c.QueryFirstOrDefaultAsync( + since.HasValue ? SqlGetErrorCountWithSince : SqlGetErrorCount, + new { since, ApplicationName = applicationName ?? ApplicationName } + ).ConfigureAwait(false); + } + } + + private OracleConnection GetConnection() => new OracleConnection(_connectionString); + + private void ChangeGuidHandler() + { + SqlMapper.AddTypeHandler(new OracleGuidTypeHandler()); + } + + private string GuidToSting(Guid target) + { + return target.ToString("N").ToLower(); + } + + private IEnumerable GuidsToStings(IEnumerable targets) + { + var result = new List(); + + foreach (var item in targets) + result.Add(GuidToSting(item)); + + return result; + } + } +} diff --git a/src/StackExchange.Exceptional.Oracle/OracleGuidTypeHandler.cs b/src/StackExchange.Exceptional.Oracle/OracleGuidTypeHandler.cs new file mode 100644 index 00000000..407339b2 --- /dev/null +++ b/src/StackExchange.Exceptional.Oracle/OracleGuidTypeHandler.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Dapper; + +namespace StackExchange.Exceptional.Stores +{ + /// + /// We need a special Mapping for Oracle. + /// + public class OracleGuidTypeHandler : SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, Guid guid) + { + parameter.Value = guid.ToString("N"); + } + + public override Guid Parse(object value) + { + return new Guid((string)value); + } + } +} diff --git a/src/StackExchange.Exceptional.Oracle/StackExchange.Exceptional.Oracle.csproj b/src/StackExchange.Exceptional.Oracle/StackExchange.Exceptional.Oracle.csproj new file mode 100644 index 00000000..12a64092 --- /dev/null +++ b/src/StackExchange.Exceptional.Oracle/StackExchange.Exceptional.Oracle.csproj @@ -0,0 +1,13 @@ + + + StackExchange.Exceptional.Oracle + Oracle storage provider for StackExchange.Exceptional + Oracle Exception Handler Errors Stack Exchange Exceptional + net461;netstandard2.0 + + + + + + + \ No newline at end of file diff --git a/tests/StackExchange.Exceptional.Tests.AspNetCore/Configs/Storage.Oracle.json b/tests/StackExchange.Exceptional.Tests.AspNetCore/Configs/Storage.Oracle.json new file mode 100644 index 00000000..35d8f799 --- /dev/null +++ b/tests/StackExchange.Exceptional.Tests.AspNetCore/Configs/Storage.Oracle.json @@ -0,0 +1,9 @@ +{ + "Exceptional": { + "Store": { + "ApplicationName": "Samples (ASP.NET Core Oracle)", + "Type": "Oracle", + "ConnectionString": "User Id=root;Password=root;Data Source=127.0.0.1:1521" + } + } +} \ No newline at end of file diff --git a/tests/StackExchange.Exceptional.Tests.AspNetCore/Configuration.cs b/tests/StackExchange.Exceptional.Tests.AspNetCore/Configuration.cs index bc133d9b..5c0ea50c 100644 --- a/tests/StackExchange.Exceptional.Tests.AspNetCore/Configuration.cs +++ b/tests/StackExchange.Exceptional.Tests.AspNetCore/Configuration.cs @@ -207,6 +207,32 @@ public void MongoDBStorage() Assert.Equal("Samples (ASP.NET Core MongoDB)", store.ApplicationName); } + [Fact] + public void OracleStorage() + { + var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile(GetPath("Storage.Oracle.json")) + .Build(); + + Assert.NotNull(config); + var exceptionalSection = config.GetSection("Exceptional"); + Assert.NotNull(exceptionalSection); + + var settings = new ExceptionalSettings(); + exceptionalSection.Bind(settings); + + // Store + Assert.NotNull(settings.Store); + Assert.Equal("Samples (ASP.NET Core Oracle)", settings.Store.ApplicationName); + Assert.Equal("Oracle", settings.Store.Type); + Assert.Equal("User Id=root;Password=root;Data Source=127.0.0.1:1521", settings.Store.ConnectionString); + + Assert.IsType(settings.DefaultStore); + var sqlStore = settings.DefaultStore as OracleErrorStore; + Assert.Equal("Samples (ASP.NET Core Oracle)", sqlStore.ApplicationName); + } + private string GetPath(string configFile) => Path.Combine("Configs", configFile); } } diff --git a/tests/StackExchange.Exceptional.Tests.AspNetCore/StackExchange.Exceptional.Tests.AspNetCore.csproj b/tests/StackExchange.Exceptional.Tests.AspNetCore/StackExchange.Exceptional.Tests.AspNetCore.csproj index 522d79af..31217770 100644 --- a/tests/StackExchange.Exceptional.Tests.AspNetCore/StackExchange.Exceptional.Tests.AspNetCore.csproj +++ b/tests/StackExchange.Exceptional.Tests.AspNetCore/StackExchange.Exceptional.Tests.AspNetCore.csproj @@ -7,6 +7,9 @@ + + + diff --git a/tests/StackExchange.Exceptional.Tests/Helpers/TestConfig.cs b/tests/StackExchange.Exceptional.Tests/Helpers/TestConfig.cs index 3d8fd079..06a48287 100644 --- a/tests/StackExchange.Exceptional.Tests/Helpers/TestConfig.cs +++ b/tests/StackExchange.Exceptional.Tests/Helpers/TestConfig.cs @@ -36,6 +36,7 @@ public class Config public string MySQLConnectionString { get; set; } = Environment.GetEnvironmentVariable(nameof(MySQLConnectionString)) ?? "server=localhost;uid=root;pwd=root;database=test;Allow User Variables=true"; public string PostgreSqlConnectionString { get; set; } = Environment.GetEnvironmentVariable(nameof(PostgreSqlConnectionString)) ?? "Server=localhost;Port=5432;Database=test;User Id=postgres;Password=postgres;"; public string MongoDBConnectionString { get; set; } = Environment.GetEnvironmentVariable(nameof(MongoDBConnectionString)) ?? "mongodb://localhost/test"; + public string OracleConnectionString { get; set; } = Environment.GetEnvironmentVariable(nameof(OracleConnectionString)) ?? "User Id=root;Password=root;Data Source=127.0.0.0:1521"; } } } diff --git a/tests/StackExchange.Exceptional.Tests/StackExchange.Exceptional.Tests.csproj b/tests/StackExchange.Exceptional.Tests/StackExchange.Exceptional.Tests.csproj index 20e20732..b044e1b6 100644 --- a/tests/StackExchange.Exceptional.Tests/StackExchange.Exceptional.Tests.csproj +++ b/tests/StackExchange.Exceptional.Tests/StackExchange.Exceptional.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/StackExchange.Exceptional.Tests/Storage/OracleErrorStoreTest.cs b/tests/StackExchange.Exceptional.Tests/Storage/OracleErrorStoreTest.cs new file mode 100644 index 00000000..f928638b --- /dev/null +++ b/tests/StackExchange.Exceptional.Tests/Storage/OracleErrorStoreTest.cs @@ -0,0 +1,88 @@ +using System; +using System.Runtime.CompilerServices; +using Dapper; +using Oracle.ManagedDataAccess.Client; +using StackExchange.Exceptional.Stores; +using Xunit; +using Xunit.Abstractions; + +namespace StackExchange.Exceptional.Tests.Storage +{ + public class OracleErrorStoreTest : StoreBaseTest, IClassFixture + { + public string ConnectionString => TestConfig.Current.OracleConnectionString; + private OracleFixture Fixture { get; } + + public OracleErrorStoreTest(OracleFixture fixture, ITestOutputHelper output) : base(output) + { + Fixture = fixture; + if (Fixture.ShouldSkip) + { + Skip.Inconclusive("Couldn't test against: " + ConnectionString + "\n" + fixture.SkipReason); + } + } + + protected override ErrorStore GetStore([CallerMemberName]string appName = null) => + new OracleErrorStore(new ErrorStoreSettings + { + ConnectionString = ConnectionString, + ApplicationName = appName, + TableName = Fixture.TableName + }); + } + + public class OracleFixture : IDisposable + { + public bool ShouldSkip { get; } + public string SkipReason { get; } + public string TableName { get; } + public string TableScript { get; } + private string ConnectionString { get; } + + public OracleFixture() + { + Skip.IfNoConfig(nameof(TestConfig.Current.OracleConnectionString), TestConfig.Current.OracleConnectionString); + try + { + var script = Resource.Get("Scripts.Oracle.sql"); + var csb = new OracleConnectionStringBuilder(TestConfig.Current.OracleConnectionString) + { + ConnectionTimeout = 2000 + }; + ConnectionString = csb.ConnectionString; + using (var conn = new OracleConnection(ConnectionString)) + { + TableName = "Test" + Guid.NewGuid().ToString("N").Substring(24); + TableScript = script.Replace("Exceptions", TableName); + + //we have to split the script + foreach (var scriptPart in TableScript.Split('/')) + { + conn.Execute(scriptPart); + } + } + } + catch (Exception e) + { + e.MaybeLog(TestConfig.Current.OracleConnectionString); + ShouldSkip = true; + SkipReason = e.Message; + } + } + + public void Dispose() + { + try + { + using (var conn = new OracleConnection(ConnectionString)) + { + conn.Execute("Drop Table " + TableName.ToUpper()); + } + } + catch when (ShouldSkip) + { + // if we didn't error initially then we'll throw + } + } + } +}