From 382873ee5a8faa562068bee40fdab99fa856fcb7 Mon Sep 17 00:00:00 2001 From: Pablo Ruiz Date: Fri, 1 Jul 2022 23:44:07 +0200 Subject: [PATCH 1/8] Temporary measure to (forcibly) disable seqscans. --- .gitignore | 1 + Rebus.AdoNet/AdoNetSagaPersister.cs | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index d9bebaf..1153753 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.suo *.user *.sln.docstates +.idea/ # Build results diff --git a/Rebus.AdoNet/AdoNetSagaPersister.cs b/Rebus.AdoNet/AdoNetSagaPersister.cs index 7da4d45..c52487d 100644 --- a/Rebus.AdoNet/AdoNetSagaPersister.cs +++ b/Rebus.AdoNet/AdoNetSagaPersister.cs @@ -753,6 +753,7 @@ private string GetSagaLockingClause(SqlDialect dialect) public TSagaData Find(string sagaDataPropertyPath, object fieldFromMessage) where TSagaData : class, ISagaData { + log.Debug("Finding saga of type {0} with {1} = {2}", typeof(TSagaData).Name, sagaDataPropertyPath, fieldFromMessage); using (var scope = manager.GetScope(autocomplete: true)) { var dialect = scope.Dialect; @@ -808,7 +809,7 @@ private string GetSagaLockingClause(SqlDialect dialect) var valuesPredicate = ArraysEnabledFor(dialect) ? dialect.FormatArrayAny($"i.{indexValuesCol}", indexValuesParm) : $"(i.{indexValuesCol} LIKE ('%' || {indexValuesParm} || '%'))"; - + command.CommandText = $@" SELECT s.{dataCol} FROM {sagaTblName} s @@ -826,7 +827,7 @@ private string GetSagaLockingClause(SqlDialect dialect) END ) {forUpdate};".Replace("\t", ""); - + var value = GetIndexValue(fieldFromMessage); var values = value == null ? null : ArraysEnabledFor(dialect) ? (object)(new[] { value }) @@ -843,7 +844,9 @@ private string GetSagaLockingClause(SqlDialect dialect) try { + if (dialect is PostgreSqlDialect) connection.ExecuteCommand("SET LOCAL enable_seqscan = off;"); data = (string)command.ExecuteScalar(); + if (dialect is PostgreSqlDialect) connection.ExecuteCommand("SET LOCAL enable_seqscan = on;"); } catch (DbException ex) { @@ -857,8 +860,14 @@ private string GetSagaLockingClause(SqlDialect dialect) throw; } + + if (data == null) + { + log.Debug("No saga found of type {0} with {1} = {2}", typeof(TSagaData).Name, sagaDataPropertyPath, fieldFromMessage); + return null; + } - if (data == null) return null; + log.Debug("Found saga of type {0} with {1} = {2}", typeof(TSagaData).Name, sagaDataPropertyPath, fieldFromMessage); try { From 226cd4ae7da58ed4d8a485534fbdfd608e9243c6 Mon Sep 17 00:00:00 2001 From: Pablo Ruiz Date: Wed, 6 Jul 2022 03:38:29 +0200 Subject: [PATCH 2/8] Implemented support for Json-based saga indexing, using a single table in order to speed up lookups by avoiding joins. --- Rebus.AdoNet/AdoNetExceptionManager.cs | 22 - Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs | 1040 ++++++++++++++++++ Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs | 1040 ++++++++++++++++++ Rebus.AdoNet/Dialects/PostgreSql92Dialect.cs | 5 + 4 files changed, 2085 insertions(+), 22 deletions(-) delete mode 100644 Rebus.AdoNet/AdoNetExceptionManager.cs create mode 100644 Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs create mode 100644 Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs create mode 100644 Rebus.AdoNet/Dialects/PostgreSql92Dialect.cs diff --git a/Rebus.AdoNet/AdoNetExceptionManager.cs b/Rebus.AdoNet/AdoNetExceptionManager.cs deleted file mode 100644 index 8bdd987..0000000 --- a/Rebus.AdoNet/AdoNetExceptionManager.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Data; -using System.Data.Common; -using System.Linq; - -namespace Rebus.AdoNet -{ - // TODO: Make this configurable/extensible.. - // TODO: Provide a delegate the user can customize as to convert - // exceptions into DBConcurrencyException, which should be - // what AdoNet's code should try to catch. - public class AdoNetExceptionManager - { - public static bool IsDuplicatedKeyException(Exception ex) - { - // FIXME: This is too dummy. - return (ex is DbException); - } - - - } -} diff --git a/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs b/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs new file mode 100644 index 0000000..0845d52 --- /dev/null +++ b/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs @@ -0,0 +1,1040 @@ +using System; +using System.Data; +using System.Linq; +using System.Data.Common; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; + +using Rebus.Logging; +using Rebus.Serialization; +using Rebus.Serialization.Json; +using Rebus.AdoNet.Schema; +using Rebus.AdoNet.Dialects; + +namespace Rebus.AdoNet +{ + /// + /// Implements a saga persister for Rebus that stores sagas using an AdoNet provider. + /// + public class AdoNetSagaPersisterEx : IStoreSagaData, AdoNetSagaPersisterFluentConfigurer, ICanUpdateMultipleSagaDatasAtomically + { + private const int MaximumSagaDataTypeNameLength = 80; + private const string SAGA_ID_COLUMN = "id"; + private const string SAGA_TYPE_COLUMN = "saga_type"; + private const string SAGA_DATA_COLUMN = "data"; + private const string SAGA_REVISION_COLUMN = "revision"; + //private const string SAGAINDEX_ID_COLUMN = "saga_id"; + //private const string SAGAINDEX_KEY_COLUMN = "key"; + //private const string SAGAINDEX_VALUE_COLUMN = "value"; + private const string SAGAINDEX_VALUES_COLUMN = "values"; + private static ILog log; + private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings { + TypeNameHandling = TypeNameHandling.All, // TODO: Make it configurable by adding a SagaTypeResolver feature. + DateFormatHandling = DateFormatHandling.IsoDateFormat, // TODO: Make it configurable.. + Binder = new CustomSerializationBinder() + }; + + private readonly AdoNetUnitOfWorkManager manager; + private readonly string sagaIndexTableName; + private readonly string sagaTableName; + private readonly string idPropertyName; + private bool useSagaLocking; + private bool useSqlArrays; + private bool useNoWaitSagaLocking; + private bool indexNullProperties = true; + private Func sagaNameCustomizer; + + static AdoNetSagaPersisterEx() + { + RebusLoggerFactory.Changed += f => log = f.GetCurrentClassLogger(); + } + + /// + /// Constructs the persister with the ability to create connections to database using the specified connection string. + /// This also means that the persister will manage the connection by itself, closing it when it has stopped using it. + /// + public AdoNetSagaPersisterEx(AdoNetUnitOfWorkManager manager, string sagaTableName, string sagaIndexTableName) + { + this.manager = manager; + this.sagaTableName = sagaTableName; + this.sagaIndexTableName = sagaIndexTableName; + this.idPropertyName = Reflect.Path(x => x.Id); + } + + #region AdoNetSagaPersisterFluentConfigurer + + /// + /// Configures the persister to ignore null-valued correlation properties and not add them to the saga index. + /// + public AdoNetSagaPersisterFluentConfigurer DoNotIndexNullProperties() + { + indexNullProperties = false; + return this; + } + + public AdoNetSagaPersisterFluentConfigurer UseLockingOnSagaUpdates(bool waitForLocks) + { + useSagaLocking = true; + useNoWaitSagaLocking = !waitForLocks; + return this; + } + + /// + /// Creates the necessary saga storage tables if they haven't already been created. If a table already exists + /// with a name that matches one of the desired table names, no action is performed (i.e. it is assumed that + /// the tables already exist). + /// + public AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() + { + using (var uow = manager.Create(autonomous: true)) + using (var scope = (uow as AdoNetUnitOfWork).GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var tableNames = scope.GetTableNames(); + + // bail out if there's already a table in the database with one of the names + var sagaTableIsAlreadyCreated = tableNames.Contains(sagaTableName, StringComparer.InvariantCultureIgnoreCase); + var sagaIndexTableIsAlreadyCreated = tableNames.Contains(sagaIndexTableName, StringComparer.OrdinalIgnoreCase); + + if (sagaTableIsAlreadyCreated && sagaIndexTableIsAlreadyCreated) + { + log.Debug("Tables '{0}' and '{1}' already exists.", sagaTableName, sagaIndexTableName); + return this; + } + + if (sagaTableIsAlreadyCreated || sagaIndexTableIsAlreadyCreated) + { + // if saga index is created, then saga table is not created and vice versa + throw new ApplicationException(string.Format("Table '{0}' do not exist - you have to create it manually", + sagaIndexTableIsAlreadyCreated ? sagaTableName : sagaIndexTableName)); + } + + if (useSqlArrays && !dialect.SupportsArrayTypes) + { + throw new ApplicationException("Enabled UseSqlArraysForCorrelationIndexes but underlaying database does not support arrays?!"); + } + + log.Info("Tables '{0}' and '{1}' do not exist - they will be created now", sagaTableName, sagaIndexTableName); + + using (var command = connection.CreateCommand()) + { + command.CommandText = scope.Dialect.FormatCreateTable( + new AdoNetTable() + { + Name = sagaTableName, + Columns = new [] + { + new AdoNetColumn() { Name = SAGA_ID_COLUMN, DbType = DbType.Guid }, + new AdoNetColumn() { Name = SAGA_TYPE_COLUMN, DbType = DbType.String, Length = MaximumSagaDataTypeNameLength }, + new AdoNetColumn() { Name = SAGA_REVISION_COLUMN, DbType = DbType.Int32 }, + new AdoNetColumn() { Name = SAGA_DATA_COLUMN, DbType = DbType.String, Length = 1073741823 } + }, + PrimaryKey = new[] { SAGA_ID_COLUMN }, + Indexes = new [] + { + new AdoNetIndex() { Name = $"ix_{sagaTableName}_{SAGA_ID_COLUMN}_{SAGA_TYPE_COLUMN}", Columns = new [] { SAGA_ID_COLUMN, SAGA_TYPE_COLUMN } }, + } + } + ); + + command.ExecuteNonQuery(); + } + + using (var command = connection.CreateCommand()) + { + command.CommandText = scope.Dialect.FormatCreateTable( + new AdoNetTable() + { + Name = sagaIndexTableName, + Columns = new [] + { + new AdoNetColumn() { Name = SAGAINDEX_ID_COLUMN, DbType = DbType.Guid }, + new AdoNetColumn() { Name = SAGAINDEX_KEY_COLUMN, DbType = DbType.String, Length = 200 }, + new AdoNetColumn() { Name = SAGAINDEX_VALUE_COLUMN, DbType = DbType.String, Length = 1073741823, Nullable = true }, + new AdoNetColumn() { Name = SAGAINDEX_VALUES_COLUMN, DbType = DbType.String, Length = 1073741823, Nullable = true, Array = useSqlArrays } + }, + PrimaryKey = new[] { SAGAINDEX_ID_COLUMN, SAGAINDEX_KEY_COLUMN }, + Indexes = new [] + { + new AdoNetIndex() + { + Name = $"ix_{sagaIndexTableName}_{SAGAINDEX_KEY_COLUMN}_{SAGAINDEX_VALUE_COLUMN}", + Columns = new[] { SAGAINDEX_KEY_COLUMN, SAGAINDEX_VALUE_COLUMN }, + //Kind = "ybgin" + }, + new AdoNetIndex() + { + Name = $"ix_{sagaIndexTableName}_{SAGAINDEX_KEY_COLUMN}_{SAGAINDEX_VALUES_COLUMN}", + Columns = new[] { SAGAINDEX_KEY_COLUMN, SAGAINDEX_VALUES_COLUMN }, + //Kind = "ybgin" + } + } + } + ); + + command.ExecuteNonQuery(); + } + + scope.Complete(); + log.Info("Tables '{0}' and '{1}' created", sagaTableName, sagaIndexTableName); + } + + /*using (var scope = manager.GetScope(autonomous: true)) + { + var tableNames = scope.GetTableNames(); + if (!tableNames.Contains(sagaTableName, StringComparer.InvariantCultureIgnoreCase)) + throw new InvalidOperationException("Tables not present?!"); + + }*/ + + return this; + } + + /// + /// Customizes the saga names by invoking this customizer. + /// + /// The customizer. + /// + public AdoNetSagaPersisterFluentConfigurer CustomizeSagaNamesAs(Func customizer) + { + this.sagaNameCustomizer = customizer; + return this; + } + + /// + /// Enables locking of sagas as to avoid two or more workers to update them concurrently. + /// + /// The saga locking. + public AdoNetSagaPersisterFluentConfigurer EnableSagaLocking() + { + useSagaLocking = true; + return this; + } + + /// + /// Uses the use of sql array types for storing indexes related to correlation properties. + /// + /// The sql arrays. + public AdoNetSagaPersisterFluentConfigurer UseSqlArraysForCorrelationIndexes() + { + useSqlArrays = true; + return this; + } + + /// + /// Allows customizing opened connections by passing a delegate/lambda to invoke for each new connection. + /// + /// + /// + public AdoNetSagaPersisterFluentConfigurer CustomizeOpenedConnections(Action customizer) + { + manager.ConnectionFactory.ConnectionCustomizer = customizer; + return this; + } + + /// + /// Customizes type2name & name2type mapping logic used during serialization/deserialization. + /// + /// Delegate to invoke when resolving a name-to-type during deserialization. + /// Delegate to invoke when resolving a type-to-name during serialization. + /// + public AdoNetSagaPersisterFluentConfigurer CustomizeSerializationTypeResolving(Func nameToTypeResolver, Func typeToNameResolver) + { + (Settings.Binder as CustomSerializationBinder).NameToTypeResolver = nameToTypeResolver; + (Settings.Binder as CustomSerializationBinder).TypeToNameResolver = typeToNameResolver; + return this; + } + + #endregion + + public void Insert(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) + { + using (var scope = manager.GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var tableNames = scope.GetTableNames(); + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + + // next insert the saga + using (var command = connection.CreateCommand()) + { + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter(SAGA_TYPE_COLUMN), sagaTypeName); + command.AddParameter(dialect.EscapeParameter(SAGA_REVISION_COLUMN), ++sagaData.Revision); + command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), JsonConvert.SerializeObject(sagaData, Formatting.Indented, Settings)); + + command.CommandText = string.Format( + @"insert into {0} ({1}, {2}, {3}, {4}) values ({5}, {6}, {7}, {8});", + dialect.QuoteForTableName(sagaTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), + dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.EscapeParameter(SAGA_TYPE_COLUMN), + dialect.EscapeParameter(SAGA_REVISION_COLUMN), + dialect.EscapeParameter(SAGA_DATA_COLUMN) + ); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + + var propertiesToIndex = GetPropertiesToIndex(sagaData, sagaDataPropertyPathsToIndex); + + if (propertiesToIndex.Any()) + { + DeclareIndex(sagaData, scope, propertiesToIndex); + } + + scope.Complete(); + } + } + + public void Update(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) + { + using (var scope = manager.GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var tableNames = scope.GetTableNames(); + + // next, update or insert the saga + using (var command = connection.CreateCommand()) + { + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); + command.AddParameter(dialect.EscapeParameter("next_revision"), ++sagaData.Revision); + command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), JsonConvert.SerializeObject(sagaData, Formatting.Indented, Settings)); + + command.CommandText = string.Format( + @"UPDATE {0} SET {1} = {2}, {3} = {4} " + + @"WHERE {5} = {6} AND {7} = {8};", + dialect.QuoteForTableName(sagaTableName), + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), dialect.EscapeParameter(SAGA_DATA_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("next_revision"), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") + ); + var rows = command.ExecuteNonQuery(); + if (rows == 0) + { + throw new OptimisticLockingException(sagaData); + } + } + + var propertiesToIndex = GetPropertiesToIndex(sagaData, sagaDataPropertyPathsToIndex); + + if (propertiesToIndex.Any()) + { + DeclareIndex(sagaData, scope, propertiesToIndex); + } + + scope.Complete(); + } + } + + private void DeclareIndexUsingTableExpressions(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + var parameters = propertiesToIndex + .Select((p, i) => new + { + PropertyName = p.Key, + PropertyValue = p.Value, + PropertyNameParameter = string.Format("n{0}", i), + PropertyValueParameter = string.Format("v{0}", i), + PropertyValuesParameter = string.Format("vs{0}", i) + }) + .ToList(); + + var tuples = parameters + .Select(p => string.Format("({0}, {1}, {2}, {3})", + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), + dialect.EscapeParameter(p.PropertyNameParameter), + dialect.EscapeParameter(p.PropertyValueParameter), + dialect.EscapeParameter(p.PropertyValuesParameter) + )); + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "WITH existing AS (" + + "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {6} " + + "ON CONFLICT ({1}, {2}) DO UPDATE SET {3} = excluded.{3}, {4} = excluded.{4} " + + "RETURNING {2}) " + + "DELETE FROM {0} " + + "WHERE {1} = {5} AND {2} NOT IN (SELECT {2} FROM existing);", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 5 + string.Join(", ", tuples) //< 6 + ); + + foreach (var parameter in parameters) + { + var value = GetIndexValue(parameter.PropertyValue); + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); + + var values = ArraysEnabledFor(dialect) + ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); + var dbtype = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), dbtype, values); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + } + + private void DeclareIndexUsingReturningClause(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var existingKeys = Enumerable.Empty(); + + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + var parameters = propertiesToIndex + .Select((p, i) => new + { + PropertyName = p.Key, + PropertyValue = p.Value, + PropertyNameParameter = string.Format("n{0}", i), + PropertyValueParameter = string.Format("v{0}", i), + PropertyValuesParameter = string.Format("vs{0}", i) + }) + .ToList(); + + var tuples = parameters + .Select(p => string.Format("({0}, {1}, {2}, {3})", + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), + dialect.EscapeParameter(p.PropertyNameParameter), + dialect.EscapeParameter(p.PropertyValueParameter), + dialect.EscapeParameter(p.PropertyValuesParameter) + )); + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {5} " + + "ON CONFLICT ({1}, {2}) DO UPDATE SET {3} = excluded.{3}, {4} = excluded.{4} " + + "RETURNING {2};", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 + string.Join(", ", tuples) //< 5 + ); + + foreach (var parameter in parameters) + { + var value = GetIndexValue(parameter.PropertyValue); + var values = value == null ? null : ArraysEnabledFor(dialect) + ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), valuesDbType, values); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + using (var reader = command.ExecuteReader()) + { + existingKeys = reader.AsEnumerable(SAGAINDEX_KEY_COLUMN).ToArray(); + } + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + + var idx = 0; + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "DELETE FROM {0} " + + "WHERE {1} = {2} AND {3} NOT IN ({4});", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 3 + string.Join(", ", existingKeys.Select(k => dialect.EscapeParameter($"k{idx++}"))) + ); + + for (int i = 0; i < existingKeys.Count(); i++) + { + command.AddParameter(dialect.EscapeParameter($"k{i}"), DbType.StringFixedLength, existingKeys.ElementAt(i).Trim()); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + } + + private void DeclareIndexUnoptimized(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var connection = scope.Connection; + var dialect = scope.Dialect; + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + + var idxTbl = dialect.QuoteForTableName(sagaIndexTableName); + var idCol = dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN); + var keyCol = dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN); + var valueCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); + var valuesCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); + + var idParam = dialect.EscapeParameter(SAGAINDEX_ID_COLUMN); + + var existingKeys = Enumerable.Empty(); + + // Let's fetch existing keys.. + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "SELECT {1} FROM {0} WHERE {2} = {3};", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 2 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN) //< 3 + ); + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + using (var reader = command.ExecuteReader()) + { + existingKeys = reader.AsEnumerable(SAGAINDEX_KEY_COLUMN).ToArray(); + } + } + catch (DbException exception) + { + // FIXME: Ensure exception is the right one.. + throw new OptimisticLockingException(sagaData, exception); + } + } + + // For each exisring key, update it's value.. + foreach (var key in existingKeys.Where(k => propertiesToIndex.Any(p => p.Key == k))) + { + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "UPDATE {0} SET {1} = {2}, {3} = {4} " + + "WHERE {5} = {6} AND {7} = {8};", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 1 + dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 3 + dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN), //< 4 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 5 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 6 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 7 + dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN) //< 8 + ); + + var value = GetIndexValue(propertiesToIndex[key]); + var values = ArraysEnabledFor(dialect) + ? (object)GetIndexValues(propertiesToIndex[key])?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(propertiesToIndex[key])); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN), DbType.String, key); + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN), DbType.String, value); + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN), valuesDbType, values); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + } + + var removedKeys = existingKeys.Where(x => !propertiesToIndex.ContainsKey(x)).ToArray(); + + if (removedKeys.Length > 0) + { + // Remove no longer needed keys.. + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "DELETE FROM {0} WHERE {1} = {2} AND {3} IN ({4});", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 3 + string.Join(", ", existingKeys.Select((x, i) => dialect.EscapeParameter($"k{i}"))) + ); + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + for (int i = 0; i < existingKeys.Count(); i++) + { + command.AddParameter(dialect.EscapeParameter($"k{i}"), DbType.StringFixedLength, existingKeys.ElementAt(i).Trim()); + } + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + } + + var parameters = propertiesToIndex + .Where(x => !existingKeys.Contains(x.Key)) + .Select((p, i) => new + { + PropertyName = p.Key, + PropertyValue = p.Value, + PropertyNameParameter = string.Format("n{0}", i), + PropertyValueParameter = string.Format("v{0}", i), + PropertyValuesParameter = string.Format("vs{0}", i) + }) + .ToList(); + + if (parameters.Count > 0) + { + // Insert new keys.. + using (var command = connection.CreateCommand()) + { + + var tuples = parameters.Select(p => string.Format("({0}, {1}, {2}, {3})", + idParam, + dialect.EscapeParameter(p.PropertyNameParameter), + dialect.EscapeParameter(p.PropertyValueParameter), + dialect.EscapeParameter(p.PropertyValuesParameter) + )); + + command.CommandText = string.Format( + "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {5};", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 + string.Join(", ", tuples) //< 5 + ); + + foreach (var parameter in parameters) + { + var value = GetIndexValue(parameter.PropertyValue); + var values = ArraysEnabledFor(dialect) + ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), valuesDbType, values); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + + } + } + } + + private void DeclareIndex(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var dialect = scope.Dialect; + + if (dialect.SupportsOnConflictClause && dialect.SupportsReturningClause && dialect.SupportsTableExpressions) + { + DeclareIndexUsingTableExpressions(sagaData, scope, propertiesToIndex); + } + else if (dialect.SupportsOnConflictClause && dialect.SupportsReturningClause) + { + DeclareIndexUsingReturningClause(sagaData, scope, propertiesToIndex); + } + else + { + DeclareIndexUnoptimized(sagaData, scope, propertiesToIndex); + } + } + + public void Delete(ISagaData sagaData) + { + using (var scope = manager.GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + @"DELETE FROM {0} WHERE {1} = {2} AND {3} = {4};", + dialect.QuoteForTableName(sagaTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") + ); + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); + + var rows = command.ExecuteNonQuery(); + + if (rows == 0) + { + throw new OptimisticLockingException(sagaData); + } + } + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + @"DELETE FROM {0} WHERE {1} = {2};", + dialect.QuoteForTableName(sagaIndexTableName), + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN) + ); + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.ExecuteNonQuery(); + } + + scope.Complete(); + } + } + + private string GetSagaLockingClause(SqlDialect dialect) + { + if (useSagaLocking) + { + return useNoWaitSagaLocking + ? $"{dialect.SelectForUpdateClause} {dialect.SelectForNoWaitClause}" + : dialect.SelectForUpdateClause; + } + + return string.Empty; + } + + public TSagaData Find(string sagaDataPropertyPath, object fieldFromMessage) where TSagaData : class, ISagaData + { + using (var scope = manager.GetScope(autocomplete: true)) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var sagaType = GetSagaTypeName(typeof(TSagaData)); + + if (useSagaLocking) + { + if (!dialect.SupportsSelectForUpdate) + throw new InvalidOperationException($"You can't use saga locking for a Dialect {dialect.GetType()} that does not supports Select For Update."); + + if (useNoWaitSagaLocking && !dialect.SupportsSelectForWithNoWait) + throw new InvalidOperationException($"You can't use saga locking with no-wait for a Dialect {dialect.GetType()} that does not supports no-wait clause."); + } + + using (var command = connection.CreateCommand()) + { + if (sagaDataPropertyPath == idPropertyName) + { + var id = (fieldFromMessage is Guid) ? (Guid)fieldFromMessage : Guid.Parse(fieldFromMessage.ToString()); + var idParam = dialect.EscapeParameter("id"); + var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); + + command.CommandText = string.Format( + @"SELECT s.{0} FROM {1} s WHERE s.{2} = {3} AND s.{4} = {5} {6}", + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), + dialect.QuoteForTableName(sagaTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), + idParam, + dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), + sagaTypeParam, + GetSagaLockingClause(dialect) + ); + command.AddParameter(sagaTypeParam, sagaType); + command.AddParameter(idParam, id); + } + else + { + var dataCol = dialect.QuoteForColumnName(SAGA_DATA_COLUMN); + var sagaTblName = dialect.QuoteForTableName(sagaTableName); + var sagaTypeCol = dialect.QuoteForColumnName(SAGA_TYPE_COLUMN); + var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); + var indexTblName = dialect.QuoteForTableName(sagaIndexTableName); + var sagaIdCol = dialect.QuoteForColumnName(SAGA_ID_COLUMN); + var indexIdCol = dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN); + var indexKeyCol = dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN); + var indexKeyParam = dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN); + var indexValueCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); + var indexValueParm = dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN); + var indexValuesCol = dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN); + var indexValuesParm = dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN); + var forUpdate = GetSagaLockingClause(dialect); + var valuesPredicate = ArraysEnabledFor(dialect) + ? dialect.FormatArrayAny($"i.{indexValuesCol}", indexValuesParm) + : $"(i.{indexValuesCol} LIKE ('%' || {indexValuesParm} || '%'))"; + + command.CommandText = $@" + SELECT s.{dataCol} + FROM {sagaTblName} s + JOIN {indexTblName} i on s.{sagaIdCol} = i.{indexIdCol} + WHERE s.{sagaTypeCol} = {sagaTypeParam} + AND i.{indexKeyCol} = {indexKeyParam} + AND ( + CASE WHEN {indexValueParm} IS NULL THEN i.{indexValueCol} IS NULL + ELSE + ( + i.{indexValueCol} = {indexValueParm} + OR + (i.{indexValuesCol} is NOT NULL AND {valuesPredicate}) + ) + END + ) + {forUpdate};".Replace("\t", ""); + + var value = GetIndexValue(fieldFromMessage); + var values = value == null ? null : ArraysEnabledFor(dialect) + ? (object)(new[] { value }) + : GetConcatenatedIndexValues(new[] { value }); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(indexKeyParam, sagaDataPropertyPath); + command.AddParameter(sagaTypeParam, sagaType); + command.AddParameter(indexValueParm, DbType.String, value); + command.AddParameter(indexValuesParm, valuesDbType, values); + } + + string data = null; + + try + { + log.Debug("Finding saga of type {0} with {1} = {2}", sagaType, sagaDataPropertyPath, fieldFromMessage); + //if (dialect is PostgreSqlDialect) connection.ExecuteCommand("SET LOCAL enable_seqscan = off;"); + data = (string)command.ExecuteScalar(); + //if (dialect is PostgreSqlDialect) connection.ExecuteCommand("SET LOCAL enable_seqscan = on;"); + } + catch (DbException ex) + { + // When in no-wait saga-locking mode, inspect + // exception and rethrow ex as SagaLockedException. + if (useSagaLocking && useNoWaitSagaLocking) + { + if (dialect.IsSelectForNoWaitLockingException(ex)) + throw new AdoNetSagaLockedException(ex); + } + + throw; + } + + if (data == null) + { + log.Debug("No saga found of type {0} with {1} = {2}", sagaType, sagaDataPropertyPath, fieldFromMessage); + return null; + } + + log.Debug("Found saga of type {0} with {1} = {2}", sagaType, sagaDataPropertyPath, fieldFromMessage); + + try + { + return JsonConvert.DeserializeObject(data, Settings); + } + catch { } + + try + { + return (TSagaData)JsonConvert.DeserializeObject(data, Settings); + } + catch (Exception exception) + { + var message = string.Format("An error occurred while attempting to deserialize '{0}' into a {1}", data, typeof(TSagaData)); + + throw new ApplicationException(message, exception); + } + } + } + } + + private bool ArraysEnabledFor(SqlDialect dialect) + { + return useSqlArrays && dialect.SupportsArrayTypes; + } + + private bool ShouldIndexValue(object value) + { + if (indexNullProperties) + return true; + + if (value == null) return false; + if (value is string) return true; + if ((value is IEnumerable) && !(value as IEnumerable).Cast().Any()) return false; + + return true; + } + + private IDictionary GetPropertiesToIndex(ISagaData sagaData, IEnumerable sagaDataPropertyPathsToIndex) + { + return sagaDataPropertyPathsToIndex + .Select(x => new { Key = x, Value = Reflect.Value(sagaData, x) }) + .Where(ShouldIndexValue) + .ToDictionary(x => x.Key, x => x.Value); + } + + private static string GetIndexValue(object value) + { + if (value is string) + { + return value as string; + } + else if (value == null || value is IEnumerable) + { + return null; + } + + return Convert.ToString(value); + } + + private static IEnumerable GetIndexValues(object value) + { + if (!(value is IEnumerable) || value is string) + { + return null; + } + + return (value as IEnumerable).Cast().Select(x => Convert.ToString(x)).ToArray(); + } + + private static string GetConcatenatedIndexValues(IEnumerable values) + { + if (values == null || !values.Any()) + { + return null; + } + + var sb = new StringBuilder(values.Sum(x => x.Length + 1) + 1); + sb.Append('|'); + + foreach (var value in values) + { + sb.Append(value); + sb.Append('|'); + } + + return sb.ToString(); + } + + #region Default saga name + + private static string GetClassName(Type type) + { + var classNameRegex = new Regex("^[a-zA-Z0-9_]*[\\w]", RegexOptions.IgnoreCase); + var match = classNameRegex.Match(type.Name); + + if (!match.Success) throw new Exception($"Error trying extract name class from type: {type.Name}"); + + return match.Value; + } + + private static IEnumerable GetGenericArguments(Type type) + { + return type.GetGenericArguments() + .Select(x => x.Name) + .ToList(); + } + + private static string GetDefaultSagaName(Type type) + { + var declaringType = type.DeclaringType; + + if (type.IsNested && declaringType != null) + { + if (declaringType.IsGenericType) + { + var className = GetClassName(declaringType); + var genericArguments = GetGenericArguments(type).ToList(); + + return genericArguments.Any() + ? $"{className}<{string.Join(",", genericArguments)}>" + : $"{className}"; + } + + return declaringType.Name; + } + + return type.Name; + } + + #endregion + + private string GetSagaTypeName(Type sagaDataType) + { + var sagaTypeName = sagaNameCustomizer != null ? sagaNameCustomizer(sagaDataType) : GetDefaultSagaName(sagaDataType); + + if (sagaTypeName.Length > MaximumSagaDataTypeNameLength) + { + throw new InvalidOperationException( + string.Format( + @"Sorry, but the maximum length of the name of a saga data class is currently limited to {0} characters! + +This is due to a limitation in SQL Server, where compound indexes have a 900 byte upper size limit - and +since the saga index needs to be able to efficiently query by saga type, key, and value at the same time, +there's room for only 200 characters as the key, 200 characters as the value, and 80 characters as the +saga type name.", + MaximumSagaDataTypeNameLength)); + } + + return sagaTypeName; + } + } +} diff --git a/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs b/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs new file mode 100644 index 0000000..e6e5cb3 --- /dev/null +++ b/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs @@ -0,0 +1,1040 @@ +using System; +using System.Data; +using System.Linq; +using System.Data.Common; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; + +using Rebus.Logging; +using Rebus.Serialization; +using Rebus.Serialization.Json; +using Rebus.AdoNet.Schema; +using Rebus.AdoNet.Dialects; + +namespace Rebus.AdoNet +{ + /// + /// Implements a saga persister for Rebus that stores sagas using an AdoNet provider. + /// + public class AdoNetSagaPersister : IStoreSagaData, AdoNetSagaPersisterFluentConfigurer, ICanUpdateMultipleSagaDatasAtomically + { + private const int MaximumSagaDataTypeNameLength = 80; + private const string SAGA_ID_COLUMN = "id"; + private const string SAGA_TYPE_COLUMN = "saga_type"; + private const string SAGA_DATA_COLUMN = "data"; + private const string SAGA_REVISION_COLUMN = "revision"; + private const string SAGAINDEX_ID_COLUMN = "saga_id"; + private const string SAGAINDEX_KEY_COLUMN = "key"; + private const string SAGAINDEX_VALUE_COLUMN = "value"; + private const string SAGAINDEX_VALUES_COLUMN = "values"; + private static ILog log; + private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings { + TypeNameHandling = TypeNameHandling.All, // TODO: Make it configurable by adding a SagaTypeResolver feature. + DateFormatHandling = DateFormatHandling.IsoDateFormat, // TODO: Make it configurable.. + Binder = new CustomSerializationBinder() + }; + + private readonly AdoNetUnitOfWorkManager manager; + private readonly string sagaIndexTableName; + private readonly string sagaTableName; + private readonly string idPropertyName; + private bool useSagaLocking; + private bool useSqlArrays; + private bool useNoWaitSagaLocking; + private bool indexNullProperties = true; + private Func sagaNameCustomizer; + + static AdoNetSagaPersister() + { + RebusLoggerFactory.Changed += f => log = f.GetCurrentClassLogger(); + } + + /// + /// Constructs the persister with the ability to create connections to database using the specified connection string. + /// This also means that the persister will manage the connection by itself, closing it when it has stopped using it. + /// + public AdoNetSagaPersister(AdoNetUnitOfWorkManager manager, string sagaTableName, string sagaIndexTableName) + { + this.manager = manager; + this.sagaTableName = sagaTableName; + this.sagaIndexTableName = sagaIndexTableName; + this.idPropertyName = Reflect.Path(x => x.Id); + } + + #region AdoNetSagaPersisterFluentConfigurer + + /// + /// Configures the persister to ignore null-valued correlation properties and not add them to the saga index. + /// + public AdoNetSagaPersisterFluentConfigurer DoNotIndexNullProperties() + { + indexNullProperties = false; + return this; + } + + public AdoNetSagaPersisterFluentConfigurer UseLockingOnSagaUpdates(bool waitForLocks) + { + useSagaLocking = true; + useNoWaitSagaLocking = !waitForLocks; + return this; + } + + /// + /// Creates the necessary saga storage tables if they haven't already been created. If a table already exists + /// with a name that matches one of the desired table names, no action is performed (i.e. it is assumed that + /// the tables already exist). + /// + public AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() + { + using (var uow = manager.Create(autonomous: true)) + using (var scope = (uow as AdoNetUnitOfWork).GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var tableNames = scope.GetTableNames(); + + // bail out if there's already a table in the database with one of the names + var sagaTableIsAlreadyCreated = tableNames.Contains(sagaTableName, StringComparer.InvariantCultureIgnoreCase); + var sagaIndexTableIsAlreadyCreated = tableNames.Contains(sagaIndexTableName, StringComparer.OrdinalIgnoreCase); + + if (sagaTableIsAlreadyCreated && sagaIndexTableIsAlreadyCreated) + { + log.Debug("Tables '{0}' and '{1}' already exists.", sagaTableName, sagaIndexTableName); + return this; + } + + if (sagaTableIsAlreadyCreated || sagaIndexTableIsAlreadyCreated) + { + // if saga index is created, then saga table is not created and vice versa + throw new ApplicationException(string.Format("Table '{0}' do not exist - you have to create it manually", + sagaIndexTableIsAlreadyCreated ? sagaTableName : sagaIndexTableName)); + } + + if (useSqlArrays && !dialect.SupportsArrayTypes) + { + throw new ApplicationException("Enabled UseSqlArraysForCorrelationIndexes but underlaying database does not support arrays?!"); + } + + log.Info("Tables '{0}' and '{1}' do not exist - they will be created now", sagaTableName, sagaIndexTableName); + + using (var command = connection.CreateCommand()) + { + command.CommandText = scope.Dialect.FormatCreateTable( + new AdoNetTable() + { + Name = sagaTableName, + Columns = new [] + { + new AdoNetColumn() { Name = SAGA_ID_COLUMN, DbType = DbType.Guid }, + new AdoNetColumn() { Name = SAGA_TYPE_COLUMN, DbType = DbType.String, Length = MaximumSagaDataTypeNameLength }, + new AdoNetColumn() { Name = SAGA_REVISION_COLUMN, DbType = DbType.Int32 }, + new AdoNetColumn() { Name = SAGA_DATA_COLUMN, DbType = DbType.String, Length = 1073741823 } + }, + PrimaryKey = new[] { SAGA_ID_COLUMN }, + Indexes = new [] + { + new AdoNetIndex() { Name = $"ix_{sagaTableName}_{SAGA_ID_COLUMN}_{SAGA_TYPE_COLUMN}", Columns = new [] { SAGA_ID_COLUMN, SAGA_TYPE_COLUMN } }, + } + } + ); + + command.ExecuteNonQuery(); + } + + using (var command = connection.CreateCommand()) + { + command.CommandText = scope.Dialect.FormatCreateTable( + new AdoNetTable() + { + Name = sagaIndexTableName, + Columns = new [] + { + new AdoNetColumn() { Name = SAGAINDEX_ID_COLUMN, DbType = DbType.Guid }, + new AdoNetColumn() { Name = SAGAINDEX_KEY_COLUMN, DbType = DbType.String, Length = 200 }, + new AdoNetColumn() { Name = SAGAINDEX_VALUE_COLUMN, DbType = DbType.String, Length = 1073741823, Nullable = true }, + new AdoNetColumn() { Name = SAGAINDEX_VALUES_COLUMN, DbType = DbType.String, Length = 1073741823, Nullable = true, Array = useSqlArrays } + }, + PrimaryKey = new[] { SAGAINDEX_ID_COLUMN, SAGAINDEX_KEY_COLUMN }, + Indexes = new [] + { + new AdoNetIndex() + { + Name = $"ix_{sagaIndexTableName}_{SAGAINDEX_KEY_COLUMN}_{SAGAINDEX_VALUE_COLUMN}", + Columns = new[] { SAGAINDEX_KEY_COLUMN, SAGAINDEX_VALUE_COLUMN }, + //Kind = "ybgin" + }, + new AdoNetIndex() + { + Name = $"ix_{sagaIndexTableName}_{SAGAINDEX_KEY_COLUMN}_{SAGAINDEX_VALUES_COLUMN}", + Columns = new[] { SAGAINDEX_KEY_COLUMN, SAGAINDEX_VALUES_COLUMN }, + //Kind = "ybgin" + } + } + } + ); + + command.ExecuteNonQuery(); + } + + scope.Complete(); + log.Info("Tables '{0}' and '{1}' created", sagaTableName, sagaIndexTableName); + } + + /*using (var scope = manager.GetScope(autonomous: true)) + { + var tableNames = scope.GetTableNames(); + if (!tableNames.Contains(sagaTableName, StringComparer.InvariantCultureIgnoreCase)) + throw new InvalidOperationException("Tables not present?!"); + + }*/ + + return this; + } + + /// + /// Customizes the saga names by invoking this customizer. + /// + /// The customizer. + /// + public AdoNetSagaPersisterFluentConfigurer CustomizeSagaNamesAs(Func customizer) + { + this.sagaNameCustomizer = customizer; + return this; + } + + /// + /// Enables locking of sagas as to avoid two or more workers to update them concurrently. + /// + /// The saga locking. + public AdoNetSagaPersisterFluentConfigurer EnableSagaLocking() + { + useSagaLocking = true; + return this; + } + + /// + /// Uses the use of sql array types for storing indexes related to correlation properties. + /// + /// The sql arrays. + public AdoNetSagaPersisterFluentConfigurer UseSqlArraysForCorrelationIndexes() + { + useSqlArrays = true; + return this; + } + + /// + /// Allows customizing opened connections by passing a delegate/lambda to invoke for each new connection. + /// + /// + /// + public AdoNetSagaPersisterFluentConfigurer CustomizeOpenedConnections(Action customizer) + { + manager.ConnectionFactory.ConnectionCustomizer = customizer; + return this; + } + + /// + /// Customizes type2name & name2type mapping logic used during serialization/deserialization. + /// + /// Delegate to invoke when resolving a name-to-type during deserialization. + /// Delegate to invoke when resolving a type-to-name during serialization. + /// + public AdoNetSagaPersisterFluentConfigurer CustomizeSerializationTypeResolving(Func nameToTypeResolver, Func typeToNameResolver) + { + (Settings.Binder as CustomSerializationBinder).NameToTypeResolver = nameToTypeResolver; + (Settings.Binder as CustomSerializationBinder).TypeToNameResolver = typeToNameResolver; + return this; + } + + #endregion + + public void Insert(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) + { + using (var scope = manager.GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var tableNames = scope.GetTableNames(); + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + + // next insert the saga + using (var command = connection.CreateCommand()) + { + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter(SAGA_TYPE_COLUMN), sagaTypeName); + command.AddParameter(dialect.EscapeParameter(SAGA_REVISION_COLUMN), ++sagaData.Revision); + command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), JsonConvert.SerializeObject(sagaData, Formatting.Indented, Settings)); + + command.CommandText = string.Format( + @"insert into {0} ({1}, {2}, {3}, {4}) values ({5}, {6}, {7}, {8});", + dialect.QuoteForTableName(sagaTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), + dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.EscapeParameter(SAGA_TYPE_COLUMN), + dialect.EscapeParameter(SAGA_REVISION_COLUMN), + dialect.EscapeParameter(SAGA_DATA_COLUMN) + ); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + + var propertiesToIndex = GetPropertiesToIndex(sagaData, sagaDataPropertyPathsToIndex); + + if (propertiesToIndex.Any()) + { + DeclareIndex(sagaData, scope, propertiesToIndex); + } + + scope.Complete(); + } + } + + public void Update(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) + { + using (var scope = manager.GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var tableNames = scope.GetTableNames(); + + // next, update or insert the saga + using (var command = connection.CreateCommand()) + { + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); + command.AddParameter(dialect.EscapeParameter("next_revision"), ++sagaData.Revision); + command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), JsonConvert.SerializeObject(sagaData, Formatting.Indented, Settings)); + + command.CommandText = string.Format( + @"UPDATE {0} SET {1} = {2}, {3} = {4} " + + @"WHERE {5} = {6} AND {7} = {8};", + dialect.QuoteForTableName(sagaTableName), + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), dialect.EscapeParameter(SAGA_DATA_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("next_revision"), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") + ); + var rows = command.ExecuteNonQuery(); + if (rows == 0) + { + throw new OptimisticLockingException(sagaData); + } + } + + var propertiesToIndex = GetPropertiesToIndex(sagaData, sagaDataPropertyPathsToIndex); + + if (propertiesToIndex.Any()) + { + DeclareIndex(sagaData, scope, propertiesToIndex); + } + + scope.Complete(); + } + } + + private void DeclareIndexUsingTableExpressions(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + var parameters = propertiesToIndex + .Select((p, i) => new + { + PropertyName = p.Key, + PropertyValue = p.Value, + PropertyNameParameter = string.Format("n{0}", i), + PropertyValueParameter = string.Format("v{0}", i), + PropertyValuesParameter = string.Format("vs{0}", i) + }) + .ToList(); + + var tuples = parameters + .Select(p => string.Format("({0}, {1}, {2}, {3})", + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), + dialect.EscapeParameter(p.PropertyNameParameter), + dialect.EscapeParameter(p.PropertyValueParameter), + dialect.EscapeParameter(p.PropertyValuesParameter) + )); + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "WITH existing AS (" + + "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {6} " + + "ON CONFLICT ({1}, {2}) DO UPDATE SET {3} = excluded.{3}, {4} = excluded.{4} " + + "RETURNING {2}) " + + "DELETE FROM {0} " + + "WHERE {1} = {5} AND {2} NOT IN (SELECT {2} FROM existing);", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 5 + string.Join(", ", tuples) //< 6 + ); + + foreach (var parameter in parameters) + { + var value = GetIndexValue(parameter.PropertyValue); + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); + + var values = ArraysEnabledFor(dialect) + ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); + var dbtype = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), dbtype, values); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + } + + private void DeclareIndexUsingReturningClause(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var existingKeys = Enumerable.Empty(); + + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + var parameters = propertiesToIndex + .Select((p, i) => new + { + PropertyName = p.Key, + PropertyValue = p.Value, + PropertyNameParameter = string.Format("n{0}", i), + PropertyValueParameter = string.Format("v{0}", i), + PropertyValuesParameter = string.Format("vs{0}", i) + }) + .ToList(); + + var tuples = parameters + .Select(p => string.Format("({0}, {1}, {2}, {3})", + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), + dialect.EscapeParameter(p.PropertyNameParameter), + dialect.EscapeParameter(p.PropertyValueParameter), + dialect.EscapeParameter(p.PropertyValuesParameter) + )); + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {5} " + + "ON CONFLICT ({1}, {2}) DO UPDATE SET {3} = excluded.{3}, {4} = excluded.{4} " + + "RETURNING {2};", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 + string.Join(", ", tuples) //< 5 + ); + + foreach (var parameter in parameters) + { + var value = GetIndexValue(parameter.PropertyValue); + var values = value == null ? null : ArraysEnabledFor(dialect) + ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), valuesDbType, values); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + using (var reader = command.ExecuteReader()) + { + existingKeys = reader.AsEnumerable(SAGAINDEX_KEY_COLUMN).ToArray(); + } + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + + var idx = 0; + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "DELETE FROM {0} " + + "WHERE {1} = {2} AND {3} NOT IN ({4});", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 3 + string.Join(", ", existingKeys.Select(k => dialect.EscapeParameter($"k{idx++}"))) + ); + + for (int i = 0; i < existingKeys.Count(); i++) + { + command.AddParameter(dialect.EscapeParameter($"k{i}"), DbType.StringFixedLength, existingKeys.ElementAt(i).Trim()); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + } + + private void DeclareIndexUnoptimized(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var connection = scope.Connection; + var dialect = scope.Dialect; + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + + var idxTbl = dialect.QuoteForTableName(sagaIndexTableName); + var idCol = dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN); + var keyCol = dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN); + var valueCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); + var valuesCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); + + var idParam = dialect.EscapeParameter(SAGAINDEX_ID_COLUMN); + + var existingKeys = Enumerable.Empty(); + + // Let's fetch existing keys.. + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "SELECT {1} FROM {0} WHERE {2} = {3};", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 2 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN) //< 3 + ); + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + using (var reader = command.ExecuteReader()) + { + existingKeys = reader.AsEnumerable(SAGAINDEX_KEY_COLUMN).ToArray(); + } + } + catch (DbException exception) + { + // FIXME: Ensure exception is the right one.. + throw new OptimisticLockingException(sagaData, exception); + } + } + + // For each exisring key, update it's value.. + foreach (var key in existingKeys.Where(k => propertiesToIndex.Any(p => p.Key == k))) + { + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "UPDATE {0} SET {1} = {2}, {3} = {4} " + + "WHERE {5} = {6} AND {7} = {8};", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 1 + dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 3 + dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN), //< 4 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 5 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 6 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 7 + dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN) //< 8 + ); + + var value = GetIndexValue(propertiesToIndex[key]); + var values = ArraysEnabledFor(dialect) + ? (object)GetIndexValues(propertiesToIndex[key])?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(propertiesToIndex[key])); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN), DbType.String, key); + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN), DbType.String, value); + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN), valuesDbType, values); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + } + + var removedKeys = existingKeys.Where(x => !propertiesToIndex.ContainsKey(x)).ToArray(); + + if (removedKeys.Length > 0) + { + // Remove no longer needed keys.. + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "DELETE FROM {0} WHERE {1} = {2} AND {3} IN ({4});", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 3 + string.Join(", ", existingKeys.Select((x, i) => dialect.EscapeParameter($"k{i}"))) + ); + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + for (int i = 0; i < existingKeys.Count(); i++) + { + command.AddParameter(dialect.EscapeParameter($"k{i}"), DbType.StringFixedLength, existingKeys.ElementAt(i).Trim()); + } + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + } + + var parameters = propertiesToIndex + .Where(x => !existingKeys.Contains(x.Key)) + .Select((p, i) => new + { + PropertyName = p.Key, + PropertyValue = p.Value, + PropertyNameParameter = string.Format("n{0}", i), + PropertyValueParameter = string.Format("v{0}", i), + PropertyValuesParameter = string.Format("vs{0}", i) + }) + .ToList(); + + if (parameters.Count > 0) + { + // Insert new keys.. + using (var command = connection.CreateCommand()) + { + + var tuples = parameters.Select(p => string.Format("({0}, {1}, {2}, {3})", + idParam, + dialect.EscapeParameter(p.PropertyNameParameter), + dialect.EscapeParameter(p.PropertyValueParameter), + dialect.EscapeParameter(p.PropertyValuesParameter) + )); + + command.CommandText = string.Format( + "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {5};", + dialect.QuoteForTableName(sagaIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 + string.Join(", ", tuples) //< 5 + ); + + foreach (var parameter in parameters) + { + var value = GetIndexValue(parameter.PropertyValue); + var values = ArraysEnabledFor(dialect) + ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), valuesDbType, values); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) + { + throw new OptimisticLockingException(sagaData, exception); + } + + } + } + } + + private void DeclareIndex(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var dialect = scope.Dialect; + + if (dialect.SupportsOnConflictClause && dialect.SupportsReturningClause && dialect.SupportsTableExpressions) + { + DeclareIndexUsingTableExpressions(sagaData, scope, propertiesToIndex); + } + else if (dialect.SupportsOnConflictClause && dialect.SupportsReturningClause) + { + DeclareIndexUsingReturningClause(sagaData, scope, propertiesToIndex); + } + else + { + DeclareIndexUnoptimized(sagaData, scope, propertiesToIndex); + } + } + + public void Delete(ISagaData sagaData) + { + using (var scope = manager.GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + @"DELETE FROM {0} WHERE {1} = {2} AND {3} = {4};", + dialect.QuoteForTableName(sagaTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") + ); + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); + + var rows = command.ExecuteNonQuery(); + + if (rows == 0) + { + throw new OptimisticLockingException(sagaData); + } + } + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + @"DELETE FROM {0} WHERE {1} = {2};", + dialect.QuoteForTableName(sagaIndexTableName), + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN) + ); + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.ExecuteNonQuery(); + } + + scope.Complete(); + } + } + + private string GetSagaLockingClause(SqlDialect dialect) + { + if (useSagaLocking) + { + return useNoWaitSagaLocking + ? $"{dialect.SelectForUpdateClause} {dialect.SelectForNoWaitClause}" + : dialect.SelectForUpdateClause; + } + + return string.Empty; + } + + public TSagaData Find(string sagaDataPropertyPath, object fieldFromMessage) where TSagaData : class, ISagaData + { + using (var scope = manager.GetScope(autocomplete: true)) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var sagaType = GetSagaTypeName(typeof(TSagaData)); + + if (useSagaLocking) + { + if (!dialect.SupportsSelectForUpdate) + throw new InvalidOperationException($"You can't use saga locking for a Dialect {dialect.GetType()} that does not supports Select For Update."); + + if (useNoWaitSagaLocking && !dialect.SupportsSelectForWithNoWait) + throw new InvalidOperationException($"You can't use saga locking with no-wait for a Dialect {dialect.GetType()} that does not supports no-wait clause."); + } + + using (var command = connection.CreateCommand()) + { + if (sagaDataPropertyPath == idPropertyName) + { + var id = (fieldFromMessage is Guid) ? (Guid)fieldFromMessage : Guid.Parse(fieldFromMessage.ToString()); + var idParam = dialect.EscapeParameter("id"); + var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); + + command.CommandText = string.Format( + @"SELECT s.{0} FROM {1} s WHERE s.{2} = {3} AND s.{4} = {5} {6}", + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), + dialect.QuoteForTableName(sagaTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), + idParam, + dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), + sagaTypeParam, + GetSagaLockingClause(dialect) + ); + command.AddParameter(sagaTypeParam, sagaType); + command.AddParameter(idParam, id); + } + else + { + var dataCol = dialect.QuoteForColumnName(SAGA_DATA_COLUMN); + var sagaTblName = dialect.QuoteForTableName(sagaTableName); + var sagaTypeCol = dialect.QuoteForColumnName(SAGA_TYPE_COLUMN); + var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); + var indexTblName = dialect.QuoteForTableName(sagaIndexTableName); + var sagaIdCol = dialect.QuoteForColumnName(SAGA_ID_COLUMN); + var indexIdCol = dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN); + var indexKeyCol = dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN); + var indexKeyParam = dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN); + var indexValueCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); + var indexValueParm = dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN); + var indexValuesCol = dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN); + var indexValuesParm = dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN); + var forUpdate = GetSagaLockingClause(dialect); + var valuesPredicate = ArraysEnabledFor(dialect) + ? dialect.FormatArrayAny($"i.{indexValuesCol}", indexValuesParm) + : $"(i.{indexValuesCol} LIKE ('%' || {indexValuesParm} || '%'))"; + + command.CommandText = $@" + SELECT s.{dataCol} + FROM {sagaTblName} s + JOIN {indexTblName} i on s.{sagaIdCol} = i.{indexIdCol} + WHERE s.{sagaTypeCol} = {sagaTypeParam} + AND i.{indexKeyCol} = {indexKeyParam} + AND ( + CASE WHEN {indexValueParm} IS NULL THEN i.{indexValueCol} IS NULL + ELSE + ( + i.{indexValueCol} = {indexValueParm} + OR + (i.{indexValuesCol} is NOT NULL AND {valuesPredicate}) + ) + END + ) + {forUpdate};".Replace("\t", ""); + + var value = GetIndexValue(fieldFromMessage); + var values = value == null ? null : ArraysEnabledFor(dialect) + ? (object)(new[] { value }) + : GetConcatenatedIndexValues(new[] { value }); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(indexKeyParam, sagaDataPropertyPath); + command.AddParameter(sagaTypeParam, sagaType); + command.AddParameter(indexValueParm, DbType.String, value); + command.AddParameter(indexValuesParm, valuesDbType, values); + } + + string data = null; + + try + { + log.Debug("Finding saga of type {0} with {1} = {2}", sagaType, sagaDataPropertyPath, fieldFromMessage); + //if (dialect is PostgreSqlDialect) connection.ExecuteCommand("SET LOCAL enable_seqscan = off;"); + data = (string)command.ExecuteScalar(); + //if (dialect is PostgreSqlDialect) connection.ExecuteCommand("SET LOCAL enable_seqscan = on;"); + } + catch (DbException ex) + { + // When in no-wait saga-locking mode, inspect + // exception and rethrow ex as SagaLockedException. + if (useSagaLocking && useNoWaitSagaLocking) + { + if (dialect.IsSelectForNoWaitLockingException(ex)) + throw new AdoNetSagaLockedException(ex); + } + + throw; + } + + if (data == null) + { + log.Debug("No saga found of type {0} with {1} = {2}", sagaType, sagaDataPropertyPath, fieldFromMessage); + return null; + } + + log.Debug("Found saga of type {0} with {1} = {2}", sagaType, sagaDataPropertyPath, fieldFromMessage); + + try + { + return JsonConvert.DeserializeObject(data, Settings); + } + catch { } + + try + { + return (TSagaData)JsonConvert.DeserializeObject(data, Settings); + } + catch (Exception exception) + { + var message = string.Format("An error occurred while attempting to deserialize '{0}' into a {1}", data, typeof(TSagaData)); + + throw new ApplicationException(message, exception); + } + } + } + } + + private bool ArraysEnabledFor(SqlDialect dialect) + { + return useSqlArrays && dialect.SupportsArrayTypes; + } + + private bool ShouldIndexValue(object value) + { + if (indexNullProperties) + return true; + + if (value == null) return false; + if (value is string) return true; + if ((value is IEnumerable) && !(value as IEnumerable).Cast().Any()) return false; + + return true; + } + + private IDictionary GetPropertiesToIndex(ISagaData sagaData, IEnumerable sagaDataPropertyPathsToIndex) + { + return sagaDataPropertyPathsToIndex + .Select(x => new { Key = x, Value = Reflect.Value(sagaData, x) }) + .Where(ShouldIndexValue) + .ToDictionary(x => x.Key, x => x.Value); + } + + private static string GetIndexValue(object value) + { + if (value is string) + { + return value as string; + } + else if (value == null || value is IEnumerable) + { + return null; + } + + return Convert.ToString(value); + } + + private static IEnumerable GetIndexValues(object value) + { + if (!(value is IEnumerable) || value is string) + { + return null; + } + + return (value as IEnumerable).Cast().Select(x => Convert.ToString(x)).ToArray(); + } + + private static string GetConcatenatedIndexValues(IEnumerable values) + { + if (values == null || !values.Any()) + { + return null; + } + + var sb = new StringBuilder(values.Sum(x => x.Length + 1) + 1); + sb.Append('|'); + + foreach (var value in values) + { + sb.Append(value); + sb.Append('|'); + } + + return sb.ToString(); + } + + #region Default saga name + + private static string GetClassName(Type type) + { + var classNameRegex = new Regex("^[a-zA-Z0-9_]*[\\w]", RegexOptions.IgnoreCase); + var match = classNameRegex.Match(type.Name); + + if (!match.Success) throw new Exception($"Error trying extract name class from type: {type.Name}"); + + return match.Value; + } + + private static IEnumerable GetGenericArguments(Type type) + { + return type.GetGenericArguments() + .Select(x => x.Name) + .ToList(); + } + + private static string GetDefaultSagaName(Type type) + { + var declaringType = type.DeclaringType; + + if (type.IsNested && declaringType != null) + { + if (declaringType.IsGenericType) + { + var className = GetClassName(declaringType); + var genericArguments = GetGenericArguments(type).ToList(); + + return genericArguments.Any() + ? $"{className}<{string.Join(",", genericArguments)}>" + : $"{className}"; + } + + return declaringType.Name; + } + + return type.Name; + } + + #endregion + + private string GetSagaTypeName(Type sagaDataType) + { + var sagaTypeName = sagaNameCustomizer != null ? sagaNameCustomizer(sagaDataType) : GetDefaultSagaName(sagaDataType); + + if (sagaTypeName.Length > MaximumSagaDataTypeNameLength) + { + throw new InvalidOperationException( + string.Format( + @"Sorry, but the maximum length of the name of a saga data class is currently limited to {0} characters! + +This is due to a limitation in SQL Server, where compound indexes have a 900 byte upper size limit - and +since the saga index needs to be able to efficiently query by saga type, key, and value at the same time, +there's room for only 200 characters as the key, 200 characters as the value, and 80 characters as the +saga type name.", + MaximumSagaDataTypeNameLength)); + } + + return sagaTypeName; + } + } +} diff --git a/Rebus.AdoNet/Dialects/PostgreSql92Dialect.cs b/Rebus.AdoNet/Dialects/PostgreSql92Dialect.cs new file mode 100644 index 0000000..9d89371 --- /dev/null +++ b/Rebus.AdoNet/Dialects/PostgreSql92Dialect.cs @@ -0,0 +1,5 @@ +namespace Rebus.AdoNet.Dialects { + public class PostgreSql92Dialect { + + } +} \ No newline at end of file From 68adaa3ab7d8b2ff9e29ca0291088353050c9802 Mon Sep 17 00:00:00 2001 From: Pablo Ruiz Date: Wed, 6 Jul 2022 03:39:50 +0200 Subject: [PATCH 3/8] Implemented support for Json-based saga indexing, using a single table in order to speed up lookups by avoiding joins. --- README.md | 12 + Rebus.AdoNet.Tests/Rebus.AdoNet.Tests.csproj | 37 +- Rebus.AdoNet.Tests/SagaPersisterTests.cs | 218 ++-- Rebus.AdoNet.Tests/packages.config | 22 +- Rebus.AdoNet/AdoNetExtensions.cs | 37 +- Rebus.AdoNet/AdoNetSagaPersister.cs | 919 ++------------ Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs | 1070 ++++------------ Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs | 435 ++----- Rebus.AdoNet/AdoNetSubscriptionStorage.cs | 2 +- Rebus.AdoNet/AdoNetUnitOfWork.cs | 31 +- Rebus.AdoNet/AdoNetUnitOfWorkManager.cs | 17 +- Rebus.AdoNet/AdoNetUnitOfWorkScope.cs | 12 +- Rebus.AdoNet/Dialects/PostgreSql10Dialect.cs | 2 +- Rebus.AdoNet/Dialects/PostgreSql82Dialect.cs | 13 +- Rebus.AdoNet/Dialects/PostgreSql91Dialect.cs | 19 +- Rebus.AdoNet/Dialects/PostgreSql92Dialect.cs | 22 +- Rebus.AdoNet/Dialects/PostgreSql94Dialect.cs | 8 +- Rebus.AdoNet/Dialects/PostgreSqlDialect.cs | 261 ++-- Rebus.AdoNet/Dialects/SqlDialect.cs | 1138 +++++++++--------- Rebus.AdoNet/Dialects/YugabyteDbDialect.cs | 48 +- Rebus.AdoNet/Rebus.AdoNet.csproj | 4 +- Rebus.AdoNet/Schema/AdoNetColumn.cs | 43 +- Rebus.AdoNet/Schema/AdoNetIndex.cs | 52 +- 23 files changed, 1541 insertions(+), 2881 deletions(-) diff --git a/README.md b/README.md index b98dc42..366fe69 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,15 @@ In order to run unit-tests locally, a postgres instance is required. It can be launched using docker, with same parameters as it is launched on appveyor. * docker run -it --rm -e 'POSTGRES_PASSWORD=Password12!' -e 'POSTGRES_DB=test' -p 5432:5432 postgres:10 + +# Running Unit Tests (against YugaByteDB) +In order to run unit-tests locally using YugaByteDB, a 2.15+ version is required for full +compatibility (as this version supports FOR UPDATE NOWAIT/SKIP LOCKED), and it should +have been started as: + +docker run -it --rm --name yugabyte -p1070:7000 -p9000:9000 -p5432:5433 -p9042:9042 yugabytedb/yugabyte:2.15.0.0-b11 bin/yugabyted start --daemon=false --tserver_flags="yb_enable_read_committed_isolation=true" + +Also a test database has to be pre-created using: + +docker exec -it yugabyte bin/ysqlsh -C "CREATE DATABASE test;" + diff --git a/Rebus.AdoNet.Tests/Rebus.AdoNet.Tests.csproj b/Rebus.AdoNet.Tests/Rebus.AdoNet.Tests.csproj index 7fa966b..0a6312b 100644 --- a/Rebus.AdoNet.Tests/Rebus.AdoNet.Tests.csproj +++ b/Rebus.AdoNet.Tests/Rebus.AdoNet.Tests.csproj @@ -1,5 +1,7 @@  + + Debug @@ -40,37 +42,8 @@ ..\packages\Common.Logging.Core.3.3.1\lib\net40\Common.Logging.Core.dll True - - ..\packages\NUnit3TestAdapter.3.4.0\lib\Mono.Cecil.dll - False - - - ..\packages\NUnit3TestAdapter.3.4.0\lib\Mono.Cecil.Mdb.dll - False - - - ..\packages\NUnit3TestAdapter.3.4.0\lib\Mono.Cecil.Pdb.dll - False - - - ..\packages\NUnit3TestAdapter.3.4.0\lib\Mono.Cecil.Rocks.dll - False - - - ..\packages\NUnit3TestAdapter.3.4.0\lib\nunit.engine.dll - False - - - ..\packages\NUnit3TestAdapter.3.4.0\lib\nunit.engine.api.dll - False - - - ..\packages\NUnit.3.4.1\lib\net45\nunit.framework.dll - True - - - ..\packages\NUnit3TestAdapter.3.4.0\lib\NUnit3.TestAdapter.dll - False + + ..\packages\NUnit.3.13.3\lib\net45\nunit.framework.dll ..\packages\Rebus.0.84.0\lib\NET45\Rebus.dll @@ -132,6 +105,8 @@ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + "TEXT" (default) - ///     Names.Get(DbType,100)       // --> "VARCHAR(100)" (100 is in [0:255]) - ///     Names.Get(DbType,1000)  // --> "LONGVARCHAR(1000)" (100 is in [256:65534]) - ///     Names.Get(DbType,100000)    // --> "TEXT" (default) - /// - /// On the other hand, simply putting - /// - ///     Names.Put(DbType, "VARCHAR($l)" ); - /// - /// would result in - /// - ///     Names.Get(DbType)           // --> "VARCHAR($l)" (will cause trouble) - ///     Names.Get(DbType,100)       // --> "VARCHAR(100)" - ///     Names.Get(DbType,1000)  // --> "VARCHAR(1000)" - ///     Names.Get(DbType,10000) // --> "VARCHAR(10000)" - /// - /// - public class TypeNames - { - public const string LengthPlaceHolder = "$l"; - public const string PrecisionPlaceHolder = "$p"; - public const string ScalePlaceHolder = "$s"; - - private readonly Dictionary> weighted = new Dictionary>(); - private readonly Dictionary defaults = new Dictionary(); - - /// - /// - /// - /// - /// - /// - /// - private static string ReplaceOnce(string template, string placeholder, string replacement) - { - int loc = template.IndexOf(placeholder, StringComparison.Ordinal); - if (loc < 0) - { - return template; - } - else - { - return new StringBuilder(template.Substring(0, loc)) - .Append(replacement) - .Append(template.Substring(loc + placeholder.Length)) - .ToString(); - } - } - - /// - /// Replaces the specified type. - /// - /// The type. - /// The size. - /// The precision. - /// The scale. - /// - private static string Replace(string type, uint size, uint precision, uint scale) - { - type = ReplaceOnce(type, LengthPlaceHolder, size.ToString()); - type = ReplaceOnce(type, ScalePlaceHolder, scale.ToString()); - return ReplaceOnce(type, PrecisionPlaceHolder, precision.ToString()); - } - - /// - /// Get default type name for specified type - /// - /// the type key - /// the default type name associated with the specified key - public string Get(DbType typecode) - { - string result; - if (!defaults.TryGetValue(typecode, out result)) - { - throw new ArgumentException("Dialect does not support DbType." + typecode, "typecode"); - } - return result; - } - - /// - /// Get the type name specified type and size - /// - /// the type key - /// the SQL length - /// the SQL scale - /// the SQL precision - /// - /// The associated name with smallest capacity >= size if available and the - /// default type name otherwise - /// - public string Get(DbType typecode, uint size, uint precision, uint scale) - { - SortedList map; - weighted.TryGetValue(typecode, out map); - if (map != null && map.Count > 0) - { - foreach (KeyValuePair entry in map) - { - if (size <= entry.Key) - { - return Replace(entry.Value, size, precision, scale); - } - } - } - //Could not find a specific type for the size, using the default - return Replace(Get(typecode), size, precision, scale); - } - - /// - /// For types with a simple length, this method returns the definition - /// for the longest registered type. - /// - /// - /// - public string GetLongest(DbType typecode) - { - SortedList map; - weighted.TryGetValue(typecode, out map); - - if (map != null && map.Count > 0) - return Replace(map.Values[map.Count - 1], map.Keys[map.Count - 1], 0, 0); - - return Get(typecode); - } - - /// - /// Set a type name for specified type key and capacity - /// - /// the type key - /// the (maximum) type size/length - /// The associated name - public void Put(DbType typecode, uint capacity, string value) - { - SortedList map; - if (!weighted.TryGetValue(typecode, out map)) - { - // add new ordered map - weighted[typecode] = map = new SortedList(); - } - map[capacity] = value; - } - - /// - /// - /// - /// - /// - public void Put(DbType typecode, string value) - { - defaults[typecode] = value; - } - } +using System; +using System.Collections.Generic; +using System.Data; +using System.Text; + +namespace Rebus.AdoNet.Dialects +{ + /// + /// This class maps a DbType to names. + /// + /// + /// Associations may be marked with a capacity. Calling the Get() + /// method with a type and actual size n will return the associated + /// name with smallest capacity >= n, if available and an unmarked + /// default type otherwise. + /// Eg, setting + /// + ///     Names.Put(DbType,           "TEXT" ); + ///     Names.Put(DbType,   255,    "VARCHAR($l)" ); + ///     Names.Put(DbType,   65534,  "LONGVARCHAR($l)" ); + /// + /// will give you back the following: + /// + ///     Names.Get(DbType)           // --> "TEXT" (default) + ///     Names.Get(DbType,100)       // --> "VARCHAR(100)" (100 is in [0:255]) + ///     Names.Get(DbType,1000)  // --> "LONGVARCHAR(1000)" (100 is in [256:65534]) + ///     Names.Get(DbType,100000)    // --> "TEXT" (default) + /// + /// On the other hand, simply putting + /// + ///     Names.Put(DbType, "VARCHAR($l)" ); + /// + /// would result in + /// + ///     Names.Get(DbType)           // --> "VARCHAR($l)" (will cause trouble) + ///     Names.Get(DbType,100)       // --> "VARCHAR(100)" + ///     Names.Get(DbType,1000)  // --> "VARCHAR(1000)" + ///     Names.Get(DbType,10000) // --> "VARCHAR(10000)" + /// + /// + public class TypeNames + { + public const string LengthPlaceHolder = "$l"; + public const string PrecisionPlaceHolder = "$p"; + public const string ScalePlaceHolder = "$s"; + + private readonly Dictionary> weighted = new Dictionary>(); + private readonly Dictionary defaults = new Dictionary(); + + /// + /// + /// + /// + /// + /// + /// + private static string ReplaceOnce(string template, string placeholder, string replacement) + { + int loc = template.IndexOf(placeholder, StringComparison.Ordinal); + if (loc < 0) + { + return template; + } + else + { + return new StringBuilder(template.Substring(0, loc)) + .Append(replacement) + .Append(template.Substring(loc + placeholder.Length)) + .ToString(); + } + } + + /// + /// Replaces the specified type. + /// + /// The type. + /// The size. + /// The precision. + /// The scale. + /// + private static string Replace(string type, uint size, uint precision, uint scale) + { + type = ReplaceOnce(type, LengthPlaceHolder, size.ToString()); + type = ReplaceOnce(type, ScalePlaceHolder, scale.ToString()); + return ReplaceOnce(type, PrecisionPlaceHolder, precision.ToString()); + } + + /// + /// Get default type name for specified type + /// + /// the type key + /// the default type name associated with the specified key + public string Get(DbType typecode) + { + string result; + if (!defaults.TryGetValue(typecode, out result)) + { + throw new ArgumentException("Dialect does not support DbType." + typecode, "typecode"); + } + return result; + } + + /// + /// Get the type name specified type and size + /// + /// the type key + /// the SQL length + /// the SQL scale + /// the SQL precision + /// + /// The associated name with smallest capacity >= size if available and the + /// default type name otherwise + /// + public string Get(DbType typecode, uint size, uint precision, uint scale) + { + SortedList map; + weighted.TryGetValue(typecode, out map); + if (map != null && map.Count > 0) + { + foreach (KeyValuePair entry in map) + { + if (size <= entry.Key) + { + return Replace(entry.Value, size, precision, scale); + } + } + } + //Could not find a specific type for the size, using the default + return Replace(Get(typecode), size, precision, scale); + } + + /// + /// For types with a simple length, this method returns the definition + /// for the longest registered type. + /// + /// + /// + public string GetLongest(DbType typecode) + { + SortedList map; + weighted.TryGetValue(typecode, out map); + + if (map != null && map.Count > 0) + return Replace(map.Values[map.Count - 1], map.Keys[map.Count - 1], 0, 0); + + return Get(typecode); + } + + /// + /// Set a type name for specified type key and capacity + /// + /// the type key + /// the (maximum) type size/length + /// The associated name + public void Put(DbType typecode, uint capacity, string value) + { + SortedList map; + if (!weighted.TryGetValue(typecode, out map)) + { + // add new ordered map + weighted[typecode] = map = new SortedList(); + } + map[capacity] = value; + } + + /// + /// + /// + /// + /// + public void Put(DbType typecode, string value) + { + defaults[typecode] = value; + } + } } \ No newline at end of file diff --git a/Rebus.AdoNet/Properties/AssemblyInfo.cs b/Rebus.AdoNet/Properties/AssemblyInfo.cs index 139f099..7adf52b 100644 --- a/Rebus.AdoNet/Properties/AssemblyInfo.cs +++ b/Rebus.AdoNet/Properties/AssemblyInfo.cs @@ -1,39 +1,39 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Rebus.AdoNet")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Rebus.AdoNet")] -[assembly: AssemblyCopyright("Copyright © Evidencias Certificadas S.L. (2015~2016)")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("03b56847-7469-4db3-b146-2d29ce61663e")] - -[assembly: InternalsVisibleTo("Rebus.AdoNet.Tests")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("0.0.0.0")] -[assembly: AssemblyFileVersion("0.0.0.0")] -[assembly: AssemblyInformationalVersion("VERSION_STRING")] +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Rebus.AdoNet")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Rebus.AdoNet")] +[assembly: AssemblyCopyright("Copyright © Evidencias Certificadas S.L. (2015~2016)")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("03b56847-7469-4db3-b146-2d29ce61663e")] + +[assembly: InternalsVisibleTo("Rebus.AdoNet.Tests")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("0.0.0.0")] +[assembly: AssemblyFileVersion("0.0.0.0")] +[assembly: AssemblyInformationalVersion("VERSION_STRING")] diff --git a/Rebus.AdoNet/Schema/AdoNetTable.cs b/Rebus.AdoNet/Schema/AdoNetTable.cs index 1495f99..c903d49 100755 --- a/Rebus.AdoNet/Schema/AdoNetTable.cs +++ b/Rebus.AdoNet/Schema/AdoNetTable.cs @@ -1,19 +1,19 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Data; -using System.Data.Common; -using System.Text; - -namespace Rebus.AdoNet.Schema -{ - public class AdoNetTable - { - public string Name { get; set; } - public IEnumerable Columns { get; set; } - public string[] PrimaryKey { get; set; } - public IEnumerable Indexes { get; set; } - - public bool HasCompositePrimaryKey => PrimaryKey?.Count() > 1; - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Data; +using System.Data.Common; +using System.Text; + +namespace Rebus.AdoNet.Schema +{ + public class AdoNetTable + { + public string Name { get; set; } + public IEnumerable Columns { get; set; } + public string[] PrimaryKey { get; set; } + public IEnumerable Indexes { get; set; } + + public bool HasCompositePrimaryKey => PrimaryKey?.Count() > 1; + } +} diff --git a/Rebus.AdoNet/netfx/System/Guard.cs b/Rebus.AdoNet/netfx/System/Guard.cs index ff733b4..ab217bd 100755 --- a/Rebus.AdoNet/netfx/System/Guard.cs +++ b/Rebus.AdoNet/netfx/System/Guard.cs @@ -1,101 +1,101 @@ -#region BSD License -/* -Copyright (c) 2011, NETFx -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this list - of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, this - list of conditions and the following disclaimer in the documentation and/or other - materials provided with the distribution. - -* Neither the name of Clarius Consulting nor the names of its contributors may be - used to endorse or promote products derived from this software without specific - prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY -EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES -OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT -SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR -BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH -DAMAGE. -*/ -#endregion - -using System; -using System.Diagnostics; -using System.Linq.Expressions; - -/// -/// Common guard class for argument validation. -/// -/// -[DebuggerStepThrough] -static class Guard -{ - /// - /// Ensures the given is not null. - /// Throws otherwise. - /// - /// The is null. - public static void NotNull(Expression> reference, T value) - { - if (value == null) - throw new ArgumentNullException(GetParameterName(reference), "Parameter cannot be null."); - } - - /// - /// Ensures the given string is not null or empty. - /// Throws in the first case, or - /// in the latter. - /// - /// The is null or an empty string. - public static void NotNullOrEmpty(Expression> reference, string value) - { - NotNull(reference, value); - if (value.Length == 0) - throw new ArgumentException("Parameter cannot be empty.", GetParameterName(reference)); - } - - /// - /// Ensures the given string is valid according - /// to the function. Throws - /// otherwise. - /// - /// The is not valid according - /// to the function. - public static void IsValid(Expression> reference, T value, Func validate, string message) - { - if (!validate(value)) - throw new ArgumentException(message, GetParameterName(reference)); - } - - /// - /// Ensures the given string is valid according - /// to the function. Throws - /// otherwise. - /// - /// The is not valid according - /// to the function. - public static void IsValid(Expression> reference, T value, Func validate, string format, params object[] args) - { - if (!validate(value)) - throw new ArgumentException(string.Format(format, args), GetParameterName(reference)); - } - - private static string GetParameterName(Expression reference) - { - var lambda = reference as LambdaExpression; - var member = lambda.Body as MemberExpression; - - return member.Member.Name; - } +#region BSD License +/* +Copyright (c) 2011, NETFx +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list + of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or other + materials provided with the distribution. + +* Neither the name of Clarius Consulting nor the names of its contributors may be + used to endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT +SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. +*/ +#endregion + +using System; +using System.Diagnostics; +using System.Linq.Expressions; + +/// +/// Common guard class for argument validation. +/// +/// +[DebuggerStepThrough] +static class Guard +{ + /// + /// Ensures the given is not null. + /// Throws otherwise. + /// + /// The is null. + public static void NotNull(Expression> reference, T value) + { + if (value == null) + throw new ArgumentNullException(GetParameterName(reference), "Parameter cannot be null."); + } + + /// + /// Ensures the given string is not null or empty. + /// Throws in the first case, or + /// in the latter. + /// + /// The is null or an empty string. + public static void NotNullOrEmpty(Expression> reference, string value) + { + NotNull(reference, value); + if (value.Length == 0) + throw new ArgumentException("Parameter cannot be empty.", GetParameterName(reference)); + } + + /// + /// Ensures the given string is valid according + /// to the function. Throws + /// otherwise. + /// + /// The is not valid according + /// to the function. + public static void IsValid(Expression> reference, T value, Func validate, string message) + { + if (!validate(value)) + throw new ArgumentException(message, GetParameterName(reference)); + } + + /// + /// Ensures the given string is valid according + /// to the function. Throws + /// otherwise. + /// + /// The is not valid according + /// to the function. + public static void IsValid(Expression> reference, T value, Func validate, string format, params object[] args) + { + if (!validate(value)) + throw new ArgumentException(string.Format(format, args), GetParameterName(reference)); + } + + private static string GetParameterName(Expression reference) + { + var lambda = reference as LambdaExpression; + var member = lambda.Body as MemberExpression; + + return member.Member.Name; + } } \ No newline at end of file diff --git a/Rebus.AdoNet/packages.config b/Rebus.AdoNet/packages.config index e81bee4..d2e5194 100755 --- a/Rebus.AdoNet/packages.config +++ b/Rebus.AdoNet/packages.config @@ -1,8 +1,8 @@ - - - - - - - + + + + + + + \ No newline at end of file From 6f1674524794beeed4f27583fc5a05c7b8598ab4 Mon Sep 17 00:00:00 2001 From: Juanje Date: Mon, 18 Jul 2022 13:58:40 +0200 Subject: [PATCH 6/8] Added some coverage for schema creation like indexes created or dbtypes.. and added more coverage for main/strange properties being in use as correlations. Additionally, I've modified some stuff related to gitattributes behaviour and removed some whitespaces etc. --- Rebus.AdoNet.Tests/DatabaseFixtureBase.cs | 55 ++++ Rebus.AdoNet.Tests/SagaPersisterTests.cs | 295 ++++++++++++++------ Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs | 25 +- Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs | 23 +- Rebus.AdoNet/Dialects/SqlDialect.cs | 33 ++- Rebus.AdoNet/Dialects/TypeNames.cs | 12 +- 6 files changed, 325 insertions(+), 118 deletions(-) diff --git a/Rebus.AdoNet.Tests/DatabaseFixtureBase.cs b/Rebus.AdoNet.Tests/DatabaseFixtureBase.cs index 0771e42..a13c9b9 100644 --- a/Rebus.AdoNet.Tests/DatabaseFixtureBase.cs +++ b/Rebus.AdoNet.Tests/DatabaseFixtureBase.cs @@ -108,6 +108,61 @@ protected IEnumerable GetTableNames() } } + /// + /// Fetch the column names with their data types.. + /// where the Tuple.Item1 is the column name and the Tuple.Item2 is the data type. + /// + protected IEnumerable> GetColumnSchemaFor(string tableName) + { + using (var connection = Factory.CreateConnection()) + { + connection.ConnectionString = ConnectionString; + connection.Open(); + + // XXX: In order, to retrieve the schema information we can specify + // the catalog (0), schema (1), table name (2) and column name (3). + var restrictions = new string[4]; + restrictions[2] = tableName; + + var data = new List>(); + var schemas = connection.GetSchema("Columns", restrictions); + + foreach (DataRow row in schemas.Rows) + { + var name = row["COLUMN_NAME"] as string; + var type = row["DATA_TYPE"] as string; + data.Add(Tuple.Create(name, type)); + } + + return data.ToArray(); + } + } + + /// + /// Retrieve table's indexes for a specific table. + /// + protected IEnumerable GetIndexesFor(string tableName) + { + using (var connection = Factory.CreateConnection()) + { + connection.ConnectionString = ConnectionString; + connection.Open(); + + // XXX: In order, to retrieve the schema information we can specify + // the catalog (0), schema (1), table name (2) and column name (3). + var restrictions = new string[4]; + restrictions[2] = tableName; + + var data = new List(); + var schemas = connection.GetSchema("Indexes", restrictions); + + foreach (DataRow row in schemas.Rows) + data.Add(row["INDEX_NAME"] as string); + + return data.ToArray(); + } + } + protected void DropTable(string tableName) { if (!GetTableNames().Contains(tableName, StringComparer.InvariantCultureIgnoreCase)) return; diff --git a/Rebus.AdoNet.Tests/SagaPersisterTests.cs b/Rebus.AdoNet.Tests/SagaPersisterTests.cs index 1aeb9b1..6e2d7a2 100644 --- a/Rebus.AdoNet.Tests/SagaPersisterTests.cs +++ b/Rebus.AdoNet.Tests/SagaPersisterTests.cs @@ -2,6 +2,7 @@ using System.IO; using System.Data; using System.Linq; +using System.Collections; using System.Collections.Generic; using System.Reflection; @@ -109,6 +110,60 @@ class IEnumerableSagaData : ISagaData public int Revision { get; set; } } + private class ComplexSaga : ISagaData + { + #region Inner Types + + public enum Values + { + Unknown = 0, + Valid + } + + [Flags] + public enum Flags + { + Unknown = 1 << 0, + One = 1 << 1, + Two = 1 << 2 + } + + #endregion + + #region Properties + + public Guid Id { get; set; } + public int Revision { get; set; } + + public Tuple Tuple { get; set; } + public Guid Uuid { get; set; } + public char Char { get; set; } + public string Text { get; set; } + public bool Bool { get; set; } + public sbyte SByte { get; set; } + public byte Byte { get; set; } + public ushort UShort { get; set; } + public short Short { get; set; } + public uint UInt { get; set; } + public int Int { get; set; } + public ulong ULong { get; set; } + public long Long { get; set; } + public float Float { get; set; } + public double Double { get; set; } + public decimal Decimal { get; set; } + public DateTime Date { get; set; } + public TimeSpan Time { get; set; } + public Values Enum { get; set; } + public Flags EnumFlags { get; set; } + public object Object { get; set; } + public IEnumerable Strings { get; set; } + public IEnumerable Decimals { get; set; } + public IEnumerable Objects { get; set; } + public IDictionary Bag { get; set; } + + #endregion + } + [Flags] public enum Feature { @@ -122,12 +177,18 @@ public enum Feature private static readonly ILog _Log = LogManager.GetLogger(); + private static readonly IDictionary _correlations = typeof(ComplexSaga).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(x => x.Name != nameof(ISagaData.Id) && x.Name != nameof(ISagaData.Revision)) + .ToDictionary(x => x.Name, x => x); + private AdoNetConnectionFactory _factory; private AdoNetUnitOfWorkManager _manager; - private readonly Feature _features; + private readonly Feature _features; private const string SagaTableName = "Sagas"; private const string SagaIndexTableName = "SagasIndex"; + private AdoNetSagaPersister _persister; + public SagaPersisterTests(string provider, string connectionString, Feature features) : base(provider, connectionString) { @@ -195,7 +256,7 @@ protected AdoNetSagaPersister CreatePersister(bool basic = false) var result = _features.HasFlag(Feature.Json) ? new AdoNetSagaPersisterAdvanced(_manager, SagaTableName) as AdoNetSagaPersister : new AdoNetSagaPersisterLegacy(_manager, SagaTableName, SagaIndexTableName); - + if (!basic) { if (_features.HasFlag(Feature.Arrays)) result.UseSqlArraysForCorrelationIndexes(); @@ -203,6 +264,7 @@ protected AdoNetSagaPersister CreatePersister(bool basic = false) } return result; } + protected void DropSagaTables() { try @@ -221,7 +283,7 @@ protected void DropSagaTables() { } } - + [OneTimeSetUp] public void OneTimeSetup() { @@ -236,17 +298,16 @@ public void OneTimeSetup() ExecuteCommand("CREATE EXTENSION IF NOT EXISTS btree_gin;"); } - var persister = CreatePersister(); - persister.EnsureTablesAreCreated(); + _persister = CreatePersister(); + _persister.EnsureTablesAreCreated(); } - [SetUp] public new void SetUp() { if (!_features.HasFlag(Feature.Json)) ExecuteCommand($"DELETE FROM \"{SagaIndexTableName}\";"); ExecuteCommand($"DELETE FROM \"{SagaTableName}\";"); - + Disposables.TrackDisposable(EstablishMessageContext()); //< Initial (fake) message under each test will run. } @@ -255,11 +316,10 @@ public new void SetUp() [Test] public void InsertDoesPersistSagaData() { - var persister = CreatePersister(); var propertyName = Reflect.Path(d => d.PropertyThatCanBeNull); var dataWithIndexedNullProperty = new SomePieceOfSagaData { SomeValueWeCanRecognize = "hello" }; - persister.Insert(dataWithIndexedNullProperty, new[] { propertyName }); + _persister.Insert(dataWithIndexedNullProperty, new[] { propertyName }); var count = ExecuteScalar(string.Format("SELECT COUNT(*) FROM {0}", Dialect.QuoteForTableName(SagaTableName))); @@ -269,7 +329,7 @@ public void InsertDoesPersistSagaData() [Test] public void WhenIgnoringNullProperties_DoesNotSaveNullPropertiesOnUpdate() { - var persister = CreatePersister(); + var persister = _persister; persister.DoNotIndexNullProperties(); @@ -299,7 +359,7 @@ public void WhenIgnoringNullProperties_DoesNotSaveNullPropertiesOnUpdate() [Test] public void WhenIgnoringNullProperties_DoesNotSaveNullPropertiesOnInsert() { - var persister = CreatePersister(); + var persister = _persister; persister.DoNotIndexNullProperties(); @@ -339,43 +399,129 @@ public void WhenIgnoringNullProperties_DoesNotSaveNullPropertiesOnInsert() [Test] public void CanCreateSagaTablesAutomatically() { - // arrange - var persister = CreatePersister(); - - // act - persister.EnsureTablesAreCreated(); + var tableNames = GetTableNames(); + Assert.That(tableNames, Contains.Item(SagaTableName), "Missing main saga table?!"); - // assert - var existingTables = GetTableNames(); - Assert.That(existingTables, Contains.Item(SagaTableName)); - - if (persister is AdoNetSagaPersisterLegacy) - Assert.That(existingTables, Contains.Item(SagaIndexTableName)); - - // FIXME: Additional asserts depending on each case - // - Ensure index columns are text/array/json.. - // - Ensure right indexes were created.. + var schema = GetColumnSchemaFor(SagaTableName); //< (item1: COLUMN_NAME, item2: DATA_TYPE, [...?]) + var indexes = GetIndexesFor(SagaTableName); + Assert.Multiple(() => + { + Assert.That(Dialect.GetDbTypeFor(schema.First(x => x.Item1 == "id").Item2), Is.EqualTo(DbType.Guid), "#0.0"); + Assert.That(Dialect.GetDbTypeFor(schema.First(x => x.Item1 == "revision").Item2), Is.EqualTo(DbType.Int32), "#0.1"); + Assert.That(schema.First(x => x.Item1 == "saga_type").Item2, Is.EqualTo("varchar").Or.EqualTo("text"), "#0.2"); + Assert.That(schema.First(x => x.Item1 == "data").Item2, Is.EqualTo("varchar").Or.EqualTo("text"), "#0.3"); + Assert.That(indexes, Contains.Item($"ix_{SagaTableName}_id_saga_type"), "#0.4"); + }); + + if (_persister is AdoNetSagaPersisterLegacy) //< NOTE: DATA_TYPE for PostgresSQL arrays are _text instead of text[]. + { + Assert.Multiple(() => + { + var sagaIndexSchema = GetColumnSchemaFor(SagaIndexTableName); + var sagaIndexIndexes = GetIndexesFor(SagaIndexTableName); + var isArray = _features.HasFlag(Feature.Arrays); + Assert.That(tableNames, Contains.Item(SagaIndexTableName), "Missing saga indexes table?!"); + Assert.That(Dialect.GetDbTypeFor(sagaIndexSchema.First(x => x.Item1 == "saga_id").Item2), Is.EqualTo(DbType.Guid), "#1.0"); + Assert.That(sagaIndexSchema.First(x => x.Item1 == "key").Item2, Is.EqualTo("varchar").Or.EqualTo("text"), "#1.1"); + Assert.That(sagaIndexSchema.First(x => x.Item1 == "value").Item2, Is.EqualTo("varchar").Or.EqualTo("text"), "#1.2"); + Assert.That(sagaIndexSchema.First(x => x.Item1 == "values").Item2, Is.EqualTo(isArray ? "varchar[]" : "varchar").Or.EqualTo(isArray ? "_text" : "text"), "#1.3"); + Assert.That(sagaIndexIndexes, Contains.Item($"ix_{SagaIndexTableName}_key_value"), "#1.4"); + Assert.That(sagaIndexIndexes, Contains.Item($"ix_{SagaIndexTableName}_key_values"), "#1.5"); + }); + } + else if (_persister is AdoNetSagaPersisterAdvanced) + { + Assert.Multiple(() => + { + var correlationsType = schema.First(x => x.Item1 == "correlations").Item2; + Assert.That(tableNames, Does.Not.Contains(SagaIndexTableName), "Have we created saga indexes table?!"); + Assert.That(Dialect.GetDbTypeFor(correlationsType), Is.EqualTo(DbType.Object), "#2.0"); + Assert.That(correlationsType, Is.EqualTo("jsonb"), "#2.1"); + + if (Dialect.SupportsGinIndexes) + { + var ix = Dialect.SupportsMultiColumnGinIndexes ? $"ix_{SagaTableName}_saga_type_correlations" : $"ix_{SagaTableName}_correlations"; + Assert.That(indexes, Contains.Item(ix), "#2.2"); + } + }); + } + else //< If someday we add another persister.. we should add coverage for it. + { + throw new NotSupportedException("Missing coverage for a new persister?!?!"); + } } [Test] public void DoesntDoAnythingIfTheTablesAreAlreadyThere() { - // arrange - //DropSagaTables(); - var persister = CreatePersister(); - //ExecuteCommand(@"CREATE TABLE """ + SagaTableName + @""" (""id"" INT NOT NULL)"); - //ExecuteCommand(@"CREATE TABLE """ + SagaIndexTableName + @""" (""id"" INT NOT NULL)"); + Assert.That(() => + { + _persister.EnsureTablesAreCreated(); + _persister.EnsureTablesAreCreated(); + _persister.EnsureTablesAreCreated(); + }, Throws.Nothing); + } - // act + [Test] + public void SagaDataCanBeRecoveredWithDifferentKindOfValuesAsCorrelations() + { + var data = new ComplexSaga() + { + Id = Guid.NewGuid(), + Tuple = Tuple.Create("z", (object)129310.23D), + Uuid = Guid.NewGuid(), + Char = '\n', + Text = "this is a string w/ spanish characters like EÑE.", + Bool = true, + SByte = sbyte.MinValue, + Byte = byte.MaxValue, + UShort = ushort.MinValue, + Short = short.MaxValue, + UInt = uint.MinValue, + Int = int.MaxValue, + ULong = ulong.MinValue, + Long = long.MaxValue, + Float = float.NaN, + Double = double.PositiveInfinity, + Decimal = 10000.746525344148M, + Date = DateTime.UtcNow, + Time = TimeSpan.MaxValue, + Enum = ComplexSaga.Values.Valid, + EnumFlags = ComplexSaga.Flags.One | ComplexSaga.Flags.Two, + Object = new { one = 1, two = 2.7878798745M }, + Strings = new[] { "x", "y" }, + Decimals = new[] { 0.646584564M, 6.98984564544212M }, + Objects = new object[] { 'x', 0x1, float.Epsilon }, + Bag = new Dictionary() { { "a", 1 }, { "b", float.NaN } } + }; - // assert - persister.EnsureTablesAreCreated(); - persister.EnsureTablesAreCreated(); - persister.EnsureTablesAreCreated(); + _persister.Insert(data, _correlations.Keys.ToArray()); + + Assert.Multiple(() => + { + foreach (var correlation in _correlations) + { + var value = correlation.Value.GetValue(data); + var recovered = _persister.Find(correlation.Key, value); + Assert.That(recovered, Is.Not.Null, "Can't recover saga using correlation: {0}?!", correlation.Key); + + // XXX: If the property is a collection like an array.. we'll do an extra check (looking for the saga using an inner value). + // Using a KeyValuePair<,> as correlation is a corner case that we don't want to support. + if (value is IEnumerable enumerable && !(value is string) && !(value is IDictionary)) + { + foreach (var property in enumerable) + { + recovered = _persister.Find(correlation.Key, property); + Assert.That(recovered, Is.Not.Null, "Can't recover saga using correlation: {0} - {1}?!", correlation.Key, property); + } + } + + recovered = _persister.Find(correlation.Key, "None has this correlation"); + Assert.That(recovered, Is.Null, "Something went wrong using correlation: {0}?!", correlation.Key); + } + }); } - // FIXME: Add more tests with different kind of values (Dates, Decimal, Float, etc.), ensuring persisted entries can be found later on. - #endregion #region Advanced Saga Persister tests @@ -420,7 +566,7 @@ private IEnumerableSagaData EnumerableSagaData(int someNumber, IEnumerable(d => d.PropertyThatCanBeNull); var dataWithIndexedNullProperty = new SomePieceOfSagaData { Id = Guid.NewGuid(), SomeValueWeCanRecognize = "hello" }; @@ -439,7 +585,7 @@ public void CanFindAndUpdateSagaDataByCorrelationPropertyWithNull() [Test] public void PersisterCanFindSagaByPropertiesWithDifferentDataTypes() { - var persister = CreatePersister(); + var persister = _persister; TestFindSagaByPropertyWithType(persister, "Hello worlds!!"); TestFindSagaByPropertyWithType(persister, 23); TestFindSagaByPropertyWithType(persister, Guid.NewGuid()); @@ -448,7 +594,7 @@ public void PersisterCanFindSagaByPropertiesWithDifferentDataTypes() [Test] public void PersisterCanFindSagaById() { - var persister = CreatePersister(); + var persister = _persister; var savedSagaData = new MySagaData(); var savedSagaDataId = Guid.NewGuid(); savedSagaData.Id = savedSagaDataId; @@ -462,7 +608,7 @@ public void PersisterCanFindSagaById() [Test] public void PersistsComplexSagaLikeExpected() { - var persister = CreatePersister(); + var persister = _persister; var sagaDataId = Guid.NewGuid(); var complexPieceOfSagaData = @@ -496,7 +642,7 @@ public void CanDeleteSaga() { const string someStringValue = "whoolala"; - var persister = CreatePersister(); + var persister = _persister; var mySagaDataId = Guid.NewGuid(); var mySagaData = new SimpleSagaData { @@ -516,7 +662,7 @@ public void CanDeleteSaga() [Test] public void CanFindSagaByPropertyValues() { - var persister = CreatePersister(); + var persister = _persister; persister.Insert(SagaData(1, "some field 1"), new[] { "AnotherField" }); persister.Insert(SagaData(2, "some field 2"), new[] { "AnotherField" }); @@ -535,14 +681,14 @@ public void CanFindSagaByPropertyValues() [Test] public void CanFindSagaWithIEnumerableAsCorrelatorId() { - var persister = CreatePersister(); + var persister = _persister; persister.Insert(EnumerableSagaData(3, new string[] { "Field 1", "Field 2", "Field 3"}), new[] { "AnotherFields" }); var dataViaNonexistentValue = persister.Find("AnotherFields", "non-existent value"); var dataViaNonexistentField = persister.Find("SomeFieldThatDoesNotExist", "doesn't matter"); var mySagaData = persister.Find("AnotherFields", "Field 3"); - + Assert.That(dataViaNonexistentField, Is.Null); Assert.That(dataViaNonexistentValue, Is.Null); Assert.That(mySagaData, Is.Not.Null); @@ -552,7 +698,7 @@ public void CanFindSagaWithIEnumerableAsCorrelatorId() [Test] public void SamePersisterCanSaveMultipleTypesOfSagaDatas() { - var persister = CreatePersister(); + var persister = _persister; var sagaId1 = Guid.NewGuid(); var sagaId2 = Guid.NewGuid(); persister.Insert(new SimpleSagaData { Id = sagaId1, SomeString = "Ol�" }, new[] { "Id" }); @@ -571,7 +717,7 @@ public void PersisterCanFindSagaDataWithNestedElements() { const string stringValue = "I expect to find something with this string!"; - var persister = CreatePersister(); + var persister = _persister; var path = Reflect.Path(d => d.ThisOneIsNested.SomeString); persister.Insert(new SagaDataWithNestedElement @@ -608,7 +754,7 @@ public void CanUpdateSaga() // arrange const string theValue = "this is just some value"; - var persister = CreatePersister(); + var persister = _persister; var firstSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; var propertyPath = Reflect.Path(s => s.SomeCorrelationId); @@ -626,7 +772,7 @@ public void CanUpdateSaga() public void CannotInsertAnotherSagaWithDuplicateCorrelationId() { // arrange - var persister = CreatePersister(); + var persister = _persister; var theValue = "this just happens to be the same in two sagas"; var firstSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; var secondSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; @@ -648,8 +794,8 @@ public void CannotInsertAnotherSagaWithDuplicateCorrelationId() [Test] public void CannotUpdateAnotherSagaWithDuplicateCorrelationId() { - // arrange - var persister = CreatePersister(); + // arrange + var persister = _persister; var theValue = "this just happens to be the same in two sagas"; var firstSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; var secondSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = "other value" }; @@ -675,7 +821,7 @@ public void CannotUpdateAnotherSagaWithDuplicateCorrelationId() public void CanInsertAnotherSagaWithDuplicateCorrelationId() { // arrange - var persister = CreatePersister(); + var persister = _persister; var theValue = "this just happens to be the same in two sagas"; var firstSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; var secondSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; @@ -698,8 +844,8 @@ public void CanInsertAnotherSagaWithDuplicateCorrelationId() [Test] public void CanUpdateAnotherSagaWithDuplicateCorrelationId() { - // arrange - var persister = CreatePersister(); + // arrange + var persister = _persister; var theValue = "this just happens to be the same in two sagas"; var firstSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; var secondSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = "other value" }; @@ -725,7 +871,7 @@ public void CanUpdateAnotherSagaWithDuplicateCorrelationId() [Test] public void EnsuresUniquenessAlsoOnCorrelationPropertyWithNull() { - var persister = CreatePersister(); + var persister = _persister; var propertyName = Reflect.Path(d => d.PropertyThatCanBeNull); var dataWithIndexedNullProperty = new SomePieceOfSagaData { Id = Guid.NewGuid(), SomeValueWeCanRecognize = "hello" }; var anotherPieceOfDataWithIndexedNullProperty = new SomePieceOfSagaData { Id = Guid.NewGuid(), SomeValueWeCanRecognize = "hello" }; @@ -745,16 +891,16 @@ public void EnsuresUniquenessAlsoOnCorrelationPropertyWithNull() [Test] public void UsesOptimisticLockingAndDetectsRaceConditionsWhenUpdatingFindingBySomeProperty() { - var persister = CreatePersister(); + var persister = _persister; var indexBySomeString = new[] { "SomeString" }; var id = Guid.NewGuid(); var simpleSagaData = new SimpleSagaData { Id = id, SomeString = "hello world!" }; persister.Insert(simpleSagaData, indexBySomeString); var sagaData1 = persister.Find("SomeString", "hello world!"); - + Assert.That(sagaData1, Is.Not.Null); - + sagaData1.SomeString = "I changed this on one worker"; using (EnterAFakeMessageContext()) @@ -770,7 +916,7 @@ public void UsesOptimisticLockingAndDetectsRaceConditionsWhenUpdatingFindingBySo [Test] public void UsesOptimisticLockingAndDetectsRaceConditionsWhenUpdatingFindingById() { - var persister = CreatePersister(); + var persister = _persister; var indexBySomeString = new[] { "Id" }; var id = Guid.NewGuid(); var simpleSagaData = new SimpleSagaData { Id = id, SomeString = "hello world!" }; @@ -792,7 +938,7 @@ public void UsesOptimisticLockingAndDetectsRaceConditionsWhenUpdatingFindingById [Test] public void ConcurrentDeleteAndUpdateThrowsOnUpdate() { - var persister = CreatePersister(); + var persister = _persister; var indexBySomeString = new[] { "Id" }; var id = Guid.NewGuid(); var simpleSagaData = new SimpleSagaData { Id = id }; @@ -813,7 +959,7 @@ public void ConcurrentDeleteAndUpdateThrowsOnUpdate() [Test] public void ConcurrentDeleteAndUpdateThrowsOnDelete() { - var persister = CreatePersister(); + var persister = _persister; var indexBySomeString = new[] { "Id" }; var id = Guid.NewGuid(); var simpleSagaData = new SimpleSagaData { Id = id }; @@ -835,7 +981,7 @@ public void ConcurrentDeleteAndUpdateThrowsOnDelete() public void InsertingTheSameSagaDataTwiceGeneratesAnError() { // arrange - var persister = CreatePersister(); + var persister = _persister; var sagaDataPropertyPathsToIndex = new[] { Reflect.Path(d => d.Id) }; var sagaId = Guid.NewGuid(); @@ -856,7 +1002,7 @@ public void InsertingTheSameSagaDataTwiceGeneratesAnError() [Test] public void CanInsertTwoSagasUnderASingleUoW() { - var persister = CreatePersister(); + var persister = _persister; var sagaId1 = Guid.NewGuid(); var sagaId2 = Guid.NewGuid(); @@ -881,7 +1027,7 @@ public void CanInsertTwoSagasUnderASingleUoW() [Test] public void NoChangesAreMadeWhenUoWIsNotCommitted() { - var persister = CreatePersister(); + var persister = _persister; var sagaId1 = Guid.NewGuid(); var sagaId2 = Guid.NewGuid(); @@ -916,7 +1062,7 @@ public void FindSameSagaTwiceThrowsOnNoWait() return; } - var persister = CreatePersister(); + var persister = _persister; var savedSagaData = new MySagaData(); var savedSagaDataId = Guid.NewGuid(); savedSagaData.Id = savedSagaDataId; @@ -929,19 +1075,10 @@ public void FindSameSagaTwiceThrowsOnNoWait() Assert.Throws(() => { - //using (var thread = new CrossThreadRunner(() => - //{ - using (EnterAFakeMessageContext()) - //using (_manager.Create()) - //using (_manager.GetScope(autocomplete: true)) - { - //_manager.GetScope().Connection.ExecuteCommand("SET TRANSACTION ISOLATION LEVEL READ COMMITTED;"); - persister.Find("Id", savedSagaDataId); - } - //})) - //{ - // thread.Run(); - //} + using (EnterAFakeMessageContext()) + { + persister.Find("Id", savedSagaDataId); + } }); } } diff --git a/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs b/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs index b120bb4..17e54f0 100644 --- a/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs +++ b/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs @@ -19,7 +19,7 @@ namespace Rebus.AdoNet { /// /// Implements a saga persister for Rebus that stores sagas using an AdoNet provider. - /// This is an advanced implementation using single-table scheme for saga & indexes. + /// This is an advanced implementation using single-table scheme for saga & indexes. /// public class AdoNetSagaPersisterAdvanced : AdoNetSagaPersister, IStoreSagaData, AdoNetSagaPersisterFluentConfigurer, ICanUpdateMultipleSagaDatasAtomically { @@ -30,7 +30,8 @@ public class AdoNetSagaPersisterAdvanced : AdoNetSagaPersister, IStoreSagaData, private const string SAGA_REVISION_COLUMN = "revision"; private const string SAGA_CORRELATIONS_COLUMN = "correlations"; private static ILog log; - // FIXME? Maybe we should implement our own micro-serialization logic, so we can control actual conversions. + + // TODO?: Maybe we should implement our own micro-serialization logic, so we can control actual conversions. // I am thinking for example on issues with preccision on decimals, etc. (pruiz) private static readonly JsonSerializerSettings IndexSerializerSettings = new JsonSerializerSettings { Culture = CultureInfo.InvariantCulture, @@ -40,7 +41,7 @@ public class AdoNetSagaPersisterAdvanced : AdoNetSagaPersister, IStoreSagaData, new StringEnumConverter() } }; - + private readonly AdoNetUnitOfWorkManager manager; private readonly string sagasTableName; private readonly string idPropertyName; @@ -86,13 +87,13 @@ public override AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() log.Debug("Table '{0}' already exists.", sagasTableName); return this; } - + if (UseSqlArrays /* && !dialect.SupportsArrayTypes*/) { throw new ApplicationException("Enabled UseSqlArraysForCorrelationIndexes AdoNetSagaPersister selecte does not support arrays?!"); //throw new ApplicationException("Enabled UseSqlArraysForCorrelationIndexes but underlaying database does not support arrays?!"); } - + var indexes = new[] { new AdoNetIndex() { Name = $"ix_{sagasTableName}_{SAGA_ID_COLUMN}_{SAGA_TYPE_COLUMN}", @@ -119,7 +120,7 @@ public override AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() }); } } - + using (var command = connection.CreateCommand()) { command.CommandText = scope.Dialect.FormatCreateTable( @@ -237,7 +238,7 @@ public override void Update(ISagaData sagaData, string[] sagaDataPropertyPathsTo scope.Complete(); } } - + public override void Delete(ISagaData sagaData) { using (var scope = manager.GetScope()) @@ -358,7 +359,11 @@ protected override string Fetch(string sagaDataPropertyPath, object f s.{sagaCorrelationsCol} @> {dialect.Cast(sagaCorrelationsValuesParam, DbType.Object)} ) {forUpdate};".Replace("\t", ""); - + + var type = fieldFromMessage?.GetType(); + if (type != null && type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)) + throw new NotSupportedException("Using a KeyValuePair<,> as correlation is not supported."); + var value = SerializeCorrelations(new Dictionary() { { sagaDataPropertyPath, fieldFromMessage } }); var values = SerializeCorrelations(new Dictionary() { { sagaDataPropertyPath, new[] { fieldFromMessage } } }); @@ -366,7 +371,7 @@ protected override string Fetch(string sagaDataPropertyPath, object f command.AddParameter(sagaCorrelationsValueParam, DbType.String, value); command.AddParameter(sagaCorrelationsValuesParam, DbType.String, values); } - + try { log.Debug("Finding saga of type {0} with {1} = {2}\n{3}", sagaType, sagaDataPropertyPath, @@ -387,7 +392,7 @@ protected override string Fetch(string sagaDataPropertyPath, object f } } } - + #endregion } } diff --git a/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs b/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs index 1bb2c3d..7ec6def 100644 --- a/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs +++ b/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs @@ -36,7 +36,7 @@ public class AdoNetSagaPersisterLegacy : AdoNetSagaPersister, IStoreSagaData, Ad private readonly string sagasIndexTableName; private readonly string sagasTableName; private readonly string idPropertyName; - + static AdoNetSagaPersisterLegacy() { RebusLoggerFactory.Changed += f => log = f.GetCurrentClassLogger(); @@ -93,7 +93,7 @@ public override AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() } log.Info("Tables '{0}' and '{1}' do not exist - they will be created now", sagasTableName, sagasIndexTableName); - + using (var command = connection.CreateCommand()) { command.CommandText = scope.Dialect.FormatCreateTable( @@ -147,7 +147,7 @@ public override AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() } } ); - + command.ExecuteNonQuery(); } @@ -157,7 +157,7 @@ public override AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() return this; } - + #endregion public override void Insert(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) @@ -350,7 +350,7 @@ private void DeclareIndexUsingReturningClause(ISagaData sagaData, AdoNetUnitOfWo dialect.EscapeParameter(p.PropertyValueParameter), dialect.EscapeParameter(p.PropertyValuesParameter) )); - + using (var command = connection.CreateCommand()) { command.CommandText = string.Format( @@ -462,7 +462,6 @@ private void DeclareIndexUnoptimized(ISagaData sagaData, AdoNetUnitOfWorkScope s } catch (DbException exception) { - // FIXME: Ensure exception is the right one.. throw new OptimisticLockingException(sagaData, exception); } } @@ -473,7 +472,7 @@ private void DeclareIndexUnoptimized(ISagaData sagaData, AdoNetUnitOfWorkScope s using (var command = connection.CreateCommand()) { command.CommandText = string.Format( - "UPDATE {0} SET {1} = {2}, {3} = {4} " + + "UPDATE {0} SET {1} = {2}, {3} = {4} " + "WHERE {5} = {6} AND {7} = {8};", dialect.QuoteForTableName(sagasIndexTableName), //< 0 dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 1 @@ -675,7 +674,7 @@ private string GetSagaLockingClause(SqlDialect dialect) return string.Empty; } - + private bool ArraysEnabledFor(SqlDialect dialect) { return UseSqlArrays && dialect.SupportsArrayTypes; @@ -744,7 +743,7 @@ private static string GetConcatenatedIndexValues(IEnumerable values) return sb.ToString(); } - protected override string Fetch(string sagaDataPropertyPath, object fieldFromMessage) + protected override string Fetch(string sagaDataPropertyPath, object fieldFromMessage) { using (var scope = Manager.GetScope(autocomplete: true)) { @@ -760,7 +759,7 @@ protected override string Fetch(string sagaDataPropertyPath, object f if (UseNoWaitSagaLocking && !dialect.SupportsSelectForWithNoWait) throw new InvalidOperationException($"You can't use saga locking with no-wait for a Dialect {dialect.GetType()} that does not supports no-wait clause."); } - + using (var command = connection.CreateCommand()) { if (sagaDataPropertyPath == idPropertyName) @@ -801,7 +800,7 @@ protected override string Fetch(string sagaDataPropertyPath, object f var valuesPredicate = ArraysEnabledFor(dialect) ? dialect.FormatArrayAny($"i.{indexValuesCol}", indexValuesParm) : $"(i.{indexValuesCol} LIKE ('%' || {indexValuesParm} || '%'))"; - + command.CommandText = $@" SELECT s.{dataCol} FROM {sagaTblName} s @@ -819,7 +818,7 @@ protected override string Fetch(string sagaDataPropertyPath, object f END ) {forUpdate};".Replace("\t", ""); - + var value = GetIndexValue(fieldFromMessage); var values = value == null ? DBNull.Value : ArraysEnabledFor(dialect) ? (object)(new[] { value }) diff --git a/Rebus.AdoNet/Dialects/SqlDialect.cs b/Rebus.AdoNet/Dialects/SqlDialect.cs index a3bab0a..16dbca9 100644 --- a/Rebus.AdoNet/Dialects/SqlDialect.cs +++ b/Rebus.AdoNet/Dialects/SqlDialect.cs @@ -139,6 +139,11 @@ public virtual string GetLongestTypeName(DbType dbType) return _typeNames.GetLongest(dbType); } + /// + /// Reverse search for *testing purpose* in order to get (for each dialect) the related DBType.. + /// + internal virtual DbType GetDbTypeFor(string name) => _typeNames.Defaults.First(x => x.Value.ToLowerInvariant() == name.ToLowerInvariant()).Key; + #endregion #region Identifier quoting support @@ -201,14 +206,14 @@ public virtual string Qualify(string catalog, string schema, string table) ///

/// This method assumes that the name is not already Quoted. So if the name passed /// in is "name then it will return """name". It escapes the first char - /// - the " with "" and encloses the escaped string with OpenQuote and CloseQuote. + /// - the " with "" and encloses the escaped string with OpenQuote and CloseQuote. ///

/// protected virtual string Quote(string name) { string quotedName = name.Replace(OpenQuote.ToString(), new string(OpenQuote, 2)); - // in some dbs the Open and Close Quote are the same chars - if they are + // in some dbs the Open and Close Quote are the same chars - if they are // then we don't have to escape the Close Quote char because we already // got it. if (OpenQuote != CloseQuote) @@ -226,7 +231,7 @@ protected virtual string Quote(string name) /// Unquoted string /// ///

- /// This method checks the string quoted to see if it is + /// This method checks the string quoted to see if it is /// quoted. If the string quoted is already enclosed in the OpenQuote /// and CloseQuote then those chars are removed. ///

@@ -239,7 +244,7 @@ protected virtual string Quote(string name) /// The following quoted values return these results /// "quoted" = quoted /// "quote""d" = quote"d - /// quote""d = quote"d + /// quote""d = quote"d ///

///

/// If this implementation is not sufficient for your Dialect then it needs to be overridden. @@ -299,9 +304,9 @@ public virtual string[] UnQuote(string[] quoted) /// A Quoted name in the format of OpenQuote + aliasName + CloseQuote /// ///

- /// If the aliasName is already enclosed in the OpenQuote and CloseQuote then this + /// If the aliasName is already enclosed in the OpenQuote and CloseQuote then this /// method will return the aliasName that was passed in without going through any - /// Quoting process. So if aliasName is passed in already Quoted make sure that + /// Quoting process. So if aliasName is passed in already Quoted make sure that /// you have escaped all of the chars according to your DataBase's specifications. ///

///
@@ -318,9 +323,9 @@ public virtual string QuoteForAliasName(string aliasName) /// A Quoted name in the format of OpenQuote + columnName + CloseQuote /// ///

- /// If the columnName is already enclosed in the OpenQuote and CloseQuote then this + /// If the columnName is already enclosed in the OpenQuote and CloseQuote then this /// method will return the columnName that was passed in without going through any - /// Quoting process. So if columnName is passed in already Quoted make sure that + /// Quoting process. So if columnName is passed in already Quoted make sure that /// you have escaped all of the chars according to your DataBase's specifications. ///

///
@@ -336,9 +341,9 @@ public virtual string QuoteForColumnName(string columnName) /// A Quoted name in the format of OpenQuote + tableName + CloseQuote /// ///

- /// If the tableName is already enclosed in the OpenQuote and CloseQuote then this + /// If the tableName is already enclosed in the OpenQuote and CloseQuote then this /// method will return the tableName that was passed in without going through any - /// Quoting process. So if tableName is passed in already Quoted make sure that + /// Quoting process. So if tableName is passed in already Quoted make sure that /// you have escaped all of the chars according to your DataBase's specifications. ///

///
@@ -348,7 +353,7 @@ public virtual string QuoteForTableName(string tableName) } /// - /// Casts an SQL expression to db's DbType equivalent. + /// Casts an SQL expression to db's DbType equivalent. /// /// /// @@ -481,7 +486,7 @@ public virtual string FormatCreateIndex(AdoNetTable table, AdoNetIndex index) { columns = columns.Select(x => QuoteForColumnName(x)).ToArray(); } - + return string.Format("CREATE INDEX {0} ON {1} {2} ({3});", !string.IsNullOrEmpty(index.Name) ? QuoteForTableName(index.Name) : "", QuoteForTableName(table.Name), @@ -512,7 +517,7 @@ public virtual bool IsDuplicateKeyException(DbException ex) throw new NotImplementedException("IsDuplicateKeyException not implemented for this dialect!"); } #endregion - + #region AdvisoryLockFunctions public virtual bool SupportsTryAdvisoryLockFunction => false; public virtual bool SupportsTryAdvisoryXactLockFunction => false; @@ -548,7 +553,7 @@ public virtual string FormatArrayAny(string arg1, string arg2) throw new NotSupportedException("ArrayAny function not supported by this dialect."); } #endregion - + #region GIN Indexing public virtual bool SupportsGinIndexes => false; diff --git a/Rebus.AdoNet/Dialects/TypeNames.cs b/Rebus.AdoNet/Dialects/TypeNames.cs index aa32c0d..5cca450 100755 --- a/Rebus.AdoNet/Dialects/TypeNames.cs +++ b/Rebus.AdoNet/Dialects/TypeNames.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Data; using System.Text; @@ -33,7 +34,7 @@ namespace Rebus.AdoNet.Dialects /// would result in /// ///     Names.Get(DbType)           // --> "VARCHAR($l)" (will cause trouble) - ///     Names.Get(DbType,100)       // --> "VARCHAR(100)" + ///     Names.Get(DbType,100)       // --> "VARCHAR(100)" ///     Names.Get(DbType,1000)  // --> "VARCHAR(1000)" ///     Names.Get(DbType,10000) // --> "VARCHAR(10000)" /// @@ -48,7 +49,12 @@ public class TypeNames private readonly Dictionary defaults = new Dictionary(); /// - /// + /// Expose defaults as a readonly collection for testing purpose.. + /// + internal IReadOnlyDictionary Defaults => new ReadOnlyDictionary(defaults); + + /// + /// /// /// /// @@ -164,7 +170,7 @@ public void Put(DbType typecode, uint capacity, string value) } /// - /// + /// /// /// /// From 43ebfd154d0434217f5ee8039092067fea6f2762 Mon Sep 17 00:00:00 2001 From: Juanje Date: Tue, 19 Jul 2022 13:55:19 +0200 Subject: [PATCH 7/8] Requested changes.. --- .gitattributes | 6 +- Rebus.AdoNet.Tests/App.config | 22 +- Rebus.AdoNet.Tests/DatabaseFixtureBase.cs | 30 +- Rebus.AdoNet.Tests/Properties/AssemblyInfo.cs | 74 +- Rebus.AdoNet.Tests/SagaPersisterTests.cs | 379 ++-- Rebus.AdoNet.Tests/packages.config | 22 +- Rebus.AdoNet.sln | 54 +- Rebus.AdoNet/AdoNetSagaPersister.cs | 15 +- Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs | 805 ++++---- Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs | 1705 +++++++++-------- Rebus.AdoNet/Dialects/PostgreSql95Dialect.cs | 2 +- Rebus.AdoNet/Dialects/PostgreSqlDialect.cs | 14 +- Rebus.AdoNet/Dialects/SqlDialect.cs | 15 +- Rebus.AdoNet/Dialects/SqliteDialect.cs | 15 + Rebus.AdoNet/Dialects/TypeNames.cs | 356 ++-- Rebus.AdoNet/Dialects/YugabyteDbDialect.cs | 11 + .../Facilities/IDbConnectionExtensions.cs | 63 + Rebus.AdoNet/Properties/AssemblyInfo.cs | 78 +- Rebus.AdoNet/Rebus.AdoNet.csproj | 1 + Rebus.AdoNet/Schema/AdoNetColumn.cs | 44 +- Rebus.AdoNet/Schema/AdoNetIndex.cs | 60 +- Rebus.AdoNet/Schema/AdoNetTable.cs | 38 +- Rebus.AdoNet/netfx/System/Guard.cs | 200 +- Rebus.AdoNet/packages.config | 14 +- 24 files changed, 2069 insertions(+), 1954 deletions(-) create mode 100644 Rebus.AdoNet/Facilities/IDbConnectionExtensions.cs diff --git a/.gitattributes b/.gitattributes index 8b37475..028e9b4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,8 @@ # These files are text and should be normalized (convert crlf => lf) -*.cs text diff=csharp +#*.cs text diff=csharp *.xaml text *.csproj text -*.sln text +#*.sln text *.tt text *.ps1 text *.cmd text @@ -11,7 +11,7 @@ *.cshtml text *.html text *.js text -*.config text +#*.config text # Images should be treated as binary # (binary is a macro for -text -diff) diff --git a/Rebus.AdoNet.Tests/App.config b/Rebus.AdoNet.Tests/App.config index 6ec16dc..df3e459 100644 --- a/Rebus.AdoNet.Tests/App.config +++ b/Rebus.AdoNet.Tests/App.config @@ -1,11 +1,11 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/Rebus.AdoNet.Tests/DatabaseFixtureBase.cs b/Rebus.AdoNet.Tests/DatabaseFixtureBase.cs index a13c9b9..4e0f419 100644 --- a/Rebus.AdoNet.Tests/DatabaseFixtureBase.cs +++ b/Rebus.AdoNet.Tests/DatabaseFixtureBase.cs @@ -119,22 +119,7 @@ protected IEnumerable GetTableNames() connection.ConnectionString = ConnectionString; connection.Open(); - // XXX: In order, to retrieve the schema information we can specify - // the catalog (0), schema (1), table name (2) and column name (3). - var restrictions = new string[4]; - restrictions[2] = tableName; - - var data = new List>(); - var schemas = connection.GetSchema("Columns", restrictions); - - foreach (DataRow row in schemas.Rows) - { - var name = row["COLUMN_NAME"] as string; - var type = row["DATA_TYPE"] as string; - data.Add(Tuple.Create(name, type)); - } - - return data.ToArray(); + return connection.GetColumnSchemaFor(tableName); } } @@ -148,18 +133,7 @@ protected IEnumerable GetIndexesFor(string tableName) connection.ConnectionString = ConnectionString; connection.Open(); - // XXX: In order, to retrieve the schema information we can specify - // the catalog (0), schema (1), table name (2) and column name (3). - var restrictions = new string[4]; - restrictions[2] = tableName; - - var data = new List(); - var schemas = connection.GetSchema("Indexes", restrictions); - - foreach (DataRow row in schemas.Rows) - data.Add(row["INDEX_NAME"] as string); - - return data.ToArray(); + return connection.GetIndexesFor(tableName); } } diff --git a/Rebus.AdoNet.Tests/Properties/AssemblyInfo.cs b/Rebus.AdoNet.Tests/Properties/AssemblyInfo.cs index 0422338..722b091 100755 --- a/Rebus.AdoNet.Tests/Properties/AssemblyInfo.cs +++ b/Rebus.AdoNet.Tests/Properties/AssemblyInfo.cs @@ -1,37 +1,37 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Rebus.AdoNet.Tests")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Rebus.AdoNet.Tests")] -[assembly: AssemblyCopyright("Copyright © Evidencias Certificadas S.L. (2015~2016)")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("803f12ac-8946-4718-8167-cd27077fde46")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("0.0.0.0")] -[assembly: AssemblyFileVersion("0.0.0.0")] -[assembly: AssemblyInformationalVersion("VERSION_STRING")] +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Rebus.AdoNet.Tests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Rebus.AdoNet.Tests")] +[assembly: AssemblyCopyright("Copyright © Evidencias Certificadas S.L. (2015~2016)")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("803f12ac-8946-4718-8167-cd27077fde46")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("0.0.0.0")] +[assembly: AssemblyFileVersion("0.0.0.0")] +[assembly: AssemblyInformationalVersion("VERSION_STRING")] diff --git a/Rebus.AdoNet.Tests/SagaPersisterTests.cs b/Rebus.AdoNet.Tests/SagaPersisterTests.cs index 6e2d7a2..ac64736 100644 --- a/Rebus.AdoNet.Tests/SagaPersisterTests.cs +++ b/Rebus.AdoNet.Tests/SagaPersisterTests.cs @@ -54,6 +54,7 @@ class SagaDataWithNestedElement : ISagaData public Guid Id { get; set; } public int Revision { get; set; } public ThisOneIsNested ThisOneIsNested { get; set; } + public ThisOneIsNested ThisOneIsNestedToo { get; set; } } class ThisOneIsNested @@ -135,8 +136,8 @@ public enum Flags public Guid Id { get; set; } public int Revision { get; set; } - public Tuple Tuple { get; set; } public Guid Uuid { get; set; } + public Guid? NullableGuid { get; set; } public char Char { get; set; } public string Text { get; set; } public bool Bool { get; set; } @@ -158,8 +159,6 @@ public enum Flags public object Object { get; set; } public IEnumerable Strings { get; set; } public IEnumerable Decimals { get; set; } - public IEnumerable Objects { get; set; } - public IDictionary Bag { get; set; } #endregion } @@ -168,10 +167,10 @@ public enum Flags public enum Feature { None = 0, - Arrays = (1<<0), - Json = (1<<1), - Locking = (1<<2), - NoWait = (1<<3) + Arrays = (1 << 0), + Json = (1 << 1), + Locking = (1 << 2), + NoWait = (1 << 3) } #endregion @@ -239,13 +238,13 @@ public static IEnumerable GetConnectionSources() var @base = sources.Select(x => new[] { x.Provider, x.ConnectionString, Feature.None }); var extra1 = sources.Where(x => x.Features.HasFlag(Feature.Locking)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Locking) }); - var extra2 = sources.Where(x => x.Features.HasFlag(Feature.Locking|Feature.NoWait)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Locking|Feature.NoWait) }); + var extra2 = sources.Where(x => x.Features.HasFlag(Feature.Locking | Feature.NoWait)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Locking | Feature.NoWait) }); var extra3 = sources.Where(x => x.Features.HasFlag(Feature.Arrays)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Arrays) }); - var extra4 = sources.Where(x => x.Features.HasFlag(Feature.Arrays|Feature.Locking)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Arrays|Feature.Locking) }); - var extra5 = sources.Where(x => x.Features.HasFlag(Feature.Arrays|Feature.Locking|Feature.NoWait)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Arrays|Feature.Locking|Feature.NoWait) }); + var extra4 = sources.Where(x => x.Features.HasFlag(Feature.Arrays | Feature.Locking)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Arrays | Feature.Locking) }); + var extra5 = sources.Where(x => x.Features.HasFlag(Feature.Arrays | Feature.Locking | Feature.NoWait)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Arrays | Feature.Locking | Feature.NoWait) }); var extra6 = sources.Where(x => x.Features.HasFlag(Feature.Json)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Json) }); - var extra7 = sources.Where(x => x.Features.HasFlag(Feature.Json|Feature.Locking)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Json|Feature.Locking) }); - var extra8 = sources.Where(x => x.Features.HasFlag(Feature.Json|Feature.Locking|Feature.NoWait)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Json|Feature.Locking|Feature.NoWait) }); + var extra7 = sources.Where(x => x.Features.HasFlag(Feature.Json | Feature.Locking)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Json | Feature.Locking) }); + var extra8 = sources.Where(x => x.Features.HasFlag(Feature.Json | Feature.Locking | Feature.NoWait)).Select(x => new[] { x.Provider, x.ConnectionString, (Feature.Json | Feature.Locking | Feature.NoWait) }); return @base.Union(extra1).Union(extra2).Union(extra3).Union(extra4) .Union(extra5).Union(extra6).Union(extra7).Union(extra8); @@ -329,9 +328,7 @@ public void InsertDoesPersistSagaData() [Test] public void WhenIgnoringNullProperties_DoesNotSaveNullPropertiesOnUpdate() { - var persister = _persister; - - persister.DoNotIndexNullProperties(); + _persister.DoNotIndexNullProperties(); const string correlationProperty1 = "correlation property 1"; const string correlationProperty2 = "correlation property 2"; @@ -344,24 +341,22 @@ public void WhenIgnoringNullProperties_DoesNotSaveNullPropertiesOnUpdate() var firstPieceOfSagaDataWithNullValueOnProperty = new PieceOfSagaData { SomeProperty = correlationProperty1, AnotherProperty = "random12423" }; var nextPieceOfSagaDataWithNullValueOnProperty = new PieceOfSagaData { SomeProperty = correlationProperty2, AnotherProperty = "random38791387" }; - persister.Insert(firstPieceOfSagaDataWithNullValueOnProperty, correlationPropertyPaths); - persister.Insert(nextPieceOfSagaDataWithNullValueOnProperty, correlationPropertyPaths); + _persister.Insert(firstPieceOfSagaDataWithNullValueOnProperty, correlationPropertyPaths); + _persister.Insert(nextPieceOfSagaDataWithNullValueOnProperty, correlationPropertyPaths); - var firstPiece = persister.Find(Reflect.Path(s => s.SomeProperty), correlationProperty1); + var firstPiece = _persister.Find(Reflect.Path(s => s.SomeProperty), correlationProperty1); firstPiece.AnotherProperty = null; - persister.Update(firstPiece, correlationPropertyPaths); + _persister.Update(firstPiece, correlationPropertyPaths); - var nextPiece = persister.Find(Reflect.Path(s => s.SomeProperty), correlationProperty2); + var nextPiece = _persister.Find(Reflect.Path(s => s.SomeProperty), correlationProperty2); nextPiece.AnotherProperty = null; - persister.Update(nextPiece, correlationPropertyPaths); + _persister.Update(nextPiece, correlationPropertyPaths); } [Test] public void WhenIgnoringNullProperties_DoesNotSaveNullPropertiesOnInsert() { - var persister = _persister; - - persister.DoNotIndexNullProperties(); + _persister.DoNotIndexNullProperties(); const string correlationProperty1 = "correlation property 1"; const string correlationProperty2 = "correlation property 2"; @@ -371,79 +366,72 @@ public void WhenIgnoringNullProperties_DoesNotSaveNullPropertiesOnInsert() Reflect.Path(s => s.AnotherProperty) }; - var firstPieceOfSagaDataWithNullValueOnProperty = new PieceOfSagaData - { + var firstPieceOfSagaDataWithNullValueOnProperty = new PieceOfSagaData { SomeProperty = correlationProperty1 }; - var nextPieceOfSagaDataWithNullValueOnProperty = new PieceOfSagaData - { + var nextPieceOfSagaDataWithNullValueOnProperty = new PieceOfSagaData { SomeProperty = correlationProperty2 }; var firstId = firstPieceOfSagaDataWithNullValueOnProperty.Id; var nextId = nextPieceOfSagaDataWithNullValueOnProperty.Id; - persister.Insert(firstPieceOfSagaDataWithNullValueOnProperty, correlationPropertyPaths); + _persister.Insert(firstPieceOfSagaDataWithNullValueOnProperty, correlationPropertyPaths); // must not throw: - persister.Insert(nextPieceOfSagaDataWithNullValueOnProperty, correlationPropertyPaths); + _persister.Insert(nextPieceOfSagaDataWithNullValueOnProperty, correlationPropertyPaths); - var firstPiece = persister.Find(Reflect.Path(s => s.SomeProperty), correlationProperty1); - var nextPiece = persister.Find(Reflect.Path(s => s.SomeProperty), correlationProperty2); + var firstPiece = _persister.Find(Reflect.Path(s => s.SomeProperty), correlationProperty1); + var nextPiece = _persister.Find(Reflect.Path(s => s.SomeProperty), correlationProperty2); Assert.That(firstPiece.Id, Is.EqualTo(firstId)); Assert.That(nextPiece.Id, Is.EqualTo(nextId)); } [Test] - public void CanCreateSagaTablesAutomatically() + public void SchemaIsCreatedAsExpected() { var tableNames = GetTableNames(); Assert.That(tableNames, Contains.Item(SagaTableName), "Missing main saga table?!"); var schema = GetColumnSchemaFor(SagaTableName); //< (item1: COLUMN_NAME, item2: DATA_TYPE, [...?]) var indexes = GetIndexesFor(SagaTableName); - Assert.Multiple(() => - { - Assert.That(Dialect.GetDbTypeFor(schema.First(x => x.Item1 == "id").Item2), Is.EqualTo(DbType.Guid), "#0.0"); - Assert.That(Dialect.GetDbTypeFor(schema.First(x => x.Item1 == "revision").Item2), Is.EqualTo(DbType.Int32), "#0.1"); - Assert.That(schema.First(x => x.Item1 == "saga_type").Item2, Is.EqualTo("varchar").Or.EqualTo("text"), "#0.2"); - Assert.That(schema.First(x => x.Item1 == "data").Item2, Is.EqualTo("varchar").Or.EqualTo("text"), "#0.3"); - Assert.That(indexes, Contains.Item($"ix_{SagaTableName}_id_saga_type"), "#0.4"); - }); + + // NOTE: Some 'hacks' for postgres/yuga.. returned column type for strings are varchar(xxx).. + // remove the length. + var stringDbType = Dialect.GetColumnType(DbType.String).ToLower(); + if (stringDbType.EndsWith(")")) + stringDbType = stringDbType.Substring(0, stringDbType.IndexOf("(")); + + Assert.That(schema.First(x => x.Item1 == "id").Item2, Is.EqualTo(Dialect.GetColumnType(DbType.Guid).ToLower()), "#0.0"); + Assert.That(schema.First(x => x.Item1 == "revision").Item2, Is.EqualTo(Dialect.GetColumnType(DbType.Int32).ToLower()), "#0.1"); + Assert.That(schema.First(x => x.Item1 == "saga_type").Item2, Is.EqualTo(stringDbType), "#0.2"); + Assert.That(schema.First(x => x.Item1 == "data").Item2, Is.EqualTo("text"), "#0.3"); + Assert.That(indexes, Contains.Item($"ix_{SagaTableName}_id_saga_type"), "#0.4"); if (_persister is AdoNetSagaPersisterLegacy) //< NOTE: DATA_TYPE for PostgresSQL arrays are _text instead of text[]. { - Assert.Multiple(() => - { - var sagaIndexSchema = GetColumnSchemaFor(SagaIndexTableName); - var sagaIndexIndexes = GetIndexesFor(SagaIndexTableName); - var isArray = _features.HasFlag(Feature.Arrays); - Assert.That(tableNames, Contains.Item(SagaIndexTableName), "Missing saga indexes table?!"); - Assert.That(Dialect.GetDbTypeFor(sagaIndexSchema.First(x => x.Item1 == "saga_id").Item2), Is.EqualTo(DbType.Guid), "#1.0"); - Assert.That(sagaIndexSchema.First(x => x.Item1 == "key").Item2, Is.EqualTo("varchar").Or.EqualTo("text"), "#1.1"); - Assert.That(sagaIndexSchema.First(x => x.Item1 == "value").Item2, Is.EqualTo("varchar").Or.EqualTo("text"), "#1.2"); - Assert.That(sagaIndexSchema.First(x => x.Item1 == "values").Item2, Is.EqualTo(isArray ? "varchar[]" : "varchar").Or.EqualTo(isArray ? "_text" : "text"), "#1.3"); - Assert.That(sagaIndexIndexes, Contains.Item($"ix_{SagaIndexTableName}_key_value"), "#1.4"); - Assert.That(sagaIndexIndexes, Contains.Item($"ix_{SagaIndexTableName}_key_values"), "#1.5"); - }); + var sagaIndexSchema = GetColumnSchemaFor(SagaIndexTableName); + var sagaIndexIndexes = GetIndexesFor(SagaIndexTableName); + var isArray = _features.HasFlag(Feature.Arrays); + + Assert.That(tableNames, Contains.Item(SagaIndexTableName), "Missing saga indexes table?!"); + Assert.That(sagaIndexSchema.First(x => x.Item1 == "saga_id").Item2, Is.EqualTo(Dialect.GetColumnType(DbType.Guid).ToLower()), "#1.0"); + Assert.That(sagaIndexSchema.First(x => x.Item1 == "key").Item2, Is.EqualTo(stringDbType), "#1.1"); + Assert.That(sagaIndexSchema.First(x => x.Item1 == "value").Item2, Is.EqualTo("text"), "#1.2"); + Assert.That(sagaIndexSchema.First(x => x.Item1 == "values").Item2, Is.EqualTo(isArray ? "_text" : "text"), "#1.3"); + Assert.That(sagaIndexIndexes, Contains.Item($"ix_{SagaIndexTableName}_key_value"), "#1.4"); + Assert.That(sagaIndexIndexes, Contains.Item($"ix_{SagaIndexTableName}_key_values"), "#1.5"); } else if (_persister is AdoNetSagaPersisterAdvanced) { - Assert.Multiple(() => - { - var correlationsType = schema.First(x => x.Item1 == "correlations").Item2; - Assert.That(tableNames, Does.Not.Contains(SagaIndexTableName), "Have we created saga indexes table?!"); - Assert.That(Dialect.GetDbTypeFor(correlationsType), Is.EqualTo(DbType.Object), "#2.0"); - Assert.That(correlationsType, Is.EqualTo("jsonb"), "#2.1"); + var correlationsType = schema.First(x => x.Item1 == "correlations").Item2; + Assert.That(tableNames, Does.Not.Contains(SagaIndexTableName), "Have we created saga indexes table?!"); + Assert.That(correlationsType, Is.EqualTo("jsonb"), "#2.0"); - if (Dialect.SupportsGinIndexes) - { - var ix = Dialect.SupportsMultiColumnGinIndexes ? $"ix_{SagaTableName}_saga_type_correlations" : $"ix_{SagaTableName}_correlations"; - Assert.That(indexes, Contains.Item(ix), "#2.2"); - } - }); + var ix = Dialect.SupportsMultiColumnGinIndexes ? $"ix_{SagaTableName}_saga_type_correlations" : $"ix_{SagaTableName}_correlations"; + Assert.That(indexes, Contains.Item(ix), "#2.1"); } else //< If someday we add another persister.. we should add coverage for it. { @@ -468,8 +456,8 @@ public void SagaDataCanBeRecoveredWithDifferentKindOfValuesAsCorrelations() var data = new ComplexSaga() { Id = Guid.NewGuid(), - Tuple = Tuple.Create("z", (object)129310.23D), Uuid = Guid.NewGuid(), + NullableGuid = Guid.NewGuid(), Char = '\n', Text = "this is a string w/ spanish characters like EÑE.", Bool = true, @@ -488,11 +476,9 @@ public void SagaDataCanBeRecoveredWithDifferentKindOfValuesAsCorrelations() Time = TimeSpan.MaxValue, Enum = ComplexSaga.Values.Valid, EnumFlags = ComplexSaga.Flags.One | ComplexSaga.Flags.Two, - Object = new { one = 1, two = 2.7878798745M }, + Object = 2.7878798745M, Strings = new[] { "x", "y" }, - Decimals = new[] { 0.646584564M, 6.98984564544212M }, - Objects = new object[] { 'x', 0x1, float.Epsilon }, - Bag = new Dictionary() { { "a", 1 }, { "b", float.NaN } } + Decimals = new[] { 0.646584564M, 6.98984564544212M } }; _persister.Insert(data, _correlations.Keys.ToArray()); @@ -566,18 +552,17 @@ private IEnumerableSagaData EnumerableSagaData(int someNumber, IEnumerable(d => d.PropertyThatCanBeNull); var dataWithIndexedNullProperty = new SomePieceOfSagaData { Id = Guid.NewGuid(), SomeValueWeCanRecognize = "hello" }; - persister.Insert(dataWithIndexedNullProperty, new[] { propertyName }); - var sagaDataFoundViaNullProperty = persister.Find(propertyName, null); + _persister.Insert(dataWithIndexedNullProperty, new[] { propertyName }); + var sagaDataFoundViaNullProperty = _persister.Find(propertyName, null); Assert.That(sagaDataFoundViaNullProperty, Is.Not.Null, "Could not find saga data with (null) on the correlation property {0}", propertyName); Assert.That(sagaDataFoundViaNullProperty.SomeValueWeCanRecognize, Is.EqualTo("hello")); sagaDataFoundViaNullProperty.SomeValueWeCanRecognize = "hwello there!!1"; - persister.Update(sagaDataFoundViaNullProperty, new[] { propertyName }); - var sagaDataFoundAgainViaNullProperty = persister.Find(propertyName, null); + _persister.Update(sagaDataFoundViaNullProperty, new[] { propertyName }); + var sagaDataFoundAgainViaNullProperty = _persister.Find(propertyName, null); Assert.That(sagaDataFoundAgainViaNullProperty, Is.Not.Null, "Could not find saga data with (null) on the correlation property {0} after having updated it", propertyName); Assert.That(sagaDataFoundAgainViaNullProperty.SomeValueWeCanRecognize, Is.EqualTo("hwello there!!1")); } @@ -585,22 +570,20 @@ public void CanFindAndUpdateSagaDataByCorrelationPropertyWithNull() [Test] public void PersisterCanFindSagaByPropertiesWithDifferentDataTypes() { - var persister = _persister; - TestFindSagaByPropertyWithType(persister, "Hello worlds!!"); - TestFindSagaByPropertyWithType(persister, 23); - TestFindSagaByPropertyWithType(persister, Guid.NewGuid()); + TestFindSagaByPropertyWithType(_persister, "Hello worlds!!"); + TestFindSagaByPropertyWithType(_persister, 23); + TestFindSagaByPropertyWithType(_persister, Guid.NewGuid()); } [Test] public void PersisterCanFindSagaById() { - var persister = _persister; var savedSagaData = new MySagaData(); var savedSagaDataId = Guid.NewGuid(); savedSagaData.Id = savedSagaDataId; - persister.Insert(savedSagaData, new string[0]); + _persister.Insert(savedSagaData, new string[0]); - var foundSagaData = persister.Find("Id", savedSagaDataId); + var foundSagaData = _persister.Find("Id", savedSagaDataId); Assert.That(foundSagaData.Id, Is.EqualTo(savedSagaDataId)); } @@ -608,7 +591,6 @@ public void PersisterCanFindSagaById() [Test] public void PersistsComplexSagaLikeExpected() { - var persister = _persister; var sagaDataId = Guid.NewGuid(); var complexPieceOfSagaData = @@ -630,9 +612,9 @@ public void PersistsComplexSagaLikeExpected() } }; - persister.Insert(complexPieceOfSagaData, new[] { "SomeField" }); + _persister.Insert(complexPieceOfSagaData, new[] { "SomeField" }); - var sagaData = persister.Find("Id", sagaDataId); + var sagaData = _persister.Find("Id", sagaDataId); Assert.That(sagaData.SomeField, Is.EqualTo("hello")); Assert.That(sagaData.AnotherField, Is.EqualTo("world!")); } @@ -642,7 +624,6 @@ public void CanDeleteSaga() { const string someStringValue = "whoolala"; - var persister = _persister; var mySagaDataId = Guid.NewGuid(); var mySagaData = new SimpleSagaData { @@ -650,27 +631,25 @@ public void CanDeleteSaga() SomeString = someStringValue }; - persister.Insert(mySagaData, new[] { "SomeString" }); - var sagaDataToDelete = persister.Find("Id", mySagaDataId); + _persister.Insert(mySagaData, new[] { "SomeString" }); + var sagaDataToDelete = _persister.Find("Id", mySagaDataId); - persister.Delete(sagaDataToDelete); + _persister.Delete(sagaDataToDelete); - var sagaData = persister.Find("Id", mySagaDataId); + var sagaData = _persister.Find("Id", mySagaDataId); Assert.That(sagaData, Is.Null); } [Test] public void CanFindSagaByPropertyValues() { - var persister = _persister; + _persister.Insert(SagaData(1, "some field 1"), new[] { "AnotherField" }); + _persister.Insert(SagaData(2, "some field 2"), new[] { "AnotherField" }); + _persister.Insert(SagaData(3, "some field 3"), new[] { "AnotherField" }); - persister.Insert(SagaData(1, "some field 1"), new[] { "AnotherField" }); - persister.Insert(SagaData(2, "some field 2"), new[] { "AnotherField" }); - persister.Insert(SagaData(3, "some field 3"), new[] { "AnotherField" }); - - var dataViaNonexistentValue = persister.Find("AnotherField", "non-existent value"); - var dataViaNonexistentField = persister.Find("SomeFieldThatDoesNotExist", "doesn't matter"); - var mySagaData = persister.Find("AnotherField", "some field 2"); + var dataViaNonexistentValue = _persister.Find("AnotherField", "non-existent value"); + var dataViaNonexistentField = _persister.Find("SomeFieldThatDoesNotExist", "doesn't matter"); + var mySagaData = _persister.Find("AnotherField", "some field 2"); Assert.That(dataViaNonexistentField, Is.Null); Assert.That(dataViaNonexistentValue, Is.Null); @@ -681,13 +660,12 @@ public void CanFindSagaByPropertyValues() [Test] public void CanFindSagaWithIEnumerableAsCorrelatorId() { - var persister = _persister; - persister.Insert(EnumerableSagaData(3, new string[] { "Field 1", "Field 2", "Field 3"}), new[] { "AnotherFields" }); + _persister.Insert(EnumerableSagaData(3, new string[] { "Field 1", "Field 2", "Field 3" }), new[] { "AnotherFields" }); - var dataViaNonexistentValue = persister.Find("AnotherFields", "non-existent value"); - var dataViaNonexistentField = persister.Find("SomeFieldThatDoesNotExist", "doesn't matter"); - var mySagaData = persister.Find("AnotherFields", "Field 3"); + var dataViaNonexistentValue = _persister.Find("AnotherFields", "non-existent value"); + var dataViaNonexistentField = _persister.Find("SomeFieldThatDoesNotExist", "doesn't matter"); + var mySagaData = _persister.Find("AnotherFields", "Field 3"); Assert.That(dataViaNonexistentField, Is.Null); Assert.That(dataViaNonexistentValue, Is.Null); @@ -698,14 +676,13 @@ public void CanFindSagaWithIEnumerableAsCorrelatorId() [Test] public void SamePersisterCanSaveMultipleTypesOfSagaDatas() { - var persister = _persister; var sagaId1 = Guid.NewGuid(); var sagaId2 = Guid.NewGuid(); - persister.Insert(new SimpleSagaData { Id = sagaId1, SomeString = "Ol�" }, new[] { "Id" }); - persister.Insert(new MySagaData { Id = sagaId2, AnotherField = "Yipiie" }, new[] { "Id" }); + _persister.Insert(new SimpleSagaData { Id = sagaId1, SomeString = "Ol�" }, new[] { "Id" }); + _persister.Insert(new MySagaData { Id = sagaId2, AnotherField = "Yipiie" }, new[] { "Id" }); - var saga1 = persister.Find("Id", sagaId1); - var saga2 = persister.Find("Id", sagaId2); + var saga1 = _persister.Find("Id", sagaId1); + var saga2 = _persister.Find("Id", sagaId2); Assert.That(saga1.SomeString, Is.EqualTo("Ol�")); Assert.That(saga2.AnotherField, Is.EqualTo("Yipiie")); @@ -715,25 +692,43 @@ public void SamePersisterCanSaveMultipleTypesOfSagaDatas() [Test] public void PersisterCanFindSagaDataWithNestedElements() { - const string stringValue = "I expect to find something with this string!"; + var one = new SagaDataWithNestedElement() + { + Id = Guid.NewGuid(), + Revision = 1, + ThisOneIsNested = new ThisOneIsNested() { SomeString = "1" }, + ThisOneIsNestedToo = new ThisOneIsNested() { SomeString = "999" } + }; - var persister = _persister; - var path = Reflect.Path(d => d.ThisOneIsNested.SomeString); + var two = new SagaDataWithNestedElement() + { + Id = Guid.NewGuid(), + Revision = 2, + ThisOneIsNested = new ThisOneIsNested() { SomeString = "999" }, + ThisOneIsNestedToo = new ThisOneIsNested() { SomeString = "2" } + }; - persister.Insert(new SagaDataWithNestedElement + var notFound = new SagaDataWithNestedElement() { Id = Guid.NewGuid(), - Revision = 12, - ThisOneIsNested = new ThisOneIsNested - { - SomeString = stringValue - } - }, new[] { path }); + Revision = 404, + ThisOneIsNested = new ThisOneIsNested() { SomeString = "2" }, + ThisOneIsNestedToo = new ThisOneIsNested() { SomeString = "1" } + }; + + var pathOne = Reflect.Path(x => x.ThisOneIsNested.SomeString); + var pathTwo = Reflect.Path(x => x.ThisOneIsNestedToo.SomeString); - var loadedSagaData = persister.Find(path, stringValue); + _persister.Insert(one, new[] { pathOne }); + _persister.Insert(two, new[] { pathTwo }); + _persister.Insert(notFound, new[] { pathOne, pathTwo }); - Assert.That(loadedSagaData?.ThisOneIsNested, Is.Not.Null); - Assert.That(loadedSagaData?.ThisOneIsNested.SomeString, Is.EqualTo(stringValue)); + var recoveredOne = _persister.Find(pathOne, "1"); + var recoveredTwo = _persister.Find(pathTwo, "2"); + Assert.That(recoveredOne?.Id, Is.EqualTo(one.Id), "Expected to recovered one."); + Assert.That(recoveredTwo?.Id, Is.EqualTo(two.Id), "Expected to recovered one."); + Assert.That(_persister.Find(pathOne, "3"), Is.Null); + Assert.That(_persister.Find(pathTwo, "3"), Is.Null); } #endregion @@ -754,16 +749,15 @@ public void CanUpdateSaga() // arrange const string theValue = "this is just some value"; - var persister = _persister; var firstSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; var propertyPath = Reflect.Path(s => s.SomeCorrelationId); var pathsToIndex = new[] { propertyPath }; - persister.Insert(firstSaga, pathsToIndex); + _persister.Insert(firstSaga, pathsToIndex); - var sagaToUpdate = persister.Find(propertyPath, theValue); + var sagaToUpdate = _persister.Find(propertyPath, theValue); - Assert.DoesNotThrow(() => persister.Update(sagaToUpdate, pathsToIndex)); + Assert.DoesNotThrow(() => _persister.Update(sagaToUpdate, pathsToIndex)); } [Test, Description("We don't allow two sagas to have the same value of a property that is used to correlate with incoming messages, " + @@ -772,48 +766,46 @@ public void CanUpdateSaga() public void CannotInsertAnotherSagaWithDuplicateCorrelationId() { // arrange - var persister = _persister; var theValue = "this just happens to be the same in two sagas"; var firstSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; var secondSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; - if (persister is ICanUpdateMultipleSagaDatasAtomically) + if (_persister is ICanUpdateMultipleSagaDatasAtomically) { Assert.Ignore("Ignore test as persister does actually support multiple saga to be updated automically."); return; } var pathsToIndex = new[] { Reflect.Path(s => s.SomeCorrelationId) }; - persister.Insert(firstSaga, pathsToIndex); + _persister.Insert(firstSaga, pathsToIndex); // act // assert - Assert.Throws(() => persister.Insert(secondSaga, pathsToIndex)); + Assert.Throws(() => _persister.Insert(secondSaga, pathsToIndex)); } [Test] public void CannotUpdateAnotherSagaWithDuplicateCorrelationId() { - // arrange - var persister = _persister; + // arrange var theValue = "this just happens to be the same in two sagas"; var firstSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; var secondSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = "other value" }; - if (persister is ICanUpdateMultipleSagaDatasAtomically) - { + if (_persister is ICanUpdateMultipleSagaDatasAtomically) + { Assert.Ignore("Ignore test as persister does actually support multiple saga to be updated automically."); return; } var pathsToIndex = new[] { Reflect.Path(s => s.SomeCorrelationId) }; - persister.Insert(firstSaga, pathsToIndex); - persister.Insert(secondSaga, pathsToIndex); + _persister.Insert(firstSaga, pathsToIndex); + _persister.Insert(secondSaga, pathsToIndex); // act // assert secondSaga.SomeCorrelationId = theValue; - Assert.Throws(() => persister.Update(secondSaga, pathsToIndex)); + Assert.Throws(() => _persister.Update(secondSaga, pathsToIndex)); } [Test] @@ -821,12 +813,11 @@ public void CannotUpdateAnotherSagaWithDuplicateCorrelationId() public void CanInsertAnotherSagaWithDuplicateCorrelationId() { // arrange - var persister = _persister; var theValue = "this just happens to be the same in two sagas"; var firstSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; var secondSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; - if (!(persister is ICanUpdateMultipleSagaDatasAtomically)) + if (!(_persister is ICanUpdateMultipleSagaDatasAtomically)) { Assert.Ignore("Ignore test as persister does not support multiple saga to be updated automically."); return; @@ -835,8 +826,8 @@ public void CanInsertAnotherSagaWithDuplicateCorrelationId() var pathsToIndex = new[] { Reflect.Path(s => s.SomeCorrelationId) }; // act - persister.Insert(firstSaga, pathsToIndex); - persister.Insert(secondSaga, pathsToIndex); + _persister.Insert(firstSaga, pathsToIndex); + _persister.Insert(secondSaga, pathsToIndex); // assert } @@ -844,13 +835,12 @@ public void CanInsertAnotherSagaWithDuplicateCorrelationId() [Test] public void CanUpdateAnotherSagaWithDuplicateCorrelationId() { - // arrange - var persister = _persister; + // arrange var theValue = "this just happens to be the same in two sagas"; var firstSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = theValue }; var secondSaga = new SomeSaga { Id = Guid.NewGuid(), SomeCorrelationId = "other value" }; - if (!(persister is ICanUpdateMultipleSagaDatasAtomically)) + if (!(_persister is ICanUpdateMultipleSagaDatasAtomically)) { Assert.Ignore("Ignore test as persister does not support multiple saga to be updated automically."); return; @@ -859,11 +849,11 @@ public void CanUpdateAnotherSagaWithDuplicateCorrelationId() var pathsToIndex = new[] { Reflect.Path(s => s.SomeCorrelationId) }; // act - persister.Insert(firstSaga, pathsToIndex); - persister.Insert(secondSaga, pathsToIndex); + _persister.Insert(firstSaga, pathsToIndex); + _persister.Insert(secondSaga, pathsToIndex); secondSaga.SomeCorrelationId = theValue; - persister.Update(secondSaga, pathsToIndex); + _persister.Update(secondSaga, pathsToIndex); // assert } @@ -871,16 +861,15 @@ public void CanUpdateAnotherSagaWithDuplicateCorrelationId() [Test] public void EnsuresUniquenessAlsoOnCorrelationPropertyWithNull() { - var persister = _persister; var propertyName = Reflect.Path(d => d.PropertyThatCanBeNull); var dataWithIndexedNullProperty = new SomePieceOfSagaData { Id = Guid.NewGuid(), SomeValueWeCanRecognize = "hello" }; var anotherPieceOfDataWithIndexedNullProperty = new SomePieceOfSagaData { Id = Guid.NewGuid(), SomeValueWeCanRecognize = "hello" }; - persister.Insert(dataWithIndexedNullProperty, new[] { propertyName }); + _persister.Insert(dataWithIndexedNullProperty, new[] { propertyName }); Assert.That( - () => persister.Insert(anotherPieceOfDataWithIndexedNullProperty, new[] { propertyName }), - (persister is ICanUpdateMultipleSagaDatasAtomically) ? (IResolveConstraint)Throws.Nothing : Throws.Exception + () => _persister.Insert(anotherPieceOfDataWithIndexedNullProperty, new[] { propertyName }), + (_persister is ICanUpdateMultipleSagaDatasAtomically) ? (IResolveConstraint)Throws.Nothing : Throws.Exception ); } @@ -891,13 +880,12 @@ public void EnsuresUniquenessAlsoOnCorrelationPropertyWithNull() [Test] public void UsesOptimisticLockingAndDetectsRaceConditionsWhenUpdatingFindingBySomeProperty() { - var persister = _persister; var indexBySomeString = new[] { "SomeString" }; var id = Guid.NewGuid(); var simpleSagaData = new SimpleSagaData { Id = id, SomeString = "hello world!" }; - persister.Insert(simpleSagaData, indexBySomeString); + _persister.Insert(simpleSagaData, indexBySomeString); - var sagaData1 = persister.Find("SomeString", "hello world!"); + var sagaData1 = _persister.Find("SomeString", "hello world!"); Assert.That(sagaData1, Is.Not.Null); @@ -905,93 +893,89 @@ public void UsesOptimisticLockingAndDetectsRaceConditionsWhenUpdatingFindingBySo using (EnterAFakeMessageContext()) { - var sagaData2 = persister.Find("SomeString", "hello world!"); + var sagaData2 = _persister.Find("SomeString", "hello world!"); sagaData2.SomeString = "I changed this on another worker"; - persister.Update(sagaData2, indexBySomeString); + _persister.Update(sagaData2, indexBySomeString); } - Assert.Throws(() => persister.Insert(sagaData1, indexBySomeString)); + Assert.Throws(() => _persister.Insert(sagaData1, indexBySomeString)); } [Test] public void UsesOptimisticLockingAndDetectsRaceConditionsWhenUpdatingFindingById() { - var persister = _persister; var indexBySomeString = new[] { "Id" }; var id = Guid.NewGuid(); var simpleSagaData = new SimpleSagaData { Id = id, SomeString = "hello world!" }; - persister.Insert(simpleSagaData, indexBySomeString); + _persister.Insert(simpleSagaData, indexBySomeString); - var sagaData1 = persister.Find("Id", id); + var sagaData1 = _persister.Find("Id", id); sagaData1.SomeString = "I changed this on one worker"; using (EnterAFakeMessageContext()) - { - var sagaData2 = persister.Find("Id", id); + { + var sagaData2 = _persister.Find("Id", id); sagaData2.SomeString = "I changed this on another worker"; - persister.Update(sagaData2, indexBySomeString); + _persister.Update(sagaData2, indexBySomeString); } - Assert.Throws(() => persister.Insert(sagaData1, indexBySomeString)); + Assert.Throws(() => _persister.Insert(sagaData1, indexBySomeString)); } [Test] public void ConcurrentDeleteAndUpdateThrowsOnUpdate() { - var persister = _persister; var indexBySomeString = new[] { "Id" }; var id = Guid.NewGuid(); var simpleSagaData = new SimpleSagaData { Id = id }; - persister.Insert(simpleSagaData, indexBySomeString); - var sagaData1 = persister.Find("Id", id); + _persister.Insert(simpleSagaData, indexBySomeString); + var sagaData1 = _persister.Find("Id", id); sagaData1.SomeString = "Some new value"; using (EnterAFakeMessageContext()) { - var sagaData2 = persister.Find("Id", id); - persister.Delete(sagaData2); + var sagaData2 = _persister.Find("Id", id); + _persister.Delete(sagaData2); } - Assert.Throws(() => persister.Update(sagaData1, indexBySomeString)); + Assert.Throws(() => _persister.Update(sagaData1, indexBySomeString)); } [Test] public void ConcurrentDeleteAndUpdateThrowsOnDelete() { - var persister = _persister; var indexBySomeString = new[] { "Id" }; var id = Guid.NewGuid(); var simpleSagaData = new SimpleSagaData { Id = id }; - persister.Insert(simpleSagaData, indexBySomeString); - var sagaData1 = persister.Find("Id", id); + _persister.Insert(simpleSagaData, indexBySomeString); + var sagaData1 = _persister.Find("Id", id); using (EnterAFakeMessageContext()) { - var sagaData2 = persister.Find("Id", id); + var sagaData2 = _persister.Find("Id", id); sagaData2.SomeString = "Some new value"; - persister.Update(sagaData2, indexBySomeString); + _persister.Update(sagaData2, indexBySomeString); } - Assert.Throws(() => persister.Delete(sagaData1)); + Assert.Throws(() => _persister.Delete(sagaData1)); } [Test] public void InsertingTheSameSagaDataTwiceGeneratesAnError() { // arrange - var persister = _persister; var sagaDataPropertyPathsToIndex = new[] { Reflect.Path(d => d.Id) }; var sagaId = Guid.NewGuid(); - persister.Insert(new SimpleSagaData { Id = sagaId, Revision = 0, SomeString = "hello!" }, + _persister.Insert(new SimpleSagaData { Id = sagaId, Revision = 0, SomeString = "hello!" }, sagaDataPropertyPathsToIndex); // act // assert Assert.Throws( - () => persister.Insert(new SimpleSagaData { Id = sagaId, Revision = 0, SomeString = "hello!" }, + () => _persister.Insert(new SimpleSagaData { Id = sagaId, Revision = 0, SomeString = "hello!" }, sagaDataPropertyPathsToIndex)); } @@ -1002,22 +986,21 @@ public void InsertingTheSameSagaDataTwiceGeneratesAnError() [Test] public void CanInsertTwoSagasUnderASingleUoW() { - var persister = _persister; var sagaId1 = Guid.NewGuid(); var sagaId2 = Guid.NewGuid(); using (var uow = _manager.Create()) { - persister.Insert(new SimpleSagaData { Id = sagaId1, SomeString = "FirstSaga" }, new[] { "Id" }); - persister.Insert(new MySagaData { Id = sagaId2, AnotherField = "SecondSaga" }, new[] { "Id" }); + _persister.Insert(new SimpleSagaData { Id = sagaId1, SomeString = "FirstSaga" }, new[] { "Id" }); + _persister.Insert(new MySagaData { Id = sagaId2, AnotherField = "SecondSaga" }, new[] { "Id" }); uow.Commit(); } using (EnterAFakeMessageContext()) { - var saga1 = persister.Find("Id", sagaId1); - var saga2 = persister.Find("Id", sagaId2); + var saga1 = _persister.Find("Id", sagaId1); + var saga2 = _persister.Find("Id", sagaId2); Assert.That(saga1.SomeString, Is.EqualTo("FirstSaga")); Assert.That(saga2.AnotherField, Is.EqualTo("SecondSaga")); @@ -1027,22 +1010,21 @@ public void CanInsertTwoSagasUnderASingleUoW() [Test] public void NoChangesAreMadeWhenUoWIsNotCommitted() { - var persister = _persister; var sagaId1 = Guid.NewGuid(); var sagaId2 = Guid.NewGuid(); using (var uow = _manager.Create()) { - persister.Insert(new SimpleSagaData { Id = sagaId1, SomeString = "FirstSaga" }, new[] { "Id" }); - persister.Insert(new MySagaData { Id = sagaId2, AnotherField = "SecondSaga" }, new[] { "Id" }); + _persister.Insert(new SimpleSagaData { Id = sagaId1, SomeString = "FirstSaga" }, new[] { "Id" }); + _persister.Insert(new MySagaData { Id = sagaId2, AnotherField = "SecondSaga" }, new[] { "Id" }); // XXX: Purposedly not committed. } using (EnterAFakeMessageContext()) { - var saga1 = persister.Find("Id", sagaId1); - var saga2 = persister.Find("Id", sagaId2); + var saga1 = _persister.Find("Id", sagaId1); + var saga2 = _persister.Find("Id", sagaId2); Assert.That(saga1, Is.Null); Assert.That(saga2, Is.Null); @@ -1062,23 +1044,32 @@ public void FindSameSagaTwiceThrowsOnNoWait() return; } - var persister = _persister; var savedSagaData = new MySagaData(); var savedSagaDataId = Guid.NewGuid(); savedSagaData.Id = savedSagaDataId; - persister.Insert(savedSagaData, new string[0]); + _persister.Insert(savedSagaData, new string[0]); using (_manager.Create()) - using (_manager.GetScope(autocomplete: true)) + //using (_manager.GetScope(autocomplete: true)) //< XXX: May required for something but we don't know when. { - persister.Find("Id", savedSagaDataId); + _persister.Find("Id", savedSagaDataId); + Assert.Throws(() => { + //using (var thread = new CrossThreadRunner(() => + //{ using (EnterAFakeMessageContext()) + //using (_manager.Create()) + //using (_manager.GetScope(autocomplete: true)) { - persister.Find("Id", savedSagaDataId); + //_manager.GetScope().Connection.ExecuteCommand("SET TRANSACTION ISOLATION LEVEL READ COMMITTED;"); + _persister.Find("Id", savedSagaDataId); } + //})) + //{ + // thread.Run(); + //} }); } } diff --git a/Rebus.AdoNet.Tests/packages.config b/Rebus.AdoNet.Tests/packages.config index 4be238e..0ffcbb5 100644 --- a/Rebus.AdoNet.Tests/packages.config +++ b/Rebus.AdoNet.Tests/packages.config @@ -1,12 +1,12 @@ - - - - - - - - - - - + + + + + + + + + + + \ No newline at end of file diff --git a/Rebus.AdoNet.sln b/Rebus.AdoNet.sln index 182307e..6cbc977 100644 --- a/Rebus.AdoNet.sln +++ b/Rebus.AdoNet.sln @@ -1,27 +1,27 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.24720.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rebus.AdoNet", "Rebus.AdoNet\Rebus.AdoNet.csproj", "{03B56847-7469-4DB3-B146-2D29CE61663E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rebus.AdoNet.Tests", "Rebus.AdoNet.Tests\Rebus.AdoNet.Tests.csproj", "{803F12AC-8946-4718-8167-CD27077FDE46}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {03B56847-7469-4DB3-B146-2D29CE61663E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {03B56847-7469-4DB3-B146-2D29CE61663E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {03B56847-7469-4DB3-B146-2D29CE61663E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {03B56847-7469-4DB3-B146-2D29CE61663E}.Release|Any CPU.Build.0 = Release|Any CPU - {803F12AC-8946-4718-8167-CD27077FDE46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {803F12AC-8946-4718-8167-CD27077FDE46}.Debug|Any CPU.Build.0 = Debug|Any CPU - {803F12AC-8946-4718-8167-CD27077FDE46}.Release|Any CPU.ActiveCfg = Release|Any CPU - {803F12AC-8946-4718-8167-CD27077FDE46}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.24720.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rebus.AdoNet", "Rebus.AdoNet\Rebus.AdoNet.csproj", "{03B56847-7469-4DB3-B146-2D29CE61663E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rebus.AdoNet.Tests", "Rebus.AdoNet.Tests\Rebus.AdoNet.Tests.csproj", "{803F12AC-8946-4718-8167-CD27077FDE46}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {03B56847-7469-4DB3-B146-2D29CE61663E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03B56847-7469-4DB3-B146-2D29CE61663E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03B56847-7469-4DB3-B146-2D29CE61663E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03B56847-7469-4DB3-B146-2D29CE61663E}.Release|Any CPU.Build.0 = Release|Any CPU + {803F12AC-8946-4718-8167-CD27077FDE46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {803F12AC-8946-4718-8167-CD27077FDE46}.Debug|Any CPU.Build.0 = Debug|Any CPU + {803F12AC-8946-4718-8167-CD27077FDE46}.Release|Any CPU.ActiveCfg = Release|Any CPU + {803F12AC-8946-4718-8167-CD27077FDE46}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/Rebus.AdoNet/AdoNetSagaPersister.cs b/Rebus.AdoNet/AdoNetSagaPersister.cs index a3c2324..94e0958 100644 --- a/Rebus.AdoNet/AdoNetSagaPersister.cs +++ b/Rebus.AdoNet/AdoNetSagaPersister.cs @@ -209,20 +209,11 @@ protected string Serialize(ISagaData sagaData) return JsonConvert.SerializeObject(sagaData, Formatting.Indented, Settings); } - public virtual void Insert(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) - { - throw new NotImplementedException("Insert method implementation missing?1"); - } + public abstract void Insert(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex); - public virtual void Update(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) - { - throw new NotImplementedException("Update method implementation missing?1"); - } + public abstract void Update(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex); - public virtual void Delete(ISagaData sagaData) - { - throw new NotImplementedException("Delete method implementation missing?1"); - } + public abstract void Delete(ISagaData sagaData); protected abstract string Fetch(string sagaDataPropertyPath, object fieldFromMessage) where TSagaData : class, ISagaData; diff --git a/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs b/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs index 17e54f0..c7fcc6a 100644 --- a/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs +++ b/Rebus.AdoNet/AdoNetSagaPersisterAdvanced.cs @@ -1,399 +1,436 @@ -#if true -using System; -using System.Data; -using System.Linq; -using System.Data.Common; -using System.Collections; -using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.RegularExpressions; -using System.Globalization; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Rebus.Logging; -using Rebus.AdoNet.Schema; -using Rebus.AdoNet.Dialects; - -namespace Rebus.AdoNet -{ - /// - /// Implements a saga persister for Rebus that stores sagas using an AdoNet provider. - /// This is an advanced implementation using single-table scheme for saga & indexes. - /// - public class AdoNetSagaPersisterAdvanced : AdoNetSagaPersister, IStoreSagaData, AdoNetSagaPersisterFluentConfigurer, ICanUpdateMultipleSagaDatasAtomically - { - private const int MaximumSagaDataTypeNameLength = 80; - private const string SAGA_ID_COLUMN = "id"; - private const string SAGA_TYPE_COLUMN = "saga_type"; - private const string SAGA_DATA_COLUMN = "data"; - private const string SAGA_REVISION_COLUMN = "revision"; - private const string SAGA_CORRELATIONS_COLUMN = "correlations"; - private static ILog log; - - // TODO?: Maybe we should implement our own micro-serialization logic, so we can control actual conversions. - // I am thinking for example on issues with preccision on decimals, etc. (pruiz) - private static readonly JsonSerializerSettings IndexSerializerSettings = new JsonSerializerSettings { - Culture = CultureInfo.InvariantCulture, - TypeNameHandling = TypeNameHandling.None, // TODO: Make it configurable? - DateFormatHandling = DateFormatHandling.IsoDateFormat, // TODO: Make it configurable? - Converters = new List() { - new StringEnumConverter() - } - }; - - private readonly AdoNetUnitOfWorkManager manager; - private readonly string sagasTableName; - private readonly string idPropertyName; - - static AdoNetSagaPersisterAdvanced() - { - RebusLoggerFactory.Changed += f => log = f.GetCurrentClassLogger(); - } - - /// - /// Constructs the persister with the ability to create connections to database using the specified connection string. - /// This also means that the persister will manage the connection by itself, closing it when it has stopped using it. - /// - public AdoNetSagaPersisterAdvanced(AdoNetUnitOfWorkManager manager, string sagasTableName) - : base(manager) - { - this.manager = manager; - this.sagasTableName = sagasTableName; - this.idPropertyName = Reflect.Path(x => x.Id); - } - - #region AdoNetSagaPersisterFluentConfigurer - - /// - /// Creates the necessary saga storage tables if they haven't already been created. If a table already exists - /// with a name that matches one of the desired table names, no action is performed (i.e. it is assumed that - /// the tables already exist). - /// - public override AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() - { - using (var uow = manager.Create(autonomous: true)) - using (var scope = (uow as AdoNetUnitOfWork).GetScope()) - { - var dialect = scope.Dialect; - var connection = scope.Connection; - var tableNames = scope.GetTableNames(); - - // bail out if there's already a table in the database with one of the names - var sagaTableIsAlreadyCreated = tableNames.Contains(sagasTableName, StringComparer.InvariantCultureIgnoreCase); - - if (sagaTableIsAlreadyCreated) - { - log.Debug("Table '{0}' already exists.", sagasTableName); - return this; - } - - if (UseSqlArrays /* && !dialect.SupportsArrayTypes*/) - { - throw new ApplicationException("Enabled UseSqlArraysForCorrelationIndexes AdoNetSagaPersister selecte does not support arrays?!"); - //throw new ApplicationException("Enabled UseSqlArraysForCorrelationIndexes but underlaying database does not support arrays?!"); - } - - var indexes = new[] { - new AdoNetIndex() { - Name = $"ix_{sagasTableName}_{SAGA_ID_COLUMN}_{SAGA_TYPE_COLUMN}", - Columns = new[] { SAGA_ID_COLUMN, SAGA_TYPE_COLUMN } - }, - }.ToList(); - - if (dialect.SupportsGinIndexes) - { - if (dialect.SupportsMultiColumnGinIndexes) - { - indexes.Add(new AdoNetIndex() { - Name = $"ix_{sagasTableName}_{SAGA_TYPE_COLUMN}_{SAGA_CORRELATIONS_COLUMN}", - Columns = new[] { SAGA_TYPE_COLUMN, SAGA_CORRELATIONS_COLUMN }, - Kind = AdoNetIndex.Kinds.GIN, - }); - } - else - { - indexes.Add(new AdoNetIndex() { - Name = $"ix_{sagasTableName}_{SAGA_CORRELATIONS_COLUMN}", - Columns = new[] { SAGA_CORRELATIONS_COLUMN }, - Kind = AdoNetIndex.Kinds.GIN, - }); - } - } - - using (var command = connection.CreateCommand()) - { - command.CommandText = scope.Dialect.FormatCreateTable( - new AdoNetTable() - { - Name = sagasTableName, - Columns = new [] - { - new AdoNetColumn() { Name = SAGA_ID_COLUMN, DbType = DbType.Guid }, - new AdoNetColumn() { Name = SAGA_TYPE_COLUMN, DbType = DbType.String, Length = MaximumSagaDataTypeNameLength }, - new AdoNetColumn() { Name = SAGA_REVISION_COLUMN, DbType = DbType.Int32 }, - new AdoNetColumn() { Name = SAGA_DATA_COLUMN, DbType = DbType.String, Length = 1073741823 }, - new AdoNetColumn() { Name = SAGA_CORRELATIONS_COLUMN, DbType = DbType.Object, } - }, - PrimaryKey = new[] { SAGA_ID_COLUMN }, - Indexes = indexes - } - ); - - log.Info("Table '{0}' do not exists - it will be created now using:\n{1}", sagasTableName, command.CommandText); - - command.ExecuteNonQuery(); - } - - scope.Complete(); - log.Info("Table '{0}' created", sagasTableName); - } - - return this; - } - - #endregion - - protected string SerializeCorrelations(IDictionary sagaData) - { - return JsonConvert.SerializeObject(sagaData, Formatting.Indented, IndexSerializerSettings); - } - public override void Insert(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) - { - using (var scope = manager.GetScope()) - using (var command = scope.Connection.CreateCommand()) - { - var dialect = scope.Dialect; - var sagaTypeName = GetSagaTypeName(sagaData.GetType()); - var propertiesToIndex = GetCorrelationItems(sagaData, sagaDataPropertyPathsToIndex); - var correlations = propertiesToIndex.Any() ? SerializeCorrelations(propertiesToIndex) : "{}"; - - // next insert the saga - command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); - command.AddParameter(dialect.EscapeParameter(SAGA_TYPE_COLUMN), sagaTypeName); - command.AddParameter(dialect.EscapeParameter(SAGA_REVISION_COLUMN), ++sagaData.Revision); - command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), Serialize(sagaData)); - command.AddParameter(dialect.EscapeParameter(SAGA_CORRELATIONS_COLUMN), correlations); - - command.CommandText = string.Format( - @"insert into {0} ({1}, {2}, {3}, {4}, {5}) values ({6}, {7}, {8}, {9}, {10});", - dialect.QuoteForTableName(sagasTableName), - dialect.QuoteForColumnName(SAGA_ID_COLUMN), - dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), - dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), - dialect.QuoteForColumnName(SAGA_DATA_COLUMN), - dialect.QuoteForColumnName(SAGA_CORRELATIONS_COLUMN), - dialect.EscapeParameter(SAGA_ID_COLUMN), - dialect.EscapeParameter(SAGA_TYPE_COLUMN), - dialect.EscapeParameter(SAGA_REVISION_COLUMN), - dialect.EscapeParameter(SAGA_DATA_COLUMN), - dialect.Cast(dialect.EscapeParameter(SAGA_CORRELATIONS_COLUMN), DbType.Object) +#if true +using System; +using System.Data; +using System.Linq; +using System.Data.Common; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Globalization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Rebus.Logging; +using Rebus.AdoNet.Schema; +using Rebus.AdoNet.Dialects; + +namespace Rebus.AdoNet +{ + /// + /// Implements a saga persister for Rebus that stores sagas using an AdoNet provider. + /// This is an advanced implementation using single-table scheme for saga & indexes. + /// + public class AdoNetSagaPersisterAdvanced : AdoNetSagaPersister, IStoreSagaData, AdoNetSagaPersisterFluentConfigurer, ICanUpdateMultipleSagaDatasAtomically + { + private const int MaximumSagaDataTypeNameLength = 80; + private const string SAGA_ID_COLUMN = "id"; + private const string SAGA_TYPE_COLUMN = "saga_type"; + private const string SAGA_DATA_COLUMN = "data"; + private const string SAGA_REVISION_COLUMN = "revision"; + private const string SAGA_CORRELATIONS_COLUMN = "correlations"; + private static ILog log; + + // TODO?: Maybe we should implement our own micro-serialization logic, so we can control actual conversions. + // I am thinking for example on issues with preccision on decimals, etc. (pruiz) + private static readonly JsonSerializerSettings IndexSerializerSettings = new JsonSerializerSettings { + Culture = CultureInfo.InvariantCulture, + TypeNameHandling = TypeNameHandling.None, // TODO: Make it configurable? + DateFormatHandling = DateFormatHandling.IsoDateFormat, // TODO: Make it configurable? + Converters = new List() { + new StringEnumConverter() + } + }; + + private readonly AdoNetUnitOfWorkManager manager; + private readonly string sagasTableName; + private readonly string idPropertyName; + + static AdoNetSagaPersisterAdvanced() + { + RebusLoggerFactory.Changed += f => log = f.GetCurrentClassLogger(); + } + + /// + /// Constructs the persister with the ability to create connections to database using the specified connection string. + /// This also means that the persister will manage the connection by itself, closing it when it has stopped using it. + /// + public AdoNetSagaPersisterAdvanced(AdoNetUnitOfWorkManager manager, string sagasTableName) + : base(manager) + { + this.manager = manager; + this.sagasTableName = sagasTableName; + this.idPropertyName = Reflect.Path(x => x.Id); + } + + #region AdoNetSagaPersisterFluentConfigurer + + /// + /// Creates the necessary saga storage tables if they haven't already been created. If a table already exists + /// with a name that matches one of the desired table names, no action is performed (i.e. it is assumed that + /// the tables already exist). + /// + public override AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() + { + using (var uow = manager.Create(autonomous: true)) + using (var scope = (uow as AdoNetUnitOfWork).GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var tableNames = scope.GetTableNames(); + + // bail out if there's already a table in the database with one of the names + var sagaTableIsAlreadyCreated = tableNames.Contains(sagasTableName, StringComparer.InvariantCultureIgnoreCase); + + if (sagaTableIsAlreadyCreated) + { + log.Debug("Table '{0}' already exists.", sagasTableName); + return this; + } + + if (UseSqlArrays /* && !dialect.SupportsArrayTypes*/) + { + throw new ApplicationException("Enabled UseSqlArraysForCorrelationIndexes AdoNetSagaPersister selecte does not support arrays?!"); + //throw new ApplicationException("Enabled UseSqlArraysForCorrelationIndexes but underlaying database does not support arrays?!"); + } + + var indexes = new[] { + new AdoNetIndex() { + Name = $"ix_{sagasTableName}_{SAGA_ID_COLUMN}_{SAGA_TYPE_COLUMN}", + Columns = new[] { SAGA_ID_COLUMN, SAGA_TYPE_COLUMN } + }, + }.ToList(); + + if (dialect.SupportsGinIndexes) + { + if (dialect.SupportsMultiColumnGinIndexes) + { + indexes.Add(new AdoNetIndex() { + Name = $"ix_{sagasTableName}_{SAGA_TYPE_COLUMN}_{SAGA_CORRELATIONS_COLUMN}", + Columns = new[] { SAGA_TYPE_COLUMN, SAGA_CORRELATIONS_COLUMN }, + Kind = AdoNetIndex.Kinds.GIN, + }); + } + else + { + indexes.Add(new AdoNetIndex() { + Name = $"ix_{sagasTableName}_{SAGA_CORRELATIONS_COLUMN}", + Columns = new[] { SAGA_CORRELATIONS_COLUMN }, + Kind = AdoNetIndex.Kinds.GIN, + }); + } + } + + using (var command = connection.CreateCommand()) + { + command.CommandText = scope.Dialect.FormatCreateTable( + new AdoNetTable() + { + Name = sagasTableName, + Columns = new [] + { + new AdoNetColumn() { Name = SAGA_ID_COLUMN, DbType = DbType.Guid }, + new AdoNetColumn() { Name = SAGA_TYPE_COLUMN, DbType = DbType.String, Length = MaximumSagaDataTypeNameLength }, + new AdoNetColumn() { Name = SAGA_REVISION_COLUMN, DbType = DbType.Int32 }, + new AdoNetColumn() { Name = SAGA_DATA_COLUMN, DbType = DbType.String, Length = 1073741823 }, + new AdoNetColumn() { Name = SAGA_CORRELATIONS_COLUMN, DbType = DbType.Object, } + }, + PrimaryKey = new[] { SAGA_ID_COLUMN }, + Indexes = indexes + } + ); + + log.Info("Table '{0}' do not exists - it will be created now using:\n{1}", sagasTableName, command.CommandText); + + command.ExecuteNonQuery(); + } + + scope.Complete(); + log.Info("Table '{0}' created", sagasTableName); + } + + return this; + } + + #endregion + + protected string SerializeCorrelations(IDictionary sagaData) + { + return JsonConvert.SerializeObject(sagaData, Formatting.Indented, IndexSerializerSettings); + } + public override void Insert(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) + { + using (var scope = manager.GetScope()) + using (var command = scope.Connection.CreateCommand()) + { + var dialect = scope.Dialect; + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + var propertiesToIndex = GetCorrelationItems(sagaData, sagaDataPropertyPathsToIndex); + var correlations = propertiesToIndex.Any() ? SerializeCorrelations(propertiesToIndex) : "{}"; + + // next insert the saga + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter(SAGA_TYPE_COLUMN), sagaTypeName); + command.AddParameter(dialect.EscapeParameter(SAGA_REVISION_COLUMN), ++sagaData.Revision); + command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), Serialize(sagaData)); + command.AddParameter(dialect.EscapeParameter(SAGA_CORRELATIONS_COLUMN), correlations); + + command.CommandText = string.Format( + @"insert into {0} ({1}, {2}, {3}, {4}, {5}) values ({6}, {7}, {8}, {9}, {10});", + dialect.QuoteForTableName(sagasTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), + dialect.QuoteForColumnName(SAGA_CORRELATIONS_COLUMN), + dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.EscapeParameter(SAGA_TYPE_COLUMN), + dialect.EscapeParameter(SAGA_REVISION_COLUMN), + dialect.EscapeParameter(SAGA_DATA_COLUMN), + dialect.Cast(dialect.EscapeParameter(SAGA_CORRELATIONS_COLUMN), DbType.Object) + ); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) + { + throw new OptimisticLockingException(sagaData, exception); + } + + scope.Complete(); + } + } + + public override void Update(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) + { + using (var scope = manager.GetScope()) + using (var command = scope.Connection.CreateCommand()) + { + var dialect = scope.Dialect; + var items = GetCorrelationItems(sagaData, sagaDataPropertyPathsToIndex); + var correlations = items.Any() ? SerializeCorrelations(items) : "{}"; + + // next, update or insert the saga + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); + command.AddParameter(dialect.EscapeParameter("next_revision"), ++sagaData.Revision); + command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), Serialize(sagaData)); + command.AddParameter(dialect.EscapeParameter(SAGA_CORRELATIONS_COLUMN), correlations); + + command.CommandText = string.Format( + @"UPDATE {0} SET {1} = {2}, {3} = {4}, {5} = {6} " + + @"WHERE {7} = {8} AND {9} = {10};", + dialect.QuoteForTableName(sagasTableName), + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), dialect.EscapeParameter(SAGA_DATA_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("next_revision"), + dialect.QuoteForColumnName(SAGA_CORRELATIONS_COLUMN), dialect.Cast(dialect.EscapeParameter(SAGA_CORRELATIONS_COLUMN), DbType.Object), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") ); try { - command.ExecuteNonQuery(); + var rows = command.ExecuteNonQuery(); + if (rows == 0) + { + throw new OptimisticLockingException(sagaData); + } } - catch (DbException exception) - { - throw new OptimisticLockingException(sagaData, exception); - } - - scope.Complete(); - } - } - - public override void Update(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) + { + throw new OptimisticLockingException(sagaData, exception); + } + + scope.Complete(); + } + } + + public override void Delete(ISagaData sagaData) + { + using (var scope = manager.GetScope()) + using (var command = scope.Connection.CreateCommand()) + { + var dialect = scope.Dialect; + + command.CommandText = string.Format( + @"DELETE FROM {0} WHERE {1} = {2} AND {3} = {4};", + dialect.QuoteForTableName(sagasTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") + ); + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); + + var rows = command.ExecuteNonQuery(); + if (rows == 0) + { + throw new OptimisticLockingException(sagaData); + } + + scope.Complete(); + } + } + + #region Fetch + private string GetSagaLockingClause(SqlDialect dialect) + { + if (UseSagaLocking) + { + return UseNoWaitSagaLocking + ? $"{dialect.SelectForUpdateClause} {dialect.SelectForNoWaitClause}" + : dialect.SelectForUpdateClause; + } + + return string.Empty; + } + + private bool ShouldIndexValue(object value) + { + if (IndexNullProperties) + return true; + + if (value == null) return false; + if (value is string) return true; + if ((value is IEnumerable) && !(value as IEnumerable).Cast().Any()) return false; + + return true; + } + + private IDictionary GetCorrelationItems(ISagaData sagaData, IEnumerable sagaDataPropertyPathsToIndex) + { + return sagaDataPropertyPathsToIndex + .Select(x => new { Key = x, Value = Reflect.Value(sagaData, x) }) + .Where(ShouldIndexValue) + .ToDictionary(x => x.Key, x => x.Value); + } + + private static void Validate(object correlation) { - using (var scope = manager.GetScope()) - using (var command = scope.Connection.CreateCommand()) - { - var dialect = scope.Dialect; - var items = GetCorrelationItems(sagaData, sagaDataPropertyPathsToIndex); - var correlations = items.Any() ? SerializeCorrelations(items) : "{}"; - - // next, update or insert the saga - command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); - command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); - command.AddParameter(dialect.EscapeParameter("next_revision"), ++sagaData.Revision); - command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), Serialize(sagaData)); - command.AddParameter(dialect.EscapeParameter(SAGA_CORRELATIONS_COLUMN), correlations); + var type = correlation?.GetType(); + if (type == null) + return; - command.CommandText = string.Format( - @"UPDATE {0} SET {1} = {2}, {3} = {4}, {5} = {6} " + - @"WHERE {7} = {8} AND {9} = {10};", - dialect.QuoteForTableName(sagasTableName), - dialect.QuoteForColumnName(SAGA_DATA_COLUMN), dialect.EscapeParameter(SAGA_DATA_COLUMN), - dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("next_revision"), - dialect.QuoteForColumnName(SAGA_CORRELATIONS_COLUMN), dialect.Cast(dialect.EscapeParameter(SAGA_CORRELATIONS_COLUMN), DbType.Object), - dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), - dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") - ); - var rows = command.ExecuteNonQuery(); - if (rows == 0) - { - throw new OptimisticLockingException(sagaData); - } - - scope.Complete(); - } - } - - public override void Delete(ISagaData sagaData) - { - using (var scope = manager.GetScope()) - using (var command = scope.Connection.CreateCommand()) + if (type.IsArray) { - var dialect = scope.Dialect; - - command.CommandText = string.Format( - @"DELETE FROM {0} WHERE {1} = {2} AND {3} = {4};", - dialect.QuoteForTableName(sagasTableName), - dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), - dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") - ); - command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); - command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); - - var rows = command.ExecuteNonQuery(); - if (rows == 0) - { - throw new OptimisticLockingException(sagaData); - } - - scope.Complete(); + if (type.GetArrayRank() > 1) + throw new ArgumentOutOfRangeException("Multidimensional arrays are not supported."); + + type = type.GetElementType(); } - } - #region Fetch - private string GetSagaLockingClause(SqlDialect dialect) - { - if (UseSagaLocking) + type = Nullable.GetUnderlyingType(type) ?? type; + if (type.IsPrimitive || type.IsEnum) + return; + + if (type == typeof(string) || type == typeof(Guid) || type == typeof(DateTime) + || type == typeof(decimal) || type == typeof(TimeSpan)) { - return UseNoWaitSagaLocking - ? $"{dialect.SelectForUpdateClause} {dialect.SelectForNoWaitClause}" - : dialect.SelectForUpdateClause; + return; } - return string.Empty; - } - - private bool ShouldIndexValue(object value) - { - if (IndexNullProperties) - return true; - - if (value == null) return false; - if (value is string) return true; - if ((value is IEnumerable) && !(value as IEnumerable).Cast().Any()) return false; - - return true; - } - - private IDictionary GetCorrelationItems(ISagaData sagaData, IEnumerable sagaDataPropertyPathsToIndex) - { - return sagaDataPropertyPathsToIndex - .Select(x => new { Key = x, Value = Reflect.Value(sagaData, x) }) - .Where(ShouldIndexValue) - .ToDictionary(x => x.Key, x => x.Value); + throw new NotSupportedException($"Type {type.Name} is not supported as a correlation value."); } - - protected override string Fetch(string sagaDataPropertyPath, object fieldFromMessage) - { - using (var scope = manager.GetScope(autocomplete: true)) - using (var command = scope.Connection.CreateCommand()) - { - var dialect = scope.Dialect; - var sagaType = GetSagaTypeName(typeof(TSagaData)); - - if (UseSagaLocking) - { - if (!dialect.SupportsSelectForUpdate) - throw new InvalidOperationException( - $"You can't use saga locking for a Dialect {dialect.GetType()} that does not supports Select For Update."); - - if (UseNoWaitSagaLocking && !dialect.SupportsSelectForWithNoWait) - throw new InvalidOperationException( - $"You can't use saga locking with no-wait for a Dialect {dialect.GetType()} that does not supports no-wait clause."); - } - - if (sagaDataPropertyPath == idPropertyName) + + protected override string Fetch(string sagaDataPropertyPath, object fieldFromMessage) + { + using (var scope = manager.GetScope(autocomplete: true)) + using (var command = scope.Connection.CreateCommand()) + { + var dialect = scope.Dialect; + var sagaType = GetSagaTypeName(typeof(TSagaData)); + + if (UseSagaLocking) + { + if (!dialect.SupportsSelectForUpdate) + throw new InvalidOperationException( + $"You can't use saga locking for a Dialect {dialect.GetType()} that does not supports Select For Update."); + + if (UseNoWaitSagaLocking && !dialect.SupportsSelectForWithNoWait) + throw new InvalidOperationException( + $"You can't use saga locking with no-wait for a Dialect {dialect.GetType()} that does not supports no-wait clause."); + } + + if (sagaDataPropertyPath == idPropertyName) + { + var id = (fieldFromMessage is Guid) + ? (Guid)fieldFromMessage + : Guid.Parse(fieldFromMessage.ToString()); + var idParam = dialect.EscapeParameter("id"); + var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); + + command.CommandText = string.Format( + @"SELECT s.{0} FROM {1} s WHERE s.{2} = {3} AND s.{4} = {5} {6}", + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), + dialect.QuoteForTableName(sagasTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), + idParam, + dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), + sagaTypeParam, + GetSagaLockingClause(dialect) + ); + command.AddParameter(sagaTypeParam, sagaType); + command.AddParameter(idParam, id); + } + else { - var id = (fieldFromMessage is Guid) - ? (Guid)fieldFromMessage - : Guid.Parse(fieldFromMessage.ToString()); - var idParam = dialect.EscapeParameter("id"); - var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); - - command.CommandText = string.Format( - @"SELECT s.{0} FROM {1} s WHERE s.{2} = {3} AND s.{4} = {5} {6}", - dialect.QuoteForColumnName(SAGA_DATA_COLUMN), - dialect.QuoteForTableName(sagasTableName), - dialect.QuoteForColumnName(SAGA_ID_COLUMN), - idParam, - dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), - sagaTypeParam, - GetSagaLockingClause(dialect) - ); - command.AddParameter(sagaTypeParam, sagaType); - command.AddParameter(idParam, id); - } - else - { - var dataCol = dialect.QuoteForColumnName(SAGA_DATA_COLUMN); - var sagaTblName = dialect.QuoteForTableName(sagasTableName); - var sagaTypeCol = dialect.QuoteForColumnName(SAGA_TYPE_COLUMN); - var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); - var sagaCorrelationsCol = dialect.QuoteForColumnName(SAGA_CORRELATIONS_COLUMN); - var sagaCorrelationsValueParam = dialect.EscapeParameter("value"); - var sagaCorrelationsValuesParam = dialect.EscapeParameter("values"); - var forUpdate = GetSagaLockingClause(dialect); - - command.CommandText = $@" - SELECT s.{dataCol} - FROM {sagaTblName} s - WHERE s.{sagaTypeCol} = {sagaTypeParam} - AND ( - s.{sagaCorrelationsCol} @> {dialect.Cast(sagaCorrelationsValueParam, DbType.Object)} - OR - s.{sagaCorrelationsCol} @> {dialect.Cast(sagaCorrelationsValuesParam, DbType.Object)} - ) - {forUpdate};".Replace("\t", ""); - - var type = fieldFromMessage?.GetType(); - if (type != null && type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)) - throw new NotSupportedException("Using a KeyValuePair<,> as correlation is not supported."); - - var value = SerializeCorrelations(new Dictionary() { { sagaDataPropertyPath, fieldFromMessage } }); - var values = SerializeCorrelations(new Dictionary() { { sagaDataPropertyPath, new[] { fieldFromMessage } } }); - - command.AddParameter(sagaTypeParam, sagaType); - command.AddParameter(sagaCorrelationsValueParam, DbType.String, value); - command.AddParameter(sagaCorrelationsValuesParam, DbType.String, values); - } - - try - { - log.Debug("Finding saga of type {0} with {1} = {2}\n{3}", sagaType, sagaDataPropertyPath, - fieldFromMessage, command.CommandText); - return (string)command.ExecuteScalar(); - } - catch (DbException ex) - { - // When in no-wait saga-locking mode, inspect - // exception and rethrow ex as SagaLockedException. - if (UseSagaLocking && UseNoWaitSagaLocking) - { - if (dialect.IsSelectForNoWaitLockingException(ex)) - throw new AdoNetSagaLockedException(ex); - } - - throw; - } - } - } - - #endregion - } -} -#endif + Validate(correlation: fieldFromMessage); + + var dataCol = dialect.QuoteForColumnName(SAGA_DATA_COLUMN); + var sagaTblName = dialect.QuoteForTableName(sagasTableName); + var sagaTypeCol = dialect.QuoteForColumnName(SAGA_TYPE_COLUMN); + var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); + var sagaCorrelationsCol = dialect.QuoteForColumnName(SAGA_CORRELATIONS_COLUMN); + var sagaCorrelationsValueParam = dialect.EscapeParameter("value"); + var sagaCorrelationsValuesParam = dialect.EscapeParameter("values"); + var forUpdate = GetSagaLockingClause(dialect); + + command.CommandText = $@" + SELECT s.{dataCol} + FROM {sagaTblName} s + WHERE s.{sagaTypeCol} = {sagaTypeParam} + AND ( + s.{sagaCorrelationsCol} @> {dialect.Cast(sagaCorrelationsValueParam, DbType.Object)} + OR + s.{sagaCorrelationsCol} @> {dialect.Cast(sagaCorrelationsValuesParam, DbType.Object)} + ) + {forUpdate};".Replace("\t", ""); + + var value = SerializeCorrelations(new Dictionary() { { sagaDataPropertyPath, fieldFromMessage } }); + var values = SerializeCorrelations(new Dictionary() { { sagaDataPropertyPath, new[] { fieldFromMessage } } }); + + command.AddParameter(sagaTypeParam, sagaType); + command.AddParameter(sagaCorrelationsValueParam, DbType.String, value); + command.AddParameter(sagaCorrelationsValuesParam, DbType.String, values); + } + + try + { + log.Debug("Finding saga of type {0} with {1} = {2}\n{3}", sagaType, sagaDataPropertyPath, + fieldFromMessage, command.CommandText); + return (string)command.ExecuteScalar(); + } + catch (DbException ex) + { + // When in no-wait saga-locking mode, inspect + // exception and rethrow ex as SagaLockedException. + if (UseSagaLocking && UseNoWaitSagaLocking) + { + if (dialect.IsSelectForNoWaitLockingException(ex)) + throw new AdoNetSagaLockedException(ex); + } + + throw; + } + } + } + + #endregion + } +} +#endif diff --git a/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs b/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs index 7ec6def..e5f86ab 100644 --- a/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs +++ b/Rebus.AdoNet/AdoNetSagaPersisterLegacy.cs @@ -1,856 +1,881 @@ -using System; -using System.Data; -using System.Linq; -using System.Data.Common; -using System.Collections; -using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.RegularExpressions; -using Newtonsoft.Json; - -using Rebus.Logging; -using Rebus.Serialization; -using Rebus.Serialization.Json; -using Rebus.AdoNet.Schema; -using Rebus.AdoNet.Dialects; - -namespace Rebus.AdoNet -{ - /// - /// Implements a saga persister for Rebus that stores sagas using an AdoNet provider. - /// - public class AdoNetSagaPersisterLegacy : AdoNetSagaPersister, IStoreSagaData, AdoNetSagaPersisterFluentConfigurer, ICanUpdateMultipleSagaDatasAtomically - { - private const int MaximumSagaDataTypeNameLength = 80; - private const string SAGA_ID_COLUMN = "id"; - private const string SAGA_TYPE_COLUMN = "saga_type"; - private const string SAGA_DATA_COLUMN = "data"; - private const string SAGA_REVISION_COLUMN = "revision"; - private const string SAGAINDEX_ID_COLUMN = "saga_id"; - private const string SAGAINDEX_KEY_COLUMN = "key"; - private const string SAGAINDEX_VALUE_COLUMN = "value"; - private const string SAGAINDEX_VALUES_COLUMN = "values"; - private static ILog log; - - private readonly string sagasIndexTableName; - private readonly string sagasTableName; - private readonly string idPropertyName; - - static AdoNetSagaPersisterLegacy() - { - RebusLoggerFactory.Changed += f => log = f.GetCurrentClassLogger(); - } - - /// - /// Constructs the persister with the ability to create connections to database using the specified connection string. - /// This also means that the persister will manage the connection by itself, closing it when it has stopped using it. - /// - public AdoNetSagaPersisterLegacy(AdoNetUnitOfWorkManager manager, string sagasTableName, string sagasIndexTableName) - : base(manager) - { - this.sagasTableName = sagasTableName; - this.sagasIndexTableName = sagasIndexTableName; - this.idPropertyName = Reflect.Path(x => x.Id); - } - - #region AdoNetSagaPersisterFluentConfigurer - - /// - /// Creates the necessary saga storage tables if they haven't already been created. If a table already exists - /// with a name that matches one of the desired table names, no action is performed (i.e. it is assumed that - /// the tables already exist). - /// - public override AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() - { - using (var uow = Manager.Create(autonomous: true)) - using (var scope = (uow as AdoNetUnitOfWork).GetScope()) - { - var dialect = scope.Dialect; - var connection = scope.Connection; - var tableNames = scope.GetTableNames(); - - // bail out if there's already a table in the database with one of the names - var sagaTableIsAlreadyCreated = tableNames.Contains(sagasTableName, StringComparer.InvariantCultureIgnoreCase); - var sagaIndexTableIsAlreadyCreated = tableNames.Contains(sagasIndexTableName, StringComparer.OrdinalIgnoreCase); - - if (sagaTableIsAlreadyCreated && sagaIndexTableIsAlreadyCreated) - { - log.Debug("Tables '{0}' and '{1}' already exists.", sagasTableName, sagasIndexTableName); - return this; - } - - if (sagaTableIsAlreadyCreated || sagaIndexTableIsAlreadyCreated) - { - // if saga index is created, then saga table is not created and vice versa - throw new ApplicationException(string.Format("Table '{0}' do not exist - you have to create it manually", - sagaIndexTableIsAlreadyCreated ? sagasTableName : sagasIndexTableName)); - } - - if (UseSqlArrays && !dialect.SupportsArrayTypes) - { - throw new ApplicationException("Enabled UseSqlArraysForCorrelationIndexes but underlaying database does not support arrays?!"); - } - - log.Info("Tables '{0}' and '{1}' do not exist - they will be created now", sagasTableName, sagasIndexTableName); - - using (var command = connection.CreateCommand()) - { - command.CommandText = scope.Dialect.FormatCreateTable( - new AdoNetTable() - { - Name = sagasTableName, - Columns = new [] - { - new AdoNetColumn() { Name = SAGA_ID_COLUMN, DbType = DbType.Guid }, - new AdoNetColumn() { Name = SAGA_TYPE_COLUMN, DbType = DbType.String, Length = MaximumSagaDataTypeNameLength }, - new AdoNetColumn() { Name = SAGA_REVISION_COLUMN, DbType = DbType.Int32 }, - new AdoNetColumn() { Name = SAGA_DATA_COLUMN, DbType = DbType.String, Length = 1073741823 } - }, - PrimaryKey = new[] { SAGA_ID_COLUMN }, - Indexes = new [] - { - new AdoNetIndex() { Name = $"ix_{sagasTableName}_{SAGA_ID_COLUMN}_{SAGA_TYPE_COLUMN}", Columns = new [] { SAGA_ID_COLUMN, SAGA_TYPE_COLUMN } }, - } - } - ); - - command.ExecuteNonQuery(); - } - - using (var command = connection.CreateCommand()) - { - command.CommandText = scope.Dialect.FormatCreateTable( - new AdoNetTable() - { - Name = sagasIndexTableName, - Columns = new [] - { - new AdoNetColumn() { Name = SAGAINDEX_ID_COLUMN, DbType = DbType.Guid }, - new AdoNetColumn() { Name = SAGAINDEX_KEY_COLUMN, DbType = DbType.String, Length = 200 }, - new AdoNetColumn() { Name = SAGAINDEX_VALUE_COLUMN, DbType = DbType.String, Length = 1073741823, Nullable = true }, - new AdoNetColumn() { Name = SAGAINDEX_VALUES_COLUMN, DbType = DbType.String, Length = 1073741823, Nullable = true, Array = UseSqlArrays } - }, - PrimaryKey = new[] { SAGAINDEX_ID_COLUMN, SAGAINDEX_KEY_COLUMN }, - Indexes = new [] - { - new AdoNetIndex() - { - Name = $"ix_{sagasIndexTableName}_{SAGAINDEX_KEY_COLUMN}_{SAGAINDEX_VALUE_COLUMN}", - Columns = new[] { SAGAINDEX_KEY_COLUMN, SAGAINDEX_VALUE_COLUMN }, - }, - new AdoNetIndex() - { - Name = $"ix_{sagasIndexTableName}_{SAGAINDEX_KEY_COLUMN}_{SAGAINDEX_VALUES_COLUMN}", - Columns = new[] { SAGAINDEX_KEY_COLUMN, SAGAINDEX_VALUES_COLUMN }, - } - } - } - ); - - command.ExecuteNonQuery(); - } - - scope.Complete(); - log.Info("Tables '{0}' and '{1}' created", sagasTableName, sagasIndexTableName); - } - - return this; - } - - #endregion - - public override void Insert(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) - { - using (var scope = Manager.GetScope()) - { - var dialect = scope.Dialect; - var connection = scope.Connection; - var tableNames = scope.GetTableNames(); - var sagaTypeName = GetSagaTypeName(sagaData.GetType()); - - // next insert the saga - using (var command = connection.CreateCommand()) - { - command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); - command.AddParameter(dialect.EscapeParameter(SAGA_TYPE_COLUMN), sagaTypeName); - command.AddParameter(dialect.EscapeParameter(SAGA_REVISION_COLUMN), ++sagaData.Revision); - command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), Serialize(sagaData)); - - command.CommandText = string.Format( - @"insert into {0} ({1}, {2}, {3}, {4}) values ({5}, {6}, {7}, {8});", - dialect.QuoteForTableName(sagasTableName), - dialect.QuoteForColumnName(SAGA_ID_COLUMN), - dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), - dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), - dialect.QuoteForColumnName(SAGA_DATA_COLUMN), - dialect.EscapeParameter(SAGA_ID_COLUMN), - dialect.EscapeParameter(SAGA_TYPE_COLUMN), - dialect.EscapeParameter(SAGA_REVISION_COLUMN), - dialect.EscapeParameter(SAGA_DATA_COLUMN) +using System; +using System.Data; +using System.Linq; +using System.Data.Common; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; + +using Rebus.Logging; +using Rebus.Serialization; +using Rebus.Serialization.Json; +using Rebus.AdoNet.Schema; +using Rebus.AdoNet.Dialects; + +namespace Rebus.AdoNet +{ + /// + /// Implements a saga persister for Rebus that stores sagas using an AdoNet provider. + /// + public class AdoNetSagaPersisterLegacy : AdoNetSagaPersister, IStoreSagaData, AdoNetSagaPersisterFluentConfigurer, ICanUpdateMultipleSagaDatasAtomically + { + private const int MaximumSagaDataTypeNameLength = 80; + private const string SAGA_ID_COLUMN = "id"; + private const string SAGA_TYPE_COLUMN = "saga_type"; + private const string SAGA_DATA_COLUMN = "data"; + private const string SAGA_REVISION_COLUMN = "revision"; + private const string SAGAINDEX_ID_COLUMN = "saga_id"; + private const string SAGAINDEX_KEY_COLUMN = "key"; + private const string SAGAINDEX_VALUE_COLUMN = "value"; + private const string SAGAINDEX_VALUES_COLUMN = "values"; + private static ILog log; + + private readonly string sagasIndexTableName; + private readonly string sagasTableName; + private readonly string idPropertyName; + + static AdoNetSagaPersisterLegacy() + { + RebusLoggerFactory.Changed += f => log = f.GetCurrentClassLogger(); + } + + /// + /// Constructs the persister with the ability to create connections to database using the specified connection string. + /// This also means that the persister will manage the connection by itself, closing it when it has stopped using it. + /// + public AdoNetSagaPersisterLegacy(AdoNetUnitOfWorkManager manager, string sagasTableName, string sagasIndexTableName) + : base(manager) + { + this.sagasTableName = sagasTableName; + this.sagasIndexTableName = sagasIndexTableName; + this.idPropertyName = Reflect.Path(x => x.Id); + } + + #region AdoNetSagaPersisterFluentConfigurer + + /// + /// Creates the necessary saga storage tables if they haven't already been created. If a table already exists + /// with a name that matches one of the desired table names, no action is performed (i.e. it is assumed that + /// the tables already exist). + /// + public override AdoNetSagaPersisterFluentConfigurer EnsureTablesAreCreated() + { + using (var uow = Manager.Create(autonomous: true)) + using (var scope = (uow as AdoNetUnitOfWork).GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var tableNames = scope.GetTableNames(); + + // bail out if there's already a table in the database with one of the names + var sagaTableIsAlreadyCreated = tableNames.Contains(sagasTableName, StringComparer.InvariantCultureIgnoreCase); + var sagaIndexTableIsAlreadyCreated = tableNames.Contains(sagasIndexTableName, StringComparer.OrdinalIgnoreCase); + + if (sagaTableIsAlreadyCreated && sagaIndexTableIsAlreadyCreated) + { + log.Debug("Tables '{0}' and '{1}' already exists.", sagasTableName, sagasIndexTableName); + return this; + } + + if (sagaTableIsAlreadyCreated || sagaIndexTableIsAlreadyCreated) + { + // if saga index is created, then saga table is not created and vice versa + throw new ApplicationException(string.Format("Table '{0}' do not exist - you have to create it manually", + sagaIndexTableIsAlreadyCreated ? sagasTableName : sagasIndexTableName)); + } + + if (UseSqlArrays && !dialect.SupportsArrayTypes) + { + throw new ApplicationException("Enabled UseSqlArraysForCorrelationIndexes but underlaying database does not support arrays?!"); + } + + log.Info("Tables '{0}' and '{1}' do not exist - they will be created now", sagasTableName, sagasIndexTableName); + + using (var command = connection.CreateCommand()) + { + command.CommandText = scope.Dialect.FormatCreateTable( + new AdoNetTable() + { + Name = sagasTableName, + Columns = new [] + { + new AdoNetColumn() { Name = SAGA_ID_COLUMN, DbType = DbType.Guid }, + new AdoNetColumn() { Name = SAGA_TYPE_COLUMN, DbType = DbType.String, Length = MaximumSagaDataTypeNameLength }, + new AdoNetColumn() { Name = SAGA_REVISION_COLUMN, DbType = DbType.Int32 }, + new AdoNetColumn() { Name = SAGA_DATA_COLUMN, DbType = DbType.String, Length = 1073741823 } + }, + PrimaryKey = new[] { SAGA_ID_COLUMN }, + Indexes = new [] + { + new AdoNetIndex() { Name = $"ix_{sagasTableName}_{SAGA_ID_COLUMN}_{SAGA_TYPE_COLUMN}", Columns = new [] { SAGA_ID_COLUMN, SAGA_TYPE_COLUMN } }, + } + } + ); + + command.ExecuteNonQuery(); + } + + using (var command = connection.CreateCommand()) + { + command.CommandText = scope.Dialect.FormatCreateTable( + new AdoNetTable() + { + Name = sagasIndexTableName, + Columns = new [] + { + new AdoNetColumn() { Name = SAGAINDEX_ID_COLUMN, DbType = DbType.Guid }, + new AdoNetColumn() { Name = SAGAINDEX_KEY_COLUMN, DbType = DbType.String, Length = 200 }, + new AdoNetColumn() { Name = SAGAINDEX_VALUE_COLUMN, DbType = DbType.String, Length = 1073741823, Nullable = true }, + new AdoNetColumn() { Name = SAGAINDEX_VALUES_COLUMN, DbType = DbType.String, Length = 1073741823, Nullable = true, Array = UseSqlArrays } + }, + PrimaryKey = new[] { SAGAINDEX_ID_COLUMN, SAGAINDEX_KEY_COLUMN }, + Indexes = new [] + { + new AdoNetIndex() + { + Name = $"ix_{sagasIndexTableName}_{SAGAINDEX_KEY_COLUMN}_{SAGAINDEX_VALUE_COLUMN}", + Columns = new[] { SAGAINDEX_KEY_COLUMN, SAGAINDEX_VALUE_COLUMN }, + }, + new AdoNetIndex() + { + Name = $"ix_{sagasIndexTableName}_{SAGAINDEX_KEY_COLUMN}_{SAGAINDEX_VALUES_COLUMN}", + Columns = new[] { SAGAINDEX_KEY_COLUMN, SAGAINDEX_VALUES_COLUMN }, + } + } + } + ); + + command.ExecuteNonQuery(); + } + + scope.Complete(); + log.Info("Tables '{0}' and '{1}' created", sagasTableName, sagasIndexTableName); + } + + return this; + } + + #endregion + + public override void Insert(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) + { + using (var scope = Manager.GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var tableNames = scope.GetTableNames(); + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + + // next insert the saga + using (var command = connection.CreateCommand()) + { + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter(SAGA_TYPE_COLUMN), sagaTypeName); + command.AddParameter(dialect.EscapeParameter(SAGA_REVISION_COLUMN), ++sagaData.Revision); + command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), Serialize(sagaData)); + + command.CommandText = string.Format( + @"insert into {0} ({1}, {2}, {3}, {4}) values ({5}, {6}, {7}, {8});", + dialect.QuoteForTableName(sagasTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), + dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.EscapeParameter(SAGA_TYPE_COLUMN), + dialect.EscapeParameter(SAGA_REVISION_COLUMN), + dialect.EscapeParameter(SAGA_DATA_COLUMN) + ); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + + var propertiesToIndex = GetPropertiesToIndex(sagaData, sagaDataPropertyPathsToIndex); + + if (propertiesToIndex.Any()) + { + DeclareIndex(sagaData, scope, propertiesToIndex); + } + + scope.Complete(); + } + } + + public override void Update(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) + { + using (var scope = Manager.GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var tableNames = scope.GetTableNames(); + + // next, update or insert the saga + using (var command = connection.CreateCommand()) + { + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); + command.AddParameter(dialect.EscapeParameter("next_revision"), ++sagaData.Revision); + command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), Serialize(sagaData)); + + command.CommandText = string.Format( + @"UPDATE {0} SET {1} = {2}, {3} = {4} " + + @"WHERE {5} = {6} AND {7} = {8};", + dialect.QuoteForTableName(sagasTableName), + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), dialect.EscapeParameter(SAGA_DATA_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("next_revision"), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") ); try { - command.ExecuteNonQuery(); + var rows = command.ExecuteNonQuery(); + if (rows == 0) + { + throw new OptimisticLockingException(sagaData); + } } - catch (DbException exception) + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) { throw new OptimisticLockingException(sagaData, exception); + } + } + + var propertiesToIndex = GetPropertiesToIndex(sagaData, sagaDataPropertyPathsToIndex); + + if (propertiesToIndex.Any()) + { + DeclareIndex(sagaData, scope, propertiesToIndex); + } + + scope.Complete(); + } + } + + private void DeclareIndexUsingTableExpressions(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + var parameters = propertiesToIndex + .Select((p, i) => new + { + PropertyName = p.Key, + PropertyValue = p.Value, + PropertyNameParameter = string.Format("n{0}", i), + PropertyValueParameter = string.Format("v{0}", i), + PropertyValuesParameter = string.Format("vs{0}", i) + }) + .ToList(); + + var tuples = parameters + .Select(p => string.Format("({0}, {1}, {2}, {3})", + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), + dialect.EscapeParameter(p.PropertyNameParameter), + dialect.EscapeParameter(p.PropertyValueParameter), + dialect.EscapeParameter(p.PropertyValuesParameter) + )); + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "WITH existing AS (" + + "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {6} " + + "ON CONFLICT ({1}, {2}) DO UPDATE SET {3} = excluded.{3}, {4} = excluded.{4} " + + "RETURNING {2}) " + + "DELETE FROM {0} " + + "WHERE {1} = {5} AND {2} NOT IN (SELECT {2} FROM existing);", + dialect.QuoteForTableName(sagasIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 5 + string.Join(", ", tuples) //< 6 + ); + + foreach (var parameter in parameters) + { + var value = GetIndexValue(parameter.PropertyValue); + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); + + var values = ArraysEnabledFor(dialect) + ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); + var dbtype = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), dbtype, values); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + } + + private void DeclareIndexUsingReturningClause(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var existingKeys = Enumerable.Empty(); + + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + var parameters = propertiesToIndex + .Select((p, i) => new + { + PropertyName = p.Key, + PropertyValue = p.Value, + PropertyNameParameter = string.Format("n{0}", i), + PropertyValueParameter = string.Format("v{0}", i), + PropertyValuesParameter = string.Format("vs{0}", i) + }) + .ToList(); + + var tuples = parameters + .Select(p => string.Format("({0}, {1}, {2}, {3})", + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), + dialect.EscapeParameter(p.PropertyNameParameter), + dialect.EscapeParameter(p.PropertyValueParameter), + dialect.EscapeParameter(p.PropertyValuesParameter) + )); + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {5} " + + "ON CONFLICT ({1}, {2}) DO UPDATE SET {3} = excluded.{3}, {4} = excluded.{4} " + + "RETURNING {2};", + dialect.QuoteForTableName(sagasIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 + string.Join(", ", tuples) //< 5 + ); + + foreach (var parameter in parameters) + { + var value = GetIndexValue(parameter.PropertyValue); + var values = value == null ? null : ArraysEnabledFor(dialect) + ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), valuesDbType, values); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + using (var reader = command.ExecuteReader()) + { + existingKeys = reader.AsEnumerable(SAGAINDEX_KEY_COLUMN).ToArray(); + } + } + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + + var idx = 0; + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "DELETE FROM {0} " + + "WHERE {1} = {2} AND {3} NOT IN ({4});", + dialect.QuoteForTableName(sagasIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 3 + string.Join(", ", existingKeys.Select(k => dialect.EscapeParameter($"k{idx++}"))) + ); + + for (int i = 0; i < existingKeys.Count(); i++) + { + command.AddParameter(dialect.EscapeParameter($"k{i}"), DbType.StringFixedLength, existingKeys.ElementAt(i).Trim()); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + command.ExecuteNonQuery(); + } + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + } + + private void DeclareIndexUnoptimized(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var connection = scope.Connection; + var dialect = scope.Dialect; + var sagaTypeName = GetSagaTypeName(sagaData.GetType()); + + var idxTbl = dialect.QuoteForTableName(sagasIndexTableName); + var idCol = dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN); + var keyCol = dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN); + var valueCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); + var valuesCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); + + var idParam = dialect.EscapeParameter(SAGAINDEX_ID_COLUMN); + + var existingKeys = Enumerable.Empty(); + + // Let's fetch existing keys.. + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "SELECT {1} FROM {0} WHERE {2} = {3};", + dialect.QuoteForTableName(sagasIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 2 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN) //< 3 + ); + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + using (var reader = command.ExecuteReader()) + { + existingKeys = reader.AsEnumerable(SAGAINDEX_KEY_COLUMN).ToArray(); + } + } + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) + { + throw new OptimisticLockingException(sagaData, exception); + } + } + + // For each exisring key, update it's value.. + foreach (var key in existingKeys.Where(k => propertiesToIndex.Any(p => p.Key == k))) + { + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "UPDATE {0} SET {1} = {2}, {3} = {4} " + + "WHERE {5} = {6} AND {7} = {8};", + dialect.QuoteForTableName(sagasIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 1 + dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 3 + dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN), //< 4 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 5 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 6 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 7 + dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN) //< 8 + ); + + var value = GetIndexValue(propertiesToIndex[key]); + var values = ArraysEnabledFor(dialect) + ? (object)GetIndexValues(propertiesToIndex[key])?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(propertiesToIndex[key])); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN), DbType.String, key); + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN), DbType.String, value); + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN), valuesDbType, values); + + try + { + command.ExecuteNonQuery(); } - } - - var propertiesToIndex = GetPropertiesToIndex(sagaData, sagaDataPropertyPathsToIndex); - - if (propertiesToIndex.Any()) - { - DeclareIndex(sagaData, scope, propertiesToIndex); - } - - scope.Complete(); - } - } - - public override void Update(ISagaData sagaData, string[] sagaDataPropertyPathsToIndex) - { - using (var scope = Manager.GetScope()) - { - var dialect = scope.Dialect; - var connection = scope.Connection; - var tableNames = scope.GetTableNames(); - - // next, update or insert the saga - using (var command = connection.CreateCommand()) - { - command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); - command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); - command.AddParameter(dialect.EscapeParameter("next_revision"), ++sagaData.Revision); - command.AddParameter(dialect.EscapeParameter(SAGA_DATA_COLUMN), Serialize(sagaData)); - - command.CommandText = string.Format( - @"UPDATE {0} SET {1} = {2}, {3} = {4} " + - @"WHERE {5} = {6} AND {7} = {8};", - dialect.QuoteForTableName(sagasTableName), - dialect.QuoteForColumnName(SAGA_DATA_COLUMN), dialect.EscapeParameter(SAGA_DATA_COLUMN), - dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("next_revision"), - dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), - dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") - ); - var rows = command.ExecuteNonQuery(); - if (rows == 0) - { - throw new OptimisticLockingException(sagaData); - } - } - - var propertiesToIndex = GetPropertiesToIndex(sagaData, sagaDataPropertyPathsToIndex); - - if (propertiesToIndex.Any()) - { - DeclareIndex(sagaData, scope, propertiesToIndex); - } - - scope.Complete(); - } - } - - private void DeclareIndexUsingTableExpressions(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) - { - var dialect = scope.Dialect; - var connection = scope.Connection; - - var sagaTypeName = GetSagaTypeName(sagaData.GetType()); - var parameters = propertiesToIndex - .Select((p, i) => new - { - PropertyName = p.Key, - PropertyValue = p.Value, - PropertyNameParameter = string.Format("n{0}", i), - PropertyValueParameter = string.Format("v{0}", i), - PropertyValuesParameter = string.Format("vs{0}", i) - }) - .ToList(); - - var tuples = parameters - .Select(p => string.Format("({0}, {1}, {2}, {3})", - dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), - dialect.EscapeParameter(p.PropertyNameParameter), - dialect.EscapeParameter(p.PropertyValueParameter), - dialect.EscapeParameter(p.PropertyValuesParameter) - )); - - using (var command = connection.CreateCommand()) - { - command.CommandText = string.Format( - "WITH existing AS (" + - "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {6} " + - "ON CONFLICT ({1}, {2}) DO UPDATE SET {3} = excluded.{3}, {4} = excluded.{4} " + - "RETURNING {2}) " + - "DELETE FROM {0} " + - "WHERE {1} = {5} AND {2} NOT IN (SELECT {2} FROM existing);", - dialect.QuoteForTableName(sagasIndexTableName), //< 0 - dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 - dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 - dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 - dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 - dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 5 - string.Join(", ", tuples) //< 6 - ); - - foreach (var parameter in parameters) - { - var value = GetIndexValue(parameter.PropertyValue); - - command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); - command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); - - var values = ArraysEnabledFor(dialect) - ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() - : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); - var dbtype = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; - - command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), dbtype, values); - } - - command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); - - try - { - command.ExecuteNonQuery(); - } - catch (DbException exception) - { - throw new OptimisticLockingException(sagaData, exception); - } - } - } - - private void DeclareIndexUsingReturningClause(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) - { - var dialect = scope.Dialect; - var connection = scope.Connection; - var existingKeys = Enumerable.Empty(); - - var sagaTypeName = GetSagaTypeName(sagaData.GetType()); - var parameters = propertiesToIndex - .Select((p, i) => new - { - PropertyName = p.Key, - PropertyValue = p.Value, - PropertyNameParameter = string.Format("n{0}", i), - PropertyValueParameter = string.Format("v{0}", i), - PropertyValuesParameter = string.Format("vs{0}", i) - }) - .ToList(); - - var tuples = parameters - .Select(p => string.Format("({0}, {1}, {2}, {3})", - dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), - dialect.EscapeParameter(p.PropertyNameParameter), - dialect.EscapeParameter(p.PropertyValueParameter), - dialect.EscapeParameter(p.PropertyValuesParameter) - )); - - using (var command = connection.CreateCommand()) - { - command.CommandText = string.Format( - "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {5} " + - "ON CONFLICT ({1}, {2}) DO UPDATE SET {3} = excluded.{3}, {4} = excluded.{4} " + - "RETURNING {2};", - dialect.QuoteForTableName(sagasIndexTableName), //< 0 - dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 - dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 - dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 - dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 - string.Join(", ", tuples) //< 5 - ); - - foreach (var parameter in parameters) - { - var value = GetIndexValue(parameter.PropertyValue); - var values = value == null ? null : ArraysEnabledFor(dialect) - ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() - : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); - var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; - - command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); - command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); - command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), valuesDbType, values); - } - - command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); - - try - { - using (var reader = command.ExecuteReader()) - { - existingKeys = reader.AsEnumerable(SAGAINDEX_KEY_COLUMN).ToArray(); - } - } - catch (DbException exception) - { - throw new OptimisticLockingException(sagaData, exception); - } - } - - var idx = 0; - using (var command = connection.CreateCommand()) - { - command.CommandText = string.Format( - "DELETE FROM {0} " + - "WHERE {1} = {2} AND {3} NOT IN ({4});", - dialect.QuoteForTableName(sagasIndexTableName), //< 0 - dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 - dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 2 - dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 3 - string.Join(", ", existingKeys.Select(k => dialect.EscapeParameter($"k{idx++}"))) - ); - - for (int i = 0; i < existingKeys.Count(); i++) - { - command.AddParameter(dialect.EscapeParameter($"k{i}"), DbType.StringFixedLength, existingKeys.ElementAt(i).Trim()); - } - - command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); - - try - { - command.ExecuteNonQuery(); - } - catch (DbException exception) - { - throw new OptimisticLockingException(sagaData, exception); - } - } - } - - private void DeclareIndexUnoptimized(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) - { - var connection = scope.Connection; - var dialect = scope.Dialect; - var sagaTypeName = GetSagaTypeName(sagaData.GetType()); - - var idxTbl = dialect.QuoteForTableName(sagasIndexTableName); - var idCol = dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN); - var keyCol = dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN); - var valueCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); - var valuesCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); - - var idParam = dialect.EscapeParameter(SAGAINDEX_ID_COLUMN); - - var existingKeys = Enumerable.Empty(); - - // Let's fetch existing keys.. - using (var command = connection.CreateCommand()) - { - command.CommandText = string.Format( - "SELECT {1} FROM {0} WHERE {2} = {3};", - dialect.QuoteForTableName(sagasIndexTableName), //< 0 - dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 1 - dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 2 - dialect.EscapeParameter(SAGAINDEX_ID_COLUMN) //< 3 - ); - - command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); - - try - { - using (var reader = command.ExecuteReader()) - { - existingKeys = reader.AsEnumerable(SAGAINDEX_KEY_COLUMN).ToArray(); - } - } - catch (DbException exception) - { - throw new OptimisticLockingException(sagaData, exception); - } - } - - // For each exisring key, update it's value.. - foreach (var key in existingKeys.Where(k => propertiesToIndex.Any(p => p.Key == k))) - { - using (var command = connection.CreateCommand()) - { - command.CommandText = string.Format( - "UPDATE {0} SET {1} = {2}, {3} = {4} " + - "WHERE {5} = {6} AND {7} = {8};", - dialect.QuoteForTableName(sagasIndexTableName), //< 0 - dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 1 - dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN), //< 2 - dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 3 - dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN), //< 4 - dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 5 - dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 6 - dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 7 - dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN) //< 8 - ); - - var value = GetIndexValue(propertiesToIndex[key]); - var values = ArraysEnabledFor(dialect) - ? (object)GetIndexValues(propertiesToIndex[key])?.ToArray() - : GetConcatenatedIndexValues(GetIndexValues(propertiesToIndex[key])); - var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; - - command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); - command.AddParameter(dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN), DbType.String, key); - command.AddParameter(dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN), DbType.String, value); - command.AddParameter(dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN), valuesDbType, values); - - try - { - command.ExecuteNonQuery(); - } - catch (DbException exception) + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) { throw new OptimisticLockingException(sagaData, exception); + } + } + } + + var removedKeys = existingKeys.Where(x => !propertiesToIndex.ContainsKey(x)).ToArray(); + + if (removedKeys.Length > 0) + { + // Remove no longer needed keys.. + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + "DELETE FROM {0} WHERE {1} = {2} AND {3} IN ({4});", + dialect.QuoteForTableName(sagasIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 3 + string.Join(", ", existingKeys.Select((x, i) => dialect.EscapeParameter($"k{i}"))) + ); + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + for (int i = 0; i < existingKeys.Count(); i++) + { + command.AddParameter(dialect.EscapeParameter($"k{i}"), DbType.StringFixedLength, existingKeys.ElementAt(i).Trim()); + } + + try + { + command.ExecuteNonQuery(); } - } - } - - var removedKeys = existingKeys.Where(x => !propertiesToIndex.ContainsKey(x)).ToArray(); - - if (removedKeys.Length > 0) - { - // Remove no longer needed keys.. - using (var command = connection.CreateCommand()) - { - command.CommandText = string.Format( - "DELETE FROM {0} WHERE {1} = {2} AND {3} IN ({4});", - dialect.QuoteForTableName(sagasIndexTableName), //< 0 - dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 - dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), //< 2 - dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 3 - string.Join(", ", existingKeys.Select((x, i) => dialect.EscapeParameter($"k{i}"))) - ); - - command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); - - for (int i = 0; i < existingKeys.Count(); i++) - { - command.AddParameter(dialect.EscapeParameter($"k{i}"), DbType.StringFixedLength, existingKeys.ElementAt(i).Trim()); - } - - try - { - command.ExecuteNonQuery(); - } - catch (DbException exception) + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) { throw new OptimisticLockingException(sagaData, exception); + } + } + } + + var parameters = propertiesToIndex + .Where(x => !existingKeys.Contains(x.Key)) + .Select((p, i) => new + { + PropertyName = p.Key, + PropertyValue = p.Value, + PropertyNameParameter = string.Format("n{0}", i), + PropertyValueParameter = string.Format("v{0}", i), + PropertyValuesParameter = string.Format("vs{0}", i) + }) + .ToList(); + + if (parameters.Count > 0) + { + // Insert new keys.. + using (var command = connection.CreateCommand()) + { + + var tuples = parameters.Select(p => string.Format("({0}, {1}, {2}, {3})", + idParam, + dialect.EscapeParameter(p.PropertyNameParameter), + dialect.EscapeParameter(p.PropertyValueParameter), + dialect.EscapeParameter(p.PropertyValuesParameter) + )); + + command.CommandText = string.Format( + "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {5};", + dialect.QuoteForTableName(sagasIndexTableName), //< 0 + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 + dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 + dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 + dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 + string.Join(", ", tuples) //< 5 + ); + + foreach (var parameter in parameters) + { + var value = GetIndexValue(parameter.PropertyValue); + var values = ArraysEnabledFor(dialect) + ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() + : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); + command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), valuesDbType, values); + } + + command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); + + try + { + command.ExecuteNonQuery(); } - } - } - - var parameters = propertiesToIndex - .Where(x => !existingKeys.Contains(x.Key)) - .Select((p, i) => new - { - PropertyName = p.Key, - PropertyValue = p.Value, - PropertyNameParameter = string.Format("n{0}", i), - PropertyValueParameter = string.Format("v{0}", i), - PropertyValuesParameter = string.Format("vs{0}", i) - }) - .ToList(); - - if (parameters.Count > 0) - { - // Insert new keys.. - using (var command = connection.CreateCommand()) - { - - var tuples = parameters.Select(p => string.Format("({0}, {1}, {2}, {3})", - idParam, - dialect.EscapeParameter(p.PropertyNameParameter), - dialect.EscapeParameter(p.PropertyValueParameter), - dialect.EscapeParameter(p.PropertyValuesParameter) - )); - - command.CommandText = string.Format( - "INSERT INTO {0} ({1}, {2}, {3}, {4}) VALUES {5};", - dialect.QuoteForTableName(sagasIndexTableName), //< 0 - dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), //< 1 - dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN), //< 2 - dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN), //< 3 - dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN),//< 4 - string.Join(", ", tuples) //< 5 - ); - - foreach (var parameter in parameters) - { - var value = GetIndexValue(parameter.PropertyValue); - var values = ArraysEnabledFor(dialect) - ? (object)GetIndexValues(parameter.PropertyValue)?.ToArray() - : GetConcatenatedIndexValues(GetIndexValues(parameter.PropertyValue)); - var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; - - command.AddParameter(dialect.EscapeParameter(parameter.PropertyNameParameter), DbType.String, parameter.PropertyName); - command.AddParameter(dialect.EscapeParameter(parameter.PropertyValueParameter), DbType.String, value); - command.AddParameter(dialect.EscapeParameter(parameter.PropertyValuesParameter), valuesDbType, values); - } - - command.AddParameter(dialect.EscapeParameter(SAGAINDEX_ID_COLUMN), DbType.Guid, sagaData.Id); - - try - { - command.ExecuteNonQuery(); - } - catch (DbException exception) + catch (DbException exception) when ( + dialect.IsOptimisticLockingException(exception) + || dialect.IsDuplicateKeyException(exception)) { throw new OptimisticLockingException(sagaData, exception); - } - - } - } - } - - private void DeclareIndex(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) - { - var dialect = scope.Dialect; - - if (dialect.SupportsOnConflictClause && dialect.SupportsReturningClause && dialect.SupportsTableExpressions) - { - DeclareIndexUsingTableExpressions(sagaData, scope, propertiesToIndex); - } - else if (dialect.SupportsOnConflictClause && dialect.SupportsReturningClause) - { - DeclareIndexUsingReturningClause(sagaData, scope, propertiesToIndex); - } - else - { - DeclareIndexUnoptimized(sagaData, scope, propertiesToIndex); - } - } - - public override void Delete(ISagaData sagaData) - { - using (var scope = Manager.GetScope()) - { - var dialect = scope.Dialect; - var connection = scope.Connection; - - using (var command = connection.CreateCommand()) - { - command.CommandText = string.Format( - @"DELETE FROM {0} WHERE {1} = {2} AND {3} = {4};", - dialect.QuoteForTableName(sagasTableName), - dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), - dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") - ); - command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); - command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); - - var rows = command.ExecuteNonQuery(); - - if (rows == 0) - { - throw new OptimisticLockingException(sagaData); - } - } - - using (var command = connection.CreateCommand()) - { - command.CommandText = string.Format( - @"DELETE FROM {0} WHERE {1} = {2};", - dialect.QuoteForTableName(sagasIndexTableName), - dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN) - ); - command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); - command.ExecuteNonQuery(); - } - - scope.Complete(); - } - } - - private string GetSagaLockingClause(SqlDialect dialect) - { - if (UseSagaLocking) - { - return UseNoWaitSagaLocking - ? $"{dialect.SelectForUpdateClause} {dialect.SelectForNoWaitClause}" - : dialect.SelectForUpdateClause; - } - - return string.Empty; - } - - private bool ArraysEnabledFor(SqlDialect dialect) - { - return UseSqlArrays && dialect.SupportsArrayTypes; - } - - private bool ShouldIndexValue(object value) - { - if (IndexNullProperties) - return true; - - if (value == null) return false; - if (value is string) return true; - if ((value is IEnumerable) && !(value as IEnumerable).Cast().Any()) return false; - - return true; - } - - private IDictionary GetPropertiesToIndex(ISagaData sagaData, IEnumerable sagaDataPropertyPathsToIndex) - { - return sagaDataPropertyPathsToIndex - .Select(x => new { Key = x, Value = Reflect.Value(sagaData, x) }) - .Where(ShouldIndexValue) - .ToDictionary(x => x.Key, x => x.Value); - } - - private static string GetIndexValue(object value) - { - if (value is string) - { - return value as string; - } - else if (value == null || value is IEnumerable) - { - return null; - } - - return Convert.ToString(value); - } - - private static IEnumerable GetIndexValues(object value) - { - if (!(value is IEnumerable) || value is string) - { - return null; - } - - return (value as IEnumerable).Cast().Select(x => Convert.ToString(x)).ToArray(); - } - - private static string GetConcatenatedIndexValues(IEnumerable values) - { - if (values == null || !values.Any()) - { - return null; - } - - var sb = new StringBuilder(values.Sum(x => x.Length + 1) + 1); - sb.Append('|'); - - foreach (var value in values) - { - sb.Append(value); - sb.Append('|'); - } - - return sb.ToString(); - } - - protected override string Fetch(string sagaDataPropertyPath, object fieldFromMessage) - { - using (var scope = Manager.GetScope(autocomplete: true)) - { - var dialect = scope.Dialect; - var connection = scope.Connection; - var sagaType = GetSagaTypeName(typeof(TSagaData)); - - if (UseSagaLocking) - { - if (!dialect.SupportsSelectForUpdate) - throw new InvalidOperationException($"You can't use saga locking for a Dialect {dialect.GetType()} that does not supports Select For Update."); - - if (UseNoWaitSagaLocking && !dialect.SupportsSelectForWithNoWait) - throw new InvalidOperationException($"You can't use saga locking with no-wait for a Dialect {dialect.GetType()} that does not supports no-wait clause."); - } - - using (var command = connection.CreateCommand()) - { - if (sagaDataPropertyPath == idPropertyName) - { - var id = (fieldFromMessage is Guid) ? (Guid)fieldFromMessage : Guid.Parse(fieldFromMessage.ToString()); - var idParam = dialect.EscapeParameter("id"); - var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); - - command.CommandText = string.Format( - @"SELECT s.{0} FROM {1} s WHERE s.{2} = {3} AND s.{4} = {5} {6}", - dialect.QuoteForColumnName(SAGA_DATA_COLUMN), - dialect.QuoteForTableName(sagasTableName), - dialect.QuoteForColumnName(SAGA_ID_COLUMN), - idParam, - dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), - sagaTypeParam, - GetSagaLockingClause(dialect) - ); - command.AddParameter(sagaTypeParam, sagaType); - command.AddParameter(idParam, id); - } - else - { - var dataCol = dialect.QuoteForColumnName(SAGA_DATA_COLUMN); - var sagaTblName = dialect.QuoteForTableName(sagasTableName); - var sagaTypeCol = dialect.QuoteForColumnName(SAGA_TYPE_COLUMN); - var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); - var indexTblName = dialect.QuoteForTableName(sagasIndexTableName); - var sagaIdCol = dialect.QuoteForColumnName(SAGA_ID_COLUMN); - var indexIdCol = dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN); - var indexKeyCol = dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN); - var indexKeyParam = dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN); - var indexValueCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); - var indexValueParm = dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN); - var indexValuesCol = dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN); - var indexValuesParm = dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN); - var forUpdate = GetSagaLockingClause(dialect); - var valuesPredicate = ArraysEnabledFor(dialect) - ? dialect.FormatArrayAny($"i.{indexValuesCol}", indexValuesParm) - : $"(i.{indexValuesCol} LIKE ('%' || {indexValuesParm} || '%'))"; - - command.CommandText = $@" - SELECT s.{dataCol} - FROM {sagaTblName} s - JOIN {indexTblName} i on s.{sagaIdCol} = i.{indexIdCol} - WHERE s.{sagaTypeCol} = {sagaTypeParam} - AND i.{indexKeyCol} = {indexKeyParam} - AND ( - CASE WHEN {indexValueParm} IS NULL THEN i.{indexValueCol} IS NULL - ELSE - ( - i.{indexValueCol} = {indexValueParm} - OR - (i.{indexValuesCol} is NOT NULL AND {valuesPredicate}) - ) - END - ) - {forUpdate};".Replace("\t", ""); - - var value = GetIndexValue(fieldFromMessage); - var values = value == null ? DBNull.Value : ArraysEnabledFor(dialect) - ? (object)(new[] { value }) - : GetConcatenatedIndexValues(new[] { value }); - var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; - - command.AddParameter(indexKeyParam, sagaDataPropertyPath); - command.AddParameter(sagaTypeParam, sagaType); - command.AddParameter(indexValueParm, DbType.String, value); - command.AddParameter(indexValuesParm, valuesDbType, values); - } - - - try - { - log.Debug("Finding saga of type {0} with {1} = {2}", sagaType, sagaDataPropertyPath, fieldFromMessage); - return (string)command.ExecuteScalar(); - } - catch (DbException ex) - { - // When in no-wait saga-locking mode, inspect - // exception and rethrow ex as SagaLockedException. - if (UseSagaLocking && UseNoWaitSagaLocking) - { - if (dialect.IsSelectForNoWaitLockingException(ex)) - throw new AdoNetSagaLockedException(ex); - } - - throw; - } - } - } - } - } -} + } + } + } + } + + private void DeclareIndex(ISagaData sagaData, AdoNetUnitOfWorkScope scope, IDictionary propertiesToIndex) + { + var dialect = scope.Dialect; + + if (dialect.SupportsOnConflictClause && dialect.SupportsReturningClause && dialect.SupportsTableExpressions) + { + DeclareIndexUsingTableExpressions(sagaData, scope, propertiesToIndex); + } + else if (dialect.SupportsOnConflictClause && dialect.SupportsReturningClause) + { + DeclareIndexUsingReturningClause(sagaData, scope, propertiesToIndex); + } + else + { + DeclareIndexUnoptimized(sagaData, scope, propertiesToIndex); + } + } + + public override void Delete(ISagaData sagaData) + { + using (var scope = Manager.GetScope()) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + @"DELETE FROM {0} WHERE {1} = {2} AND {3} = {4};", + dialect.QuoteForTableName(sagasTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN), + dialect.QuoteForColumnName(SAGA_REVISION_COLUMN), dialect.EscapeParameter("current_revision") + ); + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.AddParameter(dialect.EscapeParameter("current_revision"), sagaData.Revision); + + var rows = command.ExecuteNonQuery(); + + if (rows == 0) + { + throw new OptimisticLockingException(sagaData); + } + } + + using (var command = connection.CreateCommand()) + { + command.CommandText = string.Format( + @"DELETE FROM {0} WHERE {1} = {2};", + dialect.QuoteForTableName(sagasIndexTableName), + dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN), dialect.EscapeParameter(SAGA_ID_COLUMN) + ); + command.AddParameter(dialect.EscapeParameter(SAGA_ID_COLUMN), sagaData.Id); + command.ExecuteNonQuery(); + } + + scope.Complete(); + } + } + + private string GetSagaLockingClause(SqlDialect dialect) + { + if (UseSagaLocking) + { + return UseNoWaitSagaLocking + ? $"{dialect.SelectForUpdateClause} {dialect.SelectForNoWaitClause}" + : dialect.SelectForUpdateClause; + } + + return string.Empty; + } + + private bool ArraysEnabledFor(SqlDialect dialect) + { + return UseSqlArrays && dialect.SupportsArrayTypes; + } + + private bool ShouldIndexValue(object value) + { + if (IndexNullProperties) + return true; + + if (value == null) return false; + if (value is string) return true; + if ((value is IEnumerable) && !(value as IEnumerable).Cast().Any()) return false; + + return true; + } + + private IDictionary GetPropertiesToIndex(ISagaData sagaData, IEnumerable sagaDataPropertyPathsToIndex) + { + return sagaDataPropertyPathsToIndex + .Select(x => new { Key = x, Value = Reflect.Value(sagaData, x) }) + .Where(ShouldIndexValue) + .ToDictionary(x => x.Key, x => x.Value); + } + + private static string GetIndexValue(object value) + { + if (value is string) + { + return value as string; + } + else if (value == null || value is IEnumerable) + { + return null; + } + + return Convert.ToString(value); + } + + private static IEnumerable GetIndexValues(object value) + { + if (!(value is IEnumerable) || value is string) + { + return null; + } + + return (value as IEnumerable).Cast().Select(x => Convert.ToString(x)).ToArray(); + } + + private static string GetConcatenatedIndexValues(IEnumerable values) + { + if (values == null || !values.Any()) + { + return null; + } + + var sb = new StringBuilder(values.Sum(x => x.Length + 1) + 1); + sb.Append('|'); + + foreach (var value in values) + { + sb.Append(value); + sb.Append('|'); + } + + return sb.ToString(); + } + + protected override string Fetch(string sagaDataPropertyPath, object fieldFromMessage) + { + using (var scope = Manager.GetScope(autocomplete: true)) + { + var dialect = scope.Dialect; + var connection = scope.Connection; + var sagaType = GetSagaTypeName(typeof(TSagaData)); + + if (UseSagaLocking) + { + if (!dialect.SupportsSelectForUpdate) + throw new InvalidOperationException($"You can't use saga locking for a Dialect {dialect.GetType()} that does not supports Select For Update."); + + if (UseNoWaitSagaLocking && !dialect.SupportsSelectForWithNoWait) + throw new InvalidOperationException($"You can't use saga locking with no-wait for a Dialect {dialect.GetType()} that does not supports no-wait clause."); + } + + using (var command = connection.CreateCommand()) + { + if (sagaDataPropertyPath == idPropertyName) + { + var id = (fieldFromMessage is Guid) ? (Guid)fieldFromMessage : Guid.Parse(fieldFromMessage.ToString()); + var idParam = dialect.EscapeParameter("id"); + var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); + + command.CommandText = string.Format( + @"SELECT s.{0} FROM {1} s WHERE s.{2} = {3} AND s.{4} = {5} {6}", + dialect.QuoteForColumnName(SAGA_DATA_COLUMN), + dialect.QuoteForTableName(sagasTableName), + dialect.QuoteForColumnName(SAGA_ID_COLUMN), + idParam, + dialect.QuoteForColumnName(SAGA_TYPE_COLUMN), + sagaTypeParam, + GetSagaLockingClause(dialect) + ); + command.AddParameter(sagaTypeParam, sagaType); + command.AddParameter(idParam, id); + } + else + { + var dataCol = dialect.QuoteForColumnName(SAGA_DATA_COLUMN); + var sagaTblName = dialect.QuoteForTableName(sagasTableName); + var sagaTypeCol = dialect.QuoteForColumnName(SAGA_TYPE_COLUMN); + var sagaTypeParam = dialect.EscapeParameter(SAGA_TYPE_COLUMN); + var indexTblName = dialect.QuoteForTableName(sagasIndexTableName); + var sagaIdCol = dialect.QuoteForColumnName(SAGA_ID_COLUMN); + var indexIdCol = dialect.QuoteForColumnName(SAGAINDEX_ID_COLUMN); + var indexKeyCol = dialect.QuoteForColumnName(SAGAINDEX_KEY_COLUMN); + var indexKeyParam = dialect.EscapeParameter(SAGAINDEX_KEY_COLUMN); + var indexValueCol = dialect.QuoteForColumnName(SAGAINDEX_VALUE_COLUMN); + var indexValueParm = dialect.EscapeParameter(SAGAINDEX_VALUE_COLUMN); + var indexValuesCol = dialect.QuoteForColumnName(SAGAINDEX_VALUES_COLUMN); + var indexValuesParm = dialect.EscapeParameter(SAGAINDEX_VALUES_COLUMN); + var forUpdate = GetSagaLockingClause(dialect); + var valuesPredicate = ArraysEnabledFor(dialect) + ? dialect.FormatArrayAny($"i.{indexValuesCol}", indexValuesParm) + : $"(i.{indexValuesCol} LIKE ('%' || {indexValuesParm} || '%'))"; + + command.CommandText = $@" + SELECT s.{dataCol} + FROM {sagaTblName} s + JOIN {indexTblName} i on s.{sagaIdCol} = i.{indexIdCol} + WHERE s.{sagaTypeCol} = {sagaTypeParam} + AND i.{indexKeyCol} = {indexKeyParam} + AND ( + CASE WHEN {indexValueParm} IS NULL THEN i.{indexValueCol} IS NULL + ELSE + ( + i.{indexValueCol} = {indexValueParm} + OR + (i.{indexValuesCol} is NOT NULL AND {valuesPredicate}) + ) + END + ) + {forUpdate};".Replace("\t", ""); + + var value = GetIndexValue(fieldFromMessage); + var values = value == null ? DBNull.Value : ArraysEnabledFor(dialect) + ? (object)(new[] { value }) + : GetConcatenatedIndexValues(new[] { value }); + var valuesDbType = ArraysEnabledFor(dialect) ? DbType.Object : DbType.String; + + command.AddParameter(indexKeyParam, sagaDataPropertyPath); + command.AddParameter(sagaTypeParam, sagaType); + command.AddParameter(indexValueParm, DbType.String, value); + command.AddParameter(indexValuesParm, valuesDbType, values); + } + + + try + { + log.Debug("Finding saga of type {0} with {1} = {2}", sagaType, sagaDataPropertyPath, fieldFromMessage); + return (string)command.ExecuteScalar(); + } + catch (DbException ex) + { + // When in no-wait saga-locking mode, inspect + // exception and rethrow ex as SagaLockedException. + if (UseSagaLocking && UseNoWaitSagaLocking) + { + if (dialect.IsSelectForNoWaitLockingException(ex)) + throw new AdoNetSagaLockedException(ex); + } + + throw; + } + } + } + } + } +} diff --git a/Rebus.AdoNet/Dialects/PostgreSql95Dialect.cs b/Rebus.AdoNet/Dialects/PostgreSql95Dialect.cs index 7e1d829..03705c0 100644 --- a/Rebus.AdoNet/Dialects/PostgreSql95Dialect.cs +++ b/Rebus.AdoNet/Dialects/PostgreSql95Dialect.cs @@ -5,7 +5,7 @@ namespace Rebus.AdoNet.Dialects { public class PostgreSql95Dialect : PostgreSql94Dialect { - protected override Version MinimumDatabaseVersion => new Version("9.5"); + protected override Version MinimumDatabaseVersion => new Version("9.5"); public override ushort Priority => 95; public override bool SupportsOnConflictClause => true; diff --git a/Rebus.AdoNet/Dialects/PostgreSqlDialect.cs b/Rebus.AdoNet/Dialects/PostgreSqlDialect.cs index ba91ea5..7a2f4e8 100644 --- a/Rebus.AdoNet/Dialects/PostgreSqlDialect.cs +++ b/Rebus.AdoNet/Dialects/PostgreSqlDialect.cs @@ -74,7 +74,7 @@ public override bool SupportsThisDialect(IDbConnection connection) return false; } } - + public override bool IsSelectForNoWaitLockingException(DbException ex) { if (ex != null && _postgresExceptionNames.Contains(ex.GetType().Name)) @@ -97,6 +97,17 @@ public override bool IsDuplicateKeyException(DbException ex) return false; } + public override bool IsOptimisticLockingException(DbException ex) + { + if (ex != null && _postgresExceptionNames.Contains(ex.GetType().Name)) + { + var psqlex = new PostgreSqlExceptionAdapter(ex); + return psqlex.Code == "40001"; + } + + return false; + } + #endregion #region GetColumnType @@ -145,3 +156,4 @@ public override string FormatArrayAny(string arg1, string arg2) #endregion } } + diff --git a/Rebus.AdoNet/Dialects/SqlDialect.cs b/Rebus.AdoNet/Dialects/SqlDialect.cs index 16dbca9..fba9f94 100644 --- a/Rebus.AdoNet/Dialects/SqlDialect.cs +++ b/Rebus.AdoNet/Dialects/SqlDialect.cs @@ -139,11 +139,6 @@ public virtual string GetLongestTypeName(DbType dbType) return _typeNames.GetLongest(dbType); } - /// - /// Reverse search for *testing purpose* in order to get (for each dialect) the related DBType.. - /// - internal virtual DbType GetDbTypeFor(string name) => _typeNames.Defaults.First(x => x.Value.ToLowerInvariant() == name.ToLowerInvariant()).Key; - #endregion #region Identifier quoting support @@ -429,8 +424,8 @@ public virtual string FormatCreateTable(AdoNetTable table) { var primaryKey = (!table.HasCompositePrimaryKey && (bool)table.PrimaryKey?.Any(x => x == column.Name)); var @default = column.Default == null ? "" : column.Default is string - ? Quote(column.Default as string) - : column.Default.ToString(); + ? Quote(column.Default as string) + : column.Default.ToString(); sb.AppendFormat(" {0} {1} {2} {3} {4}", QuoteForColumnName(column.Name), @@ -570,6 +565,12 @@ public virtual string FormatArrayAny(string arg1, string arg2) public virtual string JsonColumnGinPathIndexOpclass => ""; #endregion + #region IsOptimisticException + + public abstract bool IsOptimisticLockingException(DbException ex); + + #endregion + #region SqlDialects Registry private static readonly IList _dialects = diff --git a/Rebus.AdoNet/Dialects/SqliteDialect.cs b/Rebus.AdoNet/Dialects/SqliteDialect.cs index 65cefde..bea46f4 100644 --- a/Rebus.AdoNet/Dialects/SqliteDialect.cs +++ b/Rebus.AdoNet/Dialects/SqliteDialect.cs @@ -66,5 +66,20 @@ public override string GetColumnType(DbType type, uint length, uint precision, u "INTEGER PRIMARY KEY AUTOINCREMENT" //< This is all you can get from sqlite's identity/auto-increment support. : base.GetColumnType(type, length, precision, scale, identity, array, primary); } + + public override bool IsDuplicateKeyException(DbException ex) + { + // See: https://www.sqlite.org/rescode.html + return ex.ErrorCode == 19 //< CONSTRAINT + || ex.ErrorCode == 2067; //< UNIQUE + } + + public override bool IsOptimisticLockingException(DbException ex) + { + // See: https://www.sqlite.org/rescode.html + return ex.ErrorCode == 5 //< BUSY + || ex.ErrorCode == 6; //< LOCKED + } } } + diff --git a/Rebus.AdoNet/Dialects/TypeNames.cs b/Rebus.AdoNet/Dialects/TypeNames.cs index 5cca450..4959586 100755 --- a/Rebus.AdoNet/Dialects/TypeNames.cs +++ b/Rebus.AdoNet/Dialects/TypeNames.cs @@ -1,182 +1,176 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Data; -using System.Text; - -namespace Rebus.AdoNet.Dialects -{ - /// - /// This class maps a DbType to names. - /// - /// - /// Associations may be marked with a capacity. Calling the Get() - /// method with a type and actual size n will return the associated - /// name with smallest capacity >= n, if available and an unmarked - /// default type otherwise. - /// Eg, setting - /// - ///     Names.Put(DbType,           "TEXT" ); - ///     Names.Put(DbType,   255,    "VARCHAR($l)" ); - ///     Names.Put(DbType,   65534,  "LONGVARCHAR($l)" ); - /// - /// will give you back the following: - /// - ///     Names.Get(DbType)           // --> "TEXT" (default) - ///     Names.Get(DbType,100)       // --> "VARCHAR(100)" (100 is in [0:255]) - ///     Names.Get(DbType,1000)  // --> "LONGVARCHAR(1000)" (100 is in [256:65534]) - ///     Names.Get(DbType,100000)    // --> "TEXT" (default) - /// - /// On the other hand, simply putting - /// - ///     Names.Put(DbType, "VARCHAR($l)" ); - /// - /// would result in - /// - ///     Names.Get(DbType)           // --> "VARCHAR($l)" (will cause trouble) - ///     Names.Get(DbType,100)       // --> "VARCHAR(100)" - ///     Names.Get(DbType,1000)  // --> "VARCHAR(1000)" - ///     Names.Get(DbType,10000) // --> "VARCHAR(10000)" - /// - /// - public class TypeNames - { - public const string LengthPlaceHolder = "$l"; - public const string PrecisionPlaceHolder = "$p"; - public const string ScalePlaceHolder = "$s"; - - private readonly Dictionary> weighted = new Dictionary>(); - private readonly Dictionary defaults = new Dictionary(); - - /// - /// Expose defaults as a readonly collection for testing purpose.. - /// - internal IReadOnlyDictionary Defaults => new ReadOnlyDictionary(defaults); - - /// - /// - /// - /// - /// - /// - /// - private static string ReplaceOnce(string template, string placeholder, string replacement) - { - int loc = template.IndexOf(placeholder, StringComparison.Ordinal); - if (loc < 0) - { - return template; - } - else - { - return new StringBuilder(template.Substring(0, loc)) - .Append(replacement) - .Append(template.Substring(loc + placeholder.Length)) - .ToString(); - } - } - - /// - /// Replaces the specified type. - /// - /// The type. - /// The size. - /// The precision. - /// The scale. - /// - private static string Replace(string type, uint size, uint precision, uint scale) - { - type = ReplaceOnce(type, LengthPlaceHolder, size.ToString()); - type = ReplaceOnce(type, ScalePlaceHolder, scale.ToString()); - return ReplaceOnce(type, PrecisionPlaceHolder, precision.ToString()); - } - - /// - /// Get default type name for specified type - /// - /// the type key - /// the default type name associated with the specified key - public string Get(DbType typecode) - { - string result; - if (!defaults.TryGetValue(typecode, out result)) - { - throw new ArgumentException("Dialect does not support DbType." + typecode, "typecode"); - } - return result; - } - - /// - /// Get the type name specified type and size - /// - /// the type key - /// the SQL length - /// the SQL scale - /// the SQL precision - /// - /// The associated name with smallest capacity >= size if available and the - /// default type name otherwise - /// - public string Get(DbType typecode, uint size, uint precision, uint scale) - { - SortedList map; - weighted.TryGetValue(typecode, out map); - if (map != null && map.Count > 0) - { - foreach (KeyValuePair entry in map) - { - if (size <= entry.Key) - { - return Replace(entry.Value, size, precision, scale); - } - } - } - //Could not find a specific type for the size, using the default - return Replace(Get(typecode), size, precision, scale); - } - - /// - /// For types with a simple length, this method returns the definition - /// for the longest registered type. - /// - /// - /// - public string GetLongest(DbType typecode) - { - SortedList map; - weighted.TryGetValue(typecode, out map); - - if (map != null && map.Count > 0) - return Replace(map.Values[map.Count - 1], map.Keys[map.Count - 1], 0, 0); - - return Get(typecode); - } - - /// - /// Set a type name for specified type key and capacity - /// - /// the type key - /// the (maximum) type size/length - /// The associated name - public void Put(DbType typecode, uint capacity, string value) - { - SortedList map; - if (!weighted.TryGetValue(typecode, out map)) - { - // add new ordered map - weighted[typecode] = map = new SortedList(); - } - map[capacity] = value; - } - - /// - /// - /// - /// - /// - public void Put(DbType typecode, string value) - { - defaults[typecode] = value; - } - } +using System; +using System.Collections.Generic; +using System.Data; +using System.Text; + +namespace Rebus.AdoNet.Dialects +{ + /// + /// This class maps a DbType to names. + /// + /// + /// Associations may be marked with a capacity. Calling the Get() + /// method with a type and actual size n will return the associated + /// name with smallest capacity >= n, if available and an unmarked + /// default type otherwise. + /// Eg, setting + /// + ///     Names.Put(DbType,           "TEXT" ); + ///     Names.Put(DbType,   255,    "VARCHAR($l)" ); + ///     Names.Put(DbType,   65534,  "LONGVARCHAR($l)" ); + /// + /// will give you back the following: + /// + ///     Names.Get(DbType)           // --> "TEXT" (default) + ///     Names.Get(DbType,100)       // --> "VARCHAR(100)" (100 is in [0:255]) + ///     Names.Get(DbType,1000)  // --> "LONGVARCHAR(1000)" (100 is in [256:65534]) + ///     Names.Get(DbType,100000)    // --> "TEXT" (default) + /// + /// On the other hand, simply putting + /// + ///     Names.Put(DbType, "VARCHAR($l)" ); + /// + /// would result in + /// + ///     Names.Get(DbType)           // --> "VARCHAR($l)" (will cause trouble) + ///     Names.Get(DbType,100)       // --> "VARCHAR(100)" + ///     Names.Get(DbType,1000)  // --> "VARCHAR(1000)" + ///     Names.Get(DbType,10000) // --> "VARCHAR(10000)" + /// + /// + public class TypeNames + { + public const string LengthPlaceHolder = "$l"; + public const string PrecisionPlaceHolder = "$p"; + public const string ScalePlaceHolder = "$s"; + + private readonly Dictionary> weighted = new Dictionary>(); + private readonly Dictionary defaults = new Dictionary(); + + /// + /// + /// + /// + /// + /// + /// + private static string ReplaceOnce(string template, string placeholder, string replacement) + { + int loc = template.IndexOf(placeholder, StringComparison.Ordinal); + if (loc < 0) + { + return template; + } + else + { + return new StringBuilder(template.Substring(0, loc)) + .Append(replacement) + .Append(template.Substring(loc + placeholder.Length)) + .ToString(); + } + } + + /// + /// Replaces the specified type. + /// + /// The type. + /// The size. + /// The precision. + /// The scale. + /// + private static string Replace(string type, uint size, uint precision, uint scale) + { + type = ReplaceOnce(type, LengthPlaceHolder, size.ToString()); + type = ReplaceOnce(type, ScalePlaceHolder, scale.ToString()); + return ReplaceOnce(type, PrecisionPlaceHolder, precision.ToString()); + } + + /// + /// Get default type name for specified type + /// + /// the type key + /// the default type name associated with the specified key + public string Get(DbType typecode) + { + string result; + if (!defaults.TryGetValue(typecode, out result)) + { + throw new ArgumentException("Dialect does not support DbType." + typecode, "typecode"); + } + return result; + } + + /// + /// Get the type name specified type and size + /// + /// the type key + /// the SQL length + /// the SQL scale + /// the SQL precision + /// + /// The associated name with smallest capacity >= size if available and the + /// default type name otherwise + /// + public string Get(DbType typecode, uint size, uint precision, uint scale) + { + SortedList map; + weighted.TryGetValue(typecode, out map); + if (map != null && map.Count > 0) + { + foreach (KeyValuePair entry in map) + { + if (size <= entry.Key) + { + return Replace(entry.Value, size, precision, scale); + } + } + } + //Could not find a specific type for the size, using the default + return Replace(Get(typecode), size, precision, scale); + } + + /// + /// For types with a simple length, this method returns the definition + /// for the longest registered type. + /// + /// + /// + public string GetLongest(DbType typecode) + { + SortedList map; + weighted.TryGetValue(typecode, out map); + + if (map != null && map.Count > 0) + return Replace(map.Values[map.Count - 1], map.Keys[map.Count - 1], 0, 0); + + return Get(typecode); + } + + /// + /// Set a type name for specified type key and capacity + /// + /// the type key + /// the (maximum) type size/length + /// The associated name + public void Put(DbType typecode, uint capacity, string value) + { + SortedList map; + if (!weighted.TryGetValue(typecode, out map)) + { + // add new ordered map + weighted[typecode] = map = new SortedList(); + } + map[capacity] = value; + } + + /// + /// + /// + /// + /// + public void Put(DbType typecode, string value) + { + defaults[typecode] = value; + } + } } \ No newline at end of file diff --git a/Rebus.AdoNet/Dialects/YugabyteDbDialect.cs b/Rebus.AdoNet/Dialects/YugabyteDbDialect.cs index 1f137ee..9ede794 100644 --- a/Rebus.AdoNet/Dialects/YugabyteDbDialect.cs +++ b/Rebus.AdoNet/Dialects/YugabyteDbDialect.cs @@ -61,5 +61,16 @@ public override bool IsDuplicateKeyException(DbException ex) return false; } + + public override bool IsOptimisticLockingException(DbException ex) + { + if (ex != null && _postgresExceptionNames.Contains(ex.GetType().Name)) + { + var psqlex = new PostgreSqlExceptionAdapter(ex); + return psqlex.Code == "40001"; + } + + return false; + } } } \ No newline at end of file diff --git a/Rebus.AdoNet/Facilities/IDbConnectionExtensions.cs b/Rebus.AdoNet/Facilities/IDbConnectionExtensions.cs new file mode 100644 index 0000000..cc39d17 --- /dev/null +++ b/Rebus.AdoNet/Facilities/IDbConnectionExtensions.cs @@ -0,0 +1,63 @@ +using System.Data.Common; +using System.Collections.Generic; + +namespace System.Data +{ + internal static class IDbConnectionExtensions + { + /// + /// Fetch the column names with their data types.. + /// where the Tuple.Item1 is the column name and the Tuple.Item2 is the data type. + /// + public static IEnumerable> GetColumnSchemaFor(this IDbConnection @this, string tableName) + { + if (@this == null) + throw new ArgumentNullException(nameof(@this)); + + if (string.IsNullOrWhiteSpace(tableName)) + throw new ArgumentNullException(nameof(tableName)); + + // XXX: In order, to retrieve the schema information we can specify + // the catalog (0), schema (1), table name (2) and column name (3). + var restrictions = new string[4]; + restrictions[2] = tableName; + + var data = new List>(); + var schemas = (@this as DbConnection).GetSchema("Columns", restrictions); + + foreach (DataRow row in schemas.Rows) + { + var name = row["COLUMN_NAME"] as string; + var type = row["DATA_TYPE"] as string; + data.Add(Tuple.Create(name, type)); + } + + return data.ToArray(); + } + + /// + /// Retrieve table's indexes for a specific table. + /// + public static IEnumerable GetIndexesFor(this IDbConnection @this, string tableName) + { + if (@this == null) + throw new ArgumentNullException(nameof(@this)); + + if (string.IsNullOrWhiteSpace(tableName)) + throw new ArgumentNullException(nameof(tableName)); + + // XXX: In order, to retrieve the schema information we can specify + // the catalog (0), schema (1), table name (2) and column name (3). + var restrictions = new string[4]; + restrictions[2] = tableName; + + var data = new List(); + var schemas = (@this as DbConnection).GetSchema("Indexes", restrictions); + + foreach (DataRow row in schemas.Rows) + data.Add(row["INDEX_NAME"] as string); + + return data.ToArray(); + } + } +} diff --git a/Rebus.AdoNet/Properties/AssemblyInfo.cs b/Rebus.AdoNet/Properties/AssemblyInfo.cs index 7adf52b..139f099 100644 --- a/Rebus.AdoNet/Properties/AssemblyInfo.cs +++ b/Rebus.AdoNet/Properties/AssemblyInfo.cs @@ -1,39 +1,39 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Rebus.AdoNet")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Rebus.AdoNet")] -[assembly: AssemblyCopyright("Copyright © Evidencias Certificadas S.L. (2015~2016)")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("03b56847-7469-4db3-b146-2d29ce61663e")] - -[assembly: InternalsVisibleTo("Rebus.AdoNet.Tests")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("0.0.0.0")] -[assembly: AssemblyFileVersion("0.0.0.0")] -[assembly: AssemblyInformationalVersion("VERSION_STRING")] +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Rebus.AdoNet")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Rebus.AdoNet")] +[assembly: AssemblyCopyright("Copyright © Evidencias Certificadas S.L. (2015~2016)")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("03b56847-7469-4db3-b146-2d29ce61663e")] + +[assembly: InternalsVisibleTo("Rebus.AdoNet.Tests")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("0.0.0.0")] +[assembly: AssemblyFileVersion("0.0.0.0")] +[assembly: AssemblyInformationalVersion("VERSION_STRING")] diff --git a/Rebus.AdoNet/Rebus.AdoNet.csproj b/Rebus.AdoNet/Rebus.AdoNet.csproj index 457e88f..7f1360c 100644 --- a/Rebus.AdoNet/Rebus.AdoNet.csproj +++ b/Rebus.AdoNet/Rebus.AdoNet.csproj @@ -69,6 +69,7 @@ + diff --git a/Rebus.AdoNet/Schema/AdoNetColumn.cs b/Rebus.AdoNet/Schema/AdoNetColumn.cs index ff23ea1..9d0a066 100644 --- a/Rebus.AdoNet/Schema/AdoNetColumn.cs +++ b/Rebus.AdoNet/Schema/AdoNetColumn.cs @@ -1,22 +1,22 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Data; -using System.Data.Common; -using System.Text; - -namespace Rebus.AdoNet.Schema -{ - public class AdoNetColumn - { - public string Name { get; set; } - public DbType DbType { get; set; } - public uint Length { get; set; } - public uint Precision { get; set; } - public uint Scale { get; set; } - public bool Nullable { get; set; } - public bool Identity { get; set; } - public bool Array { get; set; } - public object Default { get; set; } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Data; +using System.Data.Common; +using System.Text; + +namespace Rebus.AdoNet.Schema +{ + public class AdoNetColumn + { + public string Name { get; set; } + public DbType DbType { get; set; } + public uint Length { get; set; } + public uint Precision { get; set; } + public uint Scale { get; set; } + public bool Nullable { get; set; } + public bool Identity { get; set; } + public bool Array { get; set; } + public object Default { get; set; } + } +} diff --git a/Rebus.AdoNet/Schema/AdoNetIndex.cs b/Rebus.AdoNet/Schema/AdoNetIndex.cs index f86335e..959a8eb 100755 --- a/Rebus.AdoNet/Schema/AdoNetIndex.cs +++ b/Rebus.AdoNet/Schema/AdoNetIndex.cs @@ -1,30 +1,30 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Rebus.AdoNet.Schema -{ - public class AdoNetIndex - { - public enum SortOrder - { - Unspecified = -1, - Ascending = 0, - Descending = 1 - } - - public enum Kinds - { - Default = 0, - BTree = 1, - GIN = 2 - } - - public string Name { get; set; } - public string[] Columns { get; set; } - public SortOrder Order { get; set; } - public Kinds Kind { get; set; } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Rebus.AdoNet.Schema +{ + public class AdoNetIndex + { + public enum SortOrder + { + Unspecified = -1, + Ascending = 0, + Descending = 1 + } + + public enum Kinds + { + Default = 0, + BTree = 1, + GIN = 2 + } + + public string Name { get; set; } + public string[] Columns { get; set; } + public SortOrder Order { get; set; } + public Kinds Kind { get; set; } + } +} diff --git a/Rebus.AdoNet/Schema/AdoNetTable.cs b/Rebus.AdoNet/Schema/AdoNetTable.cs index c903d49..1495f99 100755 --- a/Rebus.AdoNet/Schema/AdoNetTable.cs +++ b/Rebus.AdoNet/Schema/AdoNetTable.cs @@ -1,19 +1,19 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Data; -using System.Data.Common; -using System.Text; - -namespace Rebus.AdoNet.Schema -{ - public class AdoNetTable - { - public string Name { get; set; } - public IEnumerable Columns { get; set; } - public string[] PrimaryKey { get; set; } - public IEnumerable Indexes { get; set; } - - public bool HasCompositePrimaryKey => PrimaryKey?.Count() > 1; - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Data; +using System.Data.Common; +using System.Text; + +namespace Rebus.AdoNet.Schema +{ + public class AdoNetTable + { + public string Name { get; set; } + public IEnumerable Columns { get; set; } + public string[] PrimaryKey { get; set; } + public IEnumerable Indexes { get; set; } + + public bool HasCompositePrimaryKey => PrimaryKey?.Count() > 1; + } +} diff --git a/Rebus.AdoNet/netfx/System/Guard.cs b/Rebus.AdoNet/netfx/System/Guard.cs index ab217bd..ff733b4 100755 --- a/Rebus.AdoNet/netfx/System/Guard.cs +++ b/Rebus.AdoNet/netfx/System/Guard.cs @@ -1,101 +1,101 @@ -#region BSD License -/* -Copyright (c) 2011, NETFx -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this list - of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, this - list of conditions and the following disclaimer in the documentation and/or other - materials provided with the distribution. - -* Neither the name of Clarius Consulting nor the names of its contributors may be - used to endorse or promote products derived from this software without specific - prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY -EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES -OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT -SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR -BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH -DAMAGE. -*/ -#endregion - -using System; -using System.Diagnostics; -using System.Linq.Expressions; - -/// -/// Common guard class for argument validation. -/// -/// -[DebuggerStepThrough] -static class Guard -{ - /// - /// Ensures the given is not null. - /// Throws otherwise. - /// - /// The is null. - public static void NotNull(Expression> reference, T value) - { - if (value == null) - throw new ArgumentNullException(GetParameterName(reference), "Parameter cannot be null."); - } - - /// - /// Ensures the given string is not null or empty. - /// Throws in the first case, or - /// in the latter. - /// - /// The is null or an empty string. - public static void NotNullOrEmpty(Expression> reference, string value) - { - NotNull(reference, value); - if (value.Length == 0) - throw new ArgumentException("Parameter cannot be empty.", GetParameterName(reference)); - } - - /// - /// Ensures the given string is valid according - /// to the function. Throws - /// otherwise. - /// - /// The is not valid according - /// to the function. - public static void IsValid(Expression> reference, T value, Func validate, string message) - { - if (!validate(value)) - throw new ArgumentException(message, GetParameterName(reference)); - } - - /// - /// Ensures the given string is valid according - /// to the function. Throws - /// otherwise. - /// - /// The is not valid according - /// to the function. - public static void IsValid(Expression> reference, T value, Func validate, string format, params object[] args) - { - if (!validate(value)) - throw new ArgumentException(string.Format(format, args), GetParameterName(reference)); - } - - private static string GetParameterName(Expression reference) - { - var lambda = reference as LambdaExpression; - var member = lambda.Body as MemberExpression; - - return member.Member.Name; - } +#region BSD License +/* +Copyright (c) 2011, NETFx +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list + of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or other + materials provided with the distribution. + +* Neither the name of Clarius Consulting nor the names of its contributors may be + used to endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT +SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. +*/ +#endregion + +using System; +using System.Diagnostics; +using System.Linq.Expressions; + +/// +/// Common guard class for argument validation. +/// +/// +[DebuggerStepThrough] +static class Guard +{ + /// + /// Ensures the given is not null. + /// Throws otherwise. + /// + /// The is null. + public static void NotNull(Expression> reference, T value) + { + if (value == null) + throw new ArgumentNullException(GetParameterName(reference), "Parameter cannot be null."); + } + + /// + /// Ensures the given string is not null or empty. + /// Throws in the first case, or + /// in the latter. + /// + /// The is null or an empty string. + public static void NotNullOrEmpty(Expression> reference, string value) + { + NotNull(reference, value); + if (value.Length == 0) + throw new ArgumentException("Parameter cannot be empty.", GetParameterName(reference)); + } + + /// + /// Ensures the given string is valid according + /// to the function. Throws + /// otherwise. + /// + /// The is not valid according + /// to the function. + public static void IsValid(Expression> reference, T value, Func validate, string message) + { + if (!validate(value)) + throw new ArgumentException(message, GetParameterName(reference)); + } + + /// + /// Ensures the given string is valid according + /// to the function. Throws + /// otherwise. + /// + /// The is not valid according + /// to the function. + public static void IsValid(Expression> reference, T value, Func validate, string format, params object[] args) + { + if (!validate(value)) + throw new ArgumentException(string.Format(format, args), GetParameterName(reference)); + } + + private static string GetParameterName(Expression reference) + { + var lambda = reference as LambdaExpression; + var member = lambda.Body as MemberExpression; + + return member.Member.Name; + } } \ No newline at end of file diff --git a/Rebus.AdoNet/packages.config b/Rebus.AdoNet/packages.config index d2e5194..e81bee4 100755 --- a/Rebus.AdoNet/packages.config +++ b/Rebus.AdoNet/packages.config @@ -1,8 +1,8 @@ - - - - - - - + + + + + + + \ No newline at end of file From 446b45d76f27a94fd378e27ecf8e632384fa24ec Mon Sep 17 00:00:00 2001 From: Juanje Date: Tue, 19 Jul 2022 20:28:48 +0200 Subject: [PATCH 8/8] Restore gitattributes --- .gitattributes | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitattributes b/.gitattributes index 028e9b4..8b37475 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,8 @@ # These files are text and should be normalized (convert crlf => lf) -#*.cs text diff=csharp +*.cs text diff=csharp *.xaml text *.csproj text -#*.sln text +*.sln text *.tt text *.ps1 text *.cmd text @@ -11,7 +11,7 @@ *.cshtml text *.html text *.js text -#*.config text +*.config text # Images should be treated as binary # (binary is a macro for -text -diff)