diff --git a/Directory.Packages.props b/Directory.Packages.props index 80cc8181..2510df9d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -164,6 +164,9 @@ + + + diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Elsa.Persistence.Dapper.Migrations.csproj b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Elsa.Persistence.Dapper.Migrations.csproj index 6253b199..2754df91 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Elsa.Persistence.Dapper.Migrations.csproj +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Elsa.Persistence.Dapper.Migrations.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs index d44a0e93..0ca45c96 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Management/Initial.cs @@ -12,82 +12,87 @@ public class Initial : Migration /// public override void Up() { - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") - .Create - .Table("WorkflowDefinitions") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("DefinitionId").AsString().NotNullable() - .WithColumn("Name").AsString().Nullable() - .WithColumn("ToolVersion").AsString().Nullable() - .WithColumn("Description").AsString(MaxValue).Nullable() - .WithColumn("ProviderName").AsString().Nullable() - .WithColumn("MaterializerName").AsString().NotNullable() - .WithColumn("MaterializerContext").AsString(MaxValue).Nullable() - .WithColumn("Props").AsString(MaxValue).NotNullable() - .WithColumn("UsableAsActivity").AsBoolean().Nullable() - .WithColumn("StringData").AsString(MaxValue).Nullable() - .WithColumn("BinaryData").AsBinary(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() - .WithColumn("Version").AsInt32().NotNullable() - .WithColumn("IsLatest").AsBoolean().NotNullable() - .WithColumn("IsReadonly").AsBoolean().NotNullable() - .WithColumn("IsPublished").AsBoolean().NotNullable(); - + if (!Schema.Table("WorkflowDefinitions").Exists()) + { + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) + .Create + .Table("WorkflowDefinitions") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("DefinitionId").AsString().NotNullable() + .WithColumn("Name").AsString().Nullable() + .WithColumn("ToolVersion").AsString().Nullable() + .WithColumn("Description").AsString(MaxValue).Nullable() + .WithColumn("ProviderName").AsString().Nullable() + .WithColumn("MaterializerName").AsString().NotNullable() + .WithColumn("MaterializerContext").AsString(MaxValue).Nullable() + .WithColumn("Props").AsString(MaxValue).NotNullable() + .WithColumn("UsableAsActivity").AsBoolean().Nullable() + .WithColumn("StringData").AsString(MaxValue).Nullable() + .WithColumn("BinaryData").AsBinary(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("IsLatest").AsBoolean().NotNullable() + .WithColumn("IsReadonly").AsBoolean().NotNullable() + .WithColumn("IsPublished").AsBoolean().NotNullable(); - IfDatabase("Sqlite") - .Create - .Table("WorkflowDefinitions") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("DefinitionId").AsString().NotNullable() - .WithColumn("Name").AsString().Nullable() - .WithColumn("ToolVersion").AsString().Nullable() - .WithColumn("Description").AsString(MaxValue).Nullable() - .WithColumn("ProviderName").AsString().Nullable() - .WithColumn("MaterializerName").AsString().NotNullable() - .WithColumn("MaterializerContext").AsString().Nullable() - .WithColumn("Props").AsString(MaxValue).NotNullable() - .WithColumn("UsableAsActivity").AsBoolean().Nullable() - .WithColumn("StringData").AsString(MaxValue).Nullable() - .WithColumn("BinaryData").AsBinary(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTime2().NotNullable() - .WithColumn("Version").AsInt32().NotNullable() - .WithColumn("IsLatest").AsBoolean().NotNullable() - .WithColumn("IsReadonly").AsBoolean().NotNullable() - .WithColumn("IsPublished").AsBoolean().NotNullable(); + IfDatabase("Sqlite") + .Create + .Table("WorkflowDefinitions") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("DefinitionId").AsString().NotNullable() + .WithColumn("Name").AsString().Nullable() + .WithColumn("ToolVersion").AsString().Nullable() + .WithColumn("Description").AsString(MaxValue).Nullable() + .WithColumn("ProviderName").AsString().Nullable() + .WithColumn("MaterializerName").AsString().NotNullable() + .WithColumn("MaterializerContext").AsString().Nullable() + .WithColumn("Props").AsString(MaxValue).NotNullable() + .WithColumn("UsableAsActivity").AsBoolean().Nullable() + .WithColumn("StringData").AsString(MaxValue).Nullable() + .WithColumn("BinaryData").AsBinary(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTime2().NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("IsLatest").AsBoolean().NotNullable() + .WithColumn("IsReadonly").AsBoolean().NotNullable() + .WithColumn("IsPublished").AsBoolean().NotNullable(); + } - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") - .Create - .Table("WorkflowInstances") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("DefinitionId").AsString().NotNullable() - .WithColumn("DefinitionVersionId").AsString().NotNullable() - .WithColumn("Version").AsInt32().NotNullable() - .WithColumn("WorkflowState").AsString(MaxValue).NotNullable() - .WithColumn("Status").AsString().NotNullable() - .WithColumn("SubStatus").AsString().NotNullable() - .WithColumn("CorrelationId").AsString().Nullable() - .WithColumn("Name").AsString().Nullable() - .WithColumn("IncidentCount").AsInt32().NotNullable() - .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() - .WithColumn("UpdatedAt").AsDateTimeOffset().Nullable() - .WithColumn("FinishedAt").AsDateTimeOffset().Nullable(); + if (!Schema.Table("WorkflowInstances").Exists()) + { + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) + .Create + .Table("WorkflowInstances") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("DefinitionId").AsString().NotNullable() + .WithColumn("DefinitionVersionId").AsString().NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("WorkflowState").AsString(MaxValue).NotNullable() + .WithColumn("Status").AsString().NotNullable() + .WithColumn("SubStatus").AsString().NotNullable() + .WithColumn("CorrelationId").AsString().Nullable() + .WithColumn("Name").AsString().Nullable() + .WithColumn("IncidentCount").AsInt32().NotNullable() + .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() + .WithColumn("UpdatedAt").AsDateTimeOffset().Nullable() + .WithColumn("FinishedAt").AsDateTimeOffset().Nullable(); - IfDatabase("Sqlite") - .Create - .Table("WorkflowInstances") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("DefinitionId").AsString().NotNullable() - .WithColumn("DefinitionVersionId").AsString().NotNullable() - .WithColumn("Version").AsInt32().NotNullable() - .WithColumn("WorkflowState").AsString(MaxValue).NotNullable() - .WithColumn("Status").AsString().NotNullable() - .WithColumn("SubStatus").AsString().NotNullable() - .WithColumn("CorrelationId").AsString().Nullable() - .WithColumn("Name").AsString().Nullable() - .WithColumn("IncidentCount").AsInt32().NotNullable() - .WithColumn("CreatedAt").AsDateTime2().NotNullable() - .WithColumn("UpdatedAt").AsDateTime2().Nullable() - .WithColumn("FinishedAt").AsDateTime2().Nullable(); + IfDatabase("Sqlite") + .Create + .Table("WorkflowInstances") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("DefinitionId").AsString().NotNullable() + .WithColumn("DefinitionVersionId").AsString().NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("WorkflowState").AsString(MaxValue).NotNullable() + .WithColumn("Status").AsString().NotNullable() + .WithColumn("SubStatus").AsString().NotNullable() + .WithColumn("CorrelationId").AsString().Nullable() + .WithColumn("Name").AsString().Nullable() + .WithColumn("IncidentCount").AsInt32().NotNullable() + .WithColumn("CreatedAt").AsDateTime2().NotNullable() + .WithColumn("UpdatedAt").AsDateTime2().Nullable() + .WithColumn("FinishedAt").AsDateTime2().Nullable(); + } } /// @@ -96,4 +101,4 @@ public override void Down() Delete.Table("WorkflowDefinitions"); Delete.Table("WorkflowInstances"); } -} \ No newline at end of file +} diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs new file mode 100644 index 00000000..b0a5e004 --- /dev/null +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/MigrationDatabases.cs @@ -0,0 +1,35 @@ +namespace Elsa.Persistence.Dapper.Migrations; + +/// +/// Provider names passed to FluentMigrator's alias-aware IfDatabase(params string[]) overload. +/// +/// +/// IfDatabase(params string[]) does an exact +/// match against the processor's DatabaseType or DatabaseTypeAliases. +/// It is not a prefix match. The predicate overload sees only DatabaseType and must not +/// be used here: FluentMigrator 7.2 reports SqlServer2016, MySql8, +/// OracleManaged, PostgreSQL15_0, etc. as DatabaseType, with +/// SqlServer / MySql / Oracle / PostgreSQL as aliases. +/// +internal static class MigrationDatabases +{ + /// + /// Names matched against DatabaseType or aliases for the DateTimeOffset create-table branch + /// (everything except SQLite). + /// + /// + /// Postgres covers the pre-7.2 PostgresProcessor. + /// PostgreSQL is an alias on FluentMigrator 7.2 AddPostgres() / + /// AddPostgres10_0() / AddPostgres11_0() / AddPostgres15_0(). + /// PostgreSQL92 is an alias on AddPostgres92() (DatabaseType Postgres92). + /// + public static readonly string[] DateTimeOffsetProviders = + [ + "SqlServer", + "Oracle", + "MySql", + "Postgres", + "PostgreSQL", + "PostgreSQL92" + ]; +} diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs index 5e472652..7515ba24 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/Initial.cs @@ -13,162 +13,177 @@ public class Initial : Migration /// public override void Up() { - Create - .Table("Triggers") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() - .WithColumn("Name").AsString().NotNullable().Indexed() - .WithColumn("ActivityId").AsString().NotNullable().Indexed() - .WithColumn("Hash").AsString().Nullable().Indexed() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable(); + if (!Schema.Table("Triggers").Exists()) + { + Create + .Table("Triggers") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() + .WithColumn("Name").AsString().NotNullable().Indexed() + .WithColumn("ActivityId").AsString().NotNullable().Indexed() + .WithColumn("Hash").AsString().Nullable().Indexed() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable(); + } - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") - .Create - .Table("Bookmarks") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("CorrelationId").AsString().Nullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() - .WithColumn("Hash").AsString().Nullable().Indexed() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedMetadata").AsString(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable().Indexed(); - - IfDatabase("Sqlite") - .Create - .Table("Bookmarks") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("CorrelationId").AsString().Nullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() - .WithColumn("Hash").AsString().Nullable().Indexed() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedMetadata").AsString(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTime2().NotNullable().Indexed(); + if (!Schema.Table("Bookmarks").Exists()) + { + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) + .Create + .Table("Bookmarks") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("CorrelationId").AsString().Nullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() + .WithColumn("Hash").AsString().Nullable().Indexed() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedMetadata").AsString(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable().Indexed(); - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") - .Create - .Table("WorkflowExecutionLogRecords") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() - .WithColumn("ParentActivityInstanceId").AsString().Nullable().Indexed() - .WithColumn("ActivityId").AsString().NotNullable().Indexed() - .WithColumn("ActivityType").AsString().NotNullable().Indexed() - .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityName").AsString().Nullable().Indexed() - .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() - .WithColumn("EventName").AsString().Nullable().Indexed() - .WithColumn("Message").AsString(MaxValue).Nullable() - .WithColumn("Source").AsString().Nullable() - .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedException").AsString(MaxValue).Nullable() - .WithColumn("Timestamp").AsDateTimeOffset().NotNullable().Indexed() - .WithColumn("Sequence").AsInt64().NotNullable().Indexed() - ; + IfDatabase("Sqlite") + .Create + .Table("Bookmarks") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("CorrelationId").AsString().Nullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() + .WithColumn("Hash").AsString().Nullable().Indexed() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedMetadata").AsString(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTime2().NotNullable().Indexed(); + } - IfDatabase("Sqlite") - .Create - .Table("WorkflowExecutionLogRecords") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("WorkflowVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() - .WithColumn("ParentActivityInstanceId").AsString().Nullable().Indexed() - .WithColumn("ActivityId").AsString().NotNullable().Indexed() - .WithColumn("ActivityType").AsString().NotNullable().Indexed() - .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityName").AsString().Nullable().Indexed() - .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() - .WithColumn("EventName").AsString().Nullable().Indexed() - .WithColumn("Message").AsString(MaxValue).Nullable() - .WithColumn("Source").AsString().Nullable() - .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() - .WithColumn("SerializedException").AsString(MaxValue).Nullable() - .WithColumn("Timestamp").AsDateTime2().NotNullable().Indexed() - .WithColumn("Sequence").AsInt64().NotNullable().Indexed() - ; - - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") - .Create - .Table("ActivityExecutionRecords") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("ActivityId").AsString().NotNullable().Indexed() - .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() - .WithColumn("ActivityType").AsString().NotNullable().Indexed() - .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityName").AsString().Nullable().Indexed() - .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() - .WithColumn("SerializedException").AsString(MaxValue).Nullable() - .WithColumn("StartedAt").AsDateTimeOffset().NotNullable().Indexed() - .WithColumn("CompletedAt").AsDateTimeOffset().Nullable().Indexed() - .WithColumn("HasBookmarks").AsBoolean().NotNullable().Indexed() - .WithColumn("Status").AsString().NotNullable().Indexed() - ; + if (!Schema.Table("WorkflowExecutionLogRecords").Exists()) + { + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) + .Create + .Table("WorkflowExecutionLogRecords") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() + .WithColumn("ParentActivityInstanceId").AsString().Nullable().Indexed() + .WithColumn("ActivityId").AsString().NotNullable().Indexed() + .WithColumn("ActivityType").AsString().NotNullable().Indexed() + .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityName").AsString().Nullable().Indexed() + .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() + .WithColumn("EventName").AsString().Nullable().Indexed() + .WithColumn("Message").AsString(MaxValue).Nullable() + .WithColumn("Source").AsString().Nullable() + .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedException").AsString(MaxValue).Nullable() + .WithColumn("Timestamp").AsDateTimeOffset().NotNullable().Indexed() + .WithColumn("Sequence").AsInt64().NotNullable().Indexed() + ; - IfDatabase("Sqlite") - .Create - .Table("ActivityExecutionRecords") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() - .WithColumn("ActivityId").AsString().NotNullable().Indexed() - .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() - .WithColumn("ActivityType").AsString().NotNullable().Indexed() - .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() - .WithColumn("ActivityName").AsString().Nullable().Indexed() - .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() - .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() - .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() - .WithColumn("SerializedException").AsString(MaxValue).Nullable() - .WithColumn("StartedAt").AsDateTime2().NotNullable().Indexed() - .WithColumn("CompletedAt").AsDateTime2().Nullable().Indexed() - .WithColumn("HasBookmarks").AsBoolean().NotNullable().Indexed() - .WithColumn("Status").AsString().NotNullable().Indexed() - ; + IfDatabase("Sqlite") + .Create + .Table("WorkflowExecutionLogRecords") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("WorkflowDefinitionId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowDefinitionVersionId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("WorkflowVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().NotNullable().Indexed() + .WithColumn("ParentActivityInstanceId").AsString().Nullable().Indexed() + .WithColumn("ActivityId").AsString().NotNullable().Indexed() + .WithColumn("ActivityType").AsString().NotNullable().Indexed() + .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityName").AsString().Nullable().Indexed() + .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() + .WithColumn("EventName").AsString().Nullable().Indexed() + .WithColumn("Message").AsString(MaxValue).Nullable() + .WithColumn("Source").AsString().Nullable() + .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() + .WithColumn("SerializedException").AsString(MaxValue).Nullable() + .WithColumn("Timestamp").AsDateTime2().NotNullable().Indexed() + .WithColumn("Sequence").AsInt64().NotNullable().Indexed() + ; + } - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") - .Create - .Table("WorkflowInboxMessages") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().Nullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().Nullable().Indexed() - .WithColumn("CorrelationId").AsString().Nullable().Indexed() - .WithColumn("Hash").AsString().NotNullable().Indexed() - .WithColumn("SerializedBookmarkPayload").AsString(MaxValue) - .WithColumn("SerializedInput").AsString(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTimeOffset().Indexed() - .WithColumn("ExpiresAt").AsDateTimeOffset().Indexed() - ; - - IfDatabase("Sqlite") - .Create - .Table("WorkflowInboxMessages") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() - .WithColumn("WorkflowInstanceId").AsString().Nullable().Indexed() - .WithColumn("ActivityInstanceId").AsString().Nullable().Indexed() - .WithColumn("CorrelationId").AsString().Nullable().Indexed() - .WithColumn("Hash").AsString().NotNullable().Indexed() - .WithColumn("SerializedBookmarkPayload").AsString(MaxValue) - .WithColumn("SerializedInput").AsString(MaxValue).Nullable() - .WithColumn("CreatedAt").AsDateTime2().Indexed() - .WithColumn("ExpiresAt").AsDateTime2().Indexed() - ; + if (!Schema.Table("ActivityExecutionRecords").Exists()) + { + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) + .Create + .Table("ActivityExecutionRecords") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("ActivityId").AsString().NotNullable().Indexed() + .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() + .WithColumn("ActivityType").AsString().NotNullable().Indexed() + .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityName").AsString().Nullable().Indexed() + .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() + .WithColumn("SerializedException").AsString(MaxValue).Nullable() + .WithColumn("StartedAt").AsDateTimeOffset().NotNullable().Indexed() + .WithColumn("CompletedAt").AsDateTimeOffset().Nullable().Indexed() + .WithColumn("HasBookmarks").AsBoolean().NotNullable().Indexed() + .WithColumn("Status").AsString().NotNullable().Indexed() + ; + + IfDatabase("Sqlite") + .Create + .Table("ActivityExecutionRecords") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("WorkflowInstanceId").AsString().NotNullable().Indexed() + .WithColumn("ActivityId").AsString().NotNullable().Indexed() + .WithColumn("ActivityNodeId").AsString(MaxNodeIdColumnLength).NotNullable().Indexed() + .WithColumn("ActivityType").AsString().NotNullable().Indexed() + .WithColumn("ActivityTypeVersion").AsInt32().NotNullable().Indexed() + .WithColumn("ActivityName").AsString().Nullable().Indexed() + .WithColumn("SerializedActivityState").AsString(MaxValue).Nullable() + .WithColumn("SerializedPayload").AsString(MaxValue).Nullable() + .WithColumn("SerializedOutputs").AsString(MaxValue).Nullable() + .WithColumn("SerializedException").AsString(MaxValue).Nullable() + .WithColumn("StartedAt").AsDateTime2().NotNullable().Indexed() + .WithColumn("CompletedAt").AsDateTime2().Nullable().Indexed() + .WithColumn("HasBookmarks").AsBoolean().NotNullable().Indexed() + .WithColumn("Status").AsString().NotNullable().Indexed() + ; + } + + if (!Schema.Table("WorkflowInboxMessages").Exists()) + { + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) + .Create + .Table("WorkflowInboxMessages") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().Nullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().Nullable().Indexed() + .WithColumn("CorrelationId").AsString().Nullable().Indexed() + .WithColumn("Hash").AsString().NotNullable().Indexed() + .WithColumn("SerializedBookmarkPayload").AsString(MaxValue) + .WithColumn("SerializedInput").AsString(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTimeOffset().Indexed() + .WithColumn("ExpiresAt").AsDateTimeOffset().Indexed() + ; + + IfDatabase("Sqlite") + .Create + .Table("WorkflowInboxMessages") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("ActivityTypeName").AsString().NotNullable().Indexed() + .WithColumn("WorkflowInstanceId").AsString().Nullable().Indexed() + .WithColumn("ActivityInstanceId").AsString().Nullable().Indexed() + .WithColumn("CorrelationId").AsString().Nullable().Indexed() + .WithColumn("Hash").AsString().NotNullable().Indexed() + .WithColumn("SerializedBookmarkPayload").AsString(MaxValue) + .WithColumn("SerializedInput").AsString(MaxValue).Nullable() + .WithColumn("CreatedAt").AsDateTime2().Indexed() + .WithColumn("ExpiresAt").AsDateTime2().Indexed() + ; + } } /// @@ -180,4 +195,4 @@ public override void Down() Delete.Table("ActivityExecutionRecords"); Delete.Table("WorkflowInboxMessages"); } -} \ No newline at end of file +} diff --git a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs index c720590f..58af18ad 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper.Migrations/Runtime/V3_3.cs @@ -21,33 +21,36 @@ public override void Up() Alter.Table("ActivityExecutionRecords").AddColumn("SerializedProperties").AsString(MaxValue).Nullable(); Alter.Table("KeyValuePairs").AddColumn("TenantId").AsString().Nullable(); - IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") - .Create - .Table("BookmarkQueueItems") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("TenantId").AsString().Nullable() - .WithColumn("WorkflowInstanceId").AsString().Nullable() - .WithColumn("CorrelationId").AsString().Nullable() - .WithColumn("BookmarkId").AsString().Nullable() - .WithColumn("StimulusHash").AsString().Nullable() - .WithColumn("ActivityInstanceId").AsString().Nullable() - .WithColumn("ActivityTypeName").AsString().Nullable() - .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() - ; + if (!Schema.Table("BookmarkQueueItems").Exists()) + { + IfDatabase(MigrationDatabases.DateTimeOffsetProviders) + .Create + .Table("BookmarkQueueItems") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("TenantId").AsString().Nullable() + .WithColumn("WorkflowInstanceId").AsString().Nullable() + .WithColumn("CorrelationId").AsString().Nullable() + .WithColumn("BookmarkId").AsString().Nullable() + .WithColumn("StimulusHash").AsString().Nullable() + .WithColumn("ActivityInstanceId").AsString().Nullable() + .WithColumn("ActivityTypeName").AsString().Nullable() + .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable() + ; - IfDatabase("Sqlite") - .Create - .Table("BookmarkQueueItems") - .WithColumn("Id").AsString().PrimaryKey() - .WithColumn("TenantId").AsString().Nullable() - .WithColumn("WorkflowInstanceId").AsString().Nullable() - .WithColumn("CorrelationId").AsString().Nullable() - .WithColumn("BookmarkId").AsString().Nullable() - .WithColumn("StimulusHash").AsString().Nullable() - .WithColumn("ActivityInstanceId").AsString().Nullable() - .WithColumn("ActivityTypeName").AsString().Nullable() - .WithColumn("CreatedAt").AsDateTime2().NotNullable() - ; + IfDatabase("Sqlite") + .Create + .Table("BookmarkQueueItems") + .WithColumn("Id").AsString().PrimaryKey() + .WithColumn("TenantId").AsString().Nullable() + .WithColumn("WorkflowInstanceId").AsString().Nullable() + .WithColumn("CorrelationId").AsString().Nullable() + .WithColumn("BookmarkId").AsString().Nullable() + .WithColumn("StimulusHash").AsString().Nullable() + .WithColumn("ActivityInstanceId").AsString().Nullable() + .WithColumn("ActivityTypeName").AsString().Nullable() + .WithColumn("CreatedAt").AsDateTime2().NotNullable() + ; + } } /// diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs index 2c130a47..d5100e3c 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Abstractions/SqlDialectBase.cs @@ -10,6 +10,12 @@ namespace Elsa.Persistence.Dapper.Abstractions; /// public abstract class SqlDialectBase : ISqlDialect { + /// + public virtual string QuoteIdentifier(string name) => name; + + /// + public virtual string BooleanLiteral(bool value) => value ? "1" : "0"; + /// public virtual string From(string table) => From(table, "*"); diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs index 52b5178a..e607b11f 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Contracts/ISqlDialect.cs @@ -8,6 +8,26 @@ namespace Elsa.Persistence.Dapper.Contracts; /// public interface ISqlDialect { + /// + /// Quotes a table or column identifier. + /// + /// + /// The default returns unchanged so SQLite, SQL Server, + /// MySQL and Oracle SQL stays byte-identical. PostgreSQL overrides this to + /// double-quote identifiers so they match FluentMigrator ForceQuote=true. + /// + string QuoteIdentifier(string name) => name; + + /// + /// Returns a SQL boolean literal. + /// + /// + /// The default emits 1/0 so SQLite, SQL Server, MySQL and Oracle + /// SQL stays byte-identical. PostgreSQL overrides this to true/false + /// because boolean = integer is 42883. + /// + string BooleanLiteral(bool value) => value ? "1" : "0"; + /// /// Returns a SELECT FROM query. /// diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs index fcd37df5..2dbc57df 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Dialects/PostgreSqlDialect.cs @@ -1,20 +1,135 @@ +using Elsa.Common.Entities; using Elsa.Persistence.Dapper.Abstractions; +using Elsa.Persistence.Dapper.Contracts; namespace Elsa.Persistence.Dapper.Dialects; /// -/// Represents a SQL Server dialect. +/// PostgreSQL dialect that emits quoted identifiers to match FluentMigrator's +/// ForceQuote=true table and column names. /// -public class PostgreSqlDialect : SqlDialectBase +/// +/// FluentMigrator 7.2's PostgreSQL processor quotes identifiers, so a table created as +/// ActivityExecutionRecords is stored as "ActivityExecutionRecords". +/// Unquoted SQL folds to lowercase (activityexecutionrecords) and misses those objects +/// (42P01). This dialect re-implements so calls through +/// (the Dapper query builder) are quoted. Shared query-builder + /// inlines go through , whose non-PG default is a no-op. +/// +/// +/// Compatibility: a handmade lowercase, unquoted schema (create table activityexecutionrecords) +/// will not match quoted "ActivityExecutionRecords". The supported PG path is the +/// FluentMigrator-created mixed-case schema. Handmade lowercase schemas must be renamed to the +/// quoted mixed-case identifiers (or recreated by the migrations) before using this dialect. +/// +/// +public class PostgreSqlDialect : SqlDialectBase, ISqlDialect { + /// + public override string QuoteIdentifier(string name) => Quote(name); + + /// + public override string BooleanLiteral(bool value) => value ? "true" : "false"; + + /// + public override string From(string table) => From(table, "*"); + + /// + public override string From(string table, params string[] fields) + { + var fieldList = string.Join(", ", fields.Select(QuoteField)); + return $"select {fieldList} from {Quote(table)} where 1=1"; + } + + /// + public override string And(string field) => $"and {Quote(field)} = @{field}"; + + /// + public override string AndNot(string field) => $"and not {Quote(field)} = @{field}"; + + /// + public override string And(string field, string[] fieldParamNames) => $"and {Quote(field)} in ({string.Join(", ", fieldParamNames)})"; + + /// + public override string AndNot(string field, string[] fieldParamNames) => $"and {Quote(field)} not in ({string.Join(", ", fieldParamNames)})"; + + /// + public override string OrderBy(string field, OrderDirection direction) + { + var directionString = direction == OrderDirection.Ascending ? "asc" : "desc"; + return $"order by {Quote(field)} {directionString}"; + } + /// public override string Upsert(string table, string primaryKeyField, string[] fields, Func? getParamName = null) { - getParamName ??= x => x; - var fieldList = string.Join(", ", fields); - var fieldParamNames = fields.Select(x => $"@{getParamName(x)}"); - var fieldParamList = string.Join(", ", fieldParamNames); - var updateList = string.Join(", ", fields.Select(x => $"{x} = @{getParamName(x)}")); - return $"insert into {table} ({fieldList}) values ({fieldParamList}) on conflict({primaryKeyField}) do update set {updateList}"; + getParamName ??= static x => x; + var fieldList = string.Join(", ", fields.Select(Quote)); + var fieldParamList = string.Join(", ", fields.Select(x => $"@{getParamName(x)}")); + var updateList = string.Join(", ", fields.Select(x => $"{Quote(x)} = @{getParamName(x)}")); + // Include the PK in the insert list: ON CONFLICT cannot insert a NOT NULL PK that was omitted (23502). + return $"insert into {Quote(table)} ({Quote(primaryKeyField)}, {fieldList}) values (@{getParamName(primaryKeyField)}, {fieldParamList}) on conflict({Quote(primaryKeyField)}) do update set {updateList};"; + } + + string ISqlDialect.Delete(string table) => $"delete from {Quote(table)} where 1=1"; + + string ISqlDialect.Count(string table) => ((ISqlDialect)this).Count("*", table); + + string ISqlDialect.Count(string fieldExpression, string table) => + $"select COUNT({QuoteFieldExpression(fieldExpression)}) from {Quote(table)} where 1=1"; + + string ISqlDialect.IsNull(string field) => $"and {Quote(field)} is null"; + + string ISqlDialect.IsNotNull(string field) => $"and {Quote(field)} is not null"; + + string ISqlDialect.Insert(string table, string[] fields, Func? getParamName) + { + getParamName ??= static x => x; + var fieldList = string.Join(", ", fields.Select(Quote)); + var fieldParamList = string.Join(", ", fields.Select(x => $"@{getParamName(x)}")); + return $"INSERT INTO {Quote(table)} ({fieldList}) VALUES ({fieldParamList});"; + } + + string ISqlDialect.Update(string table, string primaryKeyField, string[] fields, Func? getParamName) + { + getParamName ??= static x => x; + var fieldList = string.Join(", ", fields.Select(x => $"{Quote(x)} = @{getParamName(x)}")); + return $"UPDATE {Quote(table)} SET {fieldList} WHERE {Quote(primaryKeyField)} = @{getParamName(primaryKeyField)};"; + } + + string ISqlDialect.Update(string table, string[] fields, Func? getParamName) + { + getParamName ??= static x => x; + var fieldList = string.Join(", ", fields.Select(x => $"{Quote(x)} = @{getParamName(x)}")); + return $"UPDATE {Quote(table)} SET {fieldList} WHERE 1=1"; + } + + /// + /// Quotes a PostgreSQL identifier. * is left as-is. Already-quoted names are returned unchanged. + /// + internal static string Quote(string identifier) + { + if (string.IsNullOrEmpty(identifier) || identifier == "*") + return identifier; + + if (identifier.Length >= 2 && identifier[0] == '"' && identifier[^1] == '"') + return identifier; + + return string.Concat("\"", identifier.Replace("\"", "\"\""), "\""); + } + + private static string QuoteField(string field) => field == "*" ? "*" : Quote(field); + + private static string QuoteFieldExpression(string expression) + { + if (string.IsNullOrWhiteSpace(expression) || expression == "*") + return expression; + + var parts = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 1) + return Quote(parts[0]); + + parts[^1] = Quote(parts[^1]); + return string.Join(' ', parts); } -} \ No newline at end of file +} diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs index 767b081c..879f22b2 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Extensions/ParameterizedQueryBuilderExtensions.cs @@ -81,7 +81,7 @@ public static ParameterizedQuery Delete(this ParameterizedQuery query, string ta public static ParameterizedQuery Delete(this ParameterizedQuery query, string table, string primaryKey, ParameterizedQuery innerQuery) { query.Sql.AppendLine(query.Dialect.Delete(table)); - query.Sql.AppendLine($"and {primaryKey} in ("); + query.Sql.AppendLine($"and {query.QuoteIdent(primaryKey)} in ("); query.Sql.AppendLine(innerQuery.Sql.ToString()); query.Sql.AppendLine(")"); return query; @@ -175,7 +175,7 @@ public static ParameterizedQuery LessThan(this ParameterizedQuery query, string { if (value == null) return query; - query.Sql.AppendLine($"and {field} < @{field}"); + query.Sql.AppendLine($"and {query.QuoteIdent(field)} < @{field}"); query.Parameters.Add($"@{field}", value); return query; @@ -191,7 +191,7 @@ public static ParameterizedQuery WorkflowDefinitionSearchTerm(this Parameterized if (string.IsNullOrWhiteSpace(searchTerm)) return query; var searchTermLike = $"%{searchTerm}%"; - query.Sql.AppendLine("and (Name like @SearchTermLike or Description like @SearchTermLike or Id like @SearchTerm or DefinitionId like @SearchTerm)"); + query.Sql.AppendLine($"and ({query.QuoteIdent("Name")} like @SearchTermLike or {query.QuoteIdent("Description")} like @SearchTermLike or {query.QuoteIdent("Id")} like @SearchTerm or {query.QuoteIdent("DefinitionId")} like @SearchTerm)"); query.Parameters.Add("@SearchTerm", searchTerm); query.Parameters.Add("@SearchTermLike", searchTermLike); return query; @@ -258,7 +258,7 @@ public static ParameterizedQuery StartsWith(this ParameterizedQuery query, strin return query; var searchTermLike = $"{value}%"; - query.Sql.AppendLine($"and {field} like @SearchTermLike"); + query.Sql.AppendLine($"and {query.QuoteIdent(field)} like @SearchTermLike"); query.Parameters.Add($"@{field}", searchTermLike); return query; @@ -275,11 +275,11 @@ public static ParameterizedQuery Is(this ParameterizedQuery query, VersionOption var sql = query.Sql; var options = versionOptions.Value; - if (options.IsDraft) sql.AppendLine("and IsPublished = 0"); - if (options.IsLatest) sql.AppendLine("and IsLatest = 1"); - if (options.IsPublished) sql.AppendLine("and IsPublished = 1"); - if (options.IsLatestOrPublished) sql.AppendLine("and (IsLatest = 1 or IsPublished = 1)"); - if (options.IsLatestAndPublished) sql.AppendLine("and IsLatest = 1 and IsPublished = 1"); + if (options.IsDraft) sql.AppendLine($"and {query.QuoteIdent("IsPublished")} = {query.BoolLit(false)}"); + if (options.IsLatest) sql.AppendLine($"and {query.QuoteIdent("IsLatest")} = {query.BoolLit(true)}"); + if (options.IsPublished) sql.AppendLine($"and {query.QuoteIdent("IsPublished")} = {query.BoolLit(true)}"); + if (options.IsLatestOrPublished) sql.AppendLine($"and ({query.QuoteIdent("IsLatest")} = {query.BoolLit(true)} or {query.QuoteIdent("IsPublished")} = {query.BoolLit(true)})"); + if (options.IsLatestAndPublished) sql.AppendLine($"and {query.QuoteIdent("IsLatest")} = {query.BoolLit(true)} and {query.QuoteIdent("IsPublished")} = {query.BoolLit(true)}"); if (options.Version > 0) { sql.AppendLine(query.Dialect.And("Version")); @@ -299,7 +299,7 @@ public static ParameterizedQuery AndWorkflowInstanceSearchTerm(this Parameterize if (string.IsNullOrWhiteSpace(searchTerm)) return query; var searchTermLike = $"%{searchTerm}%"; - query.Sql.AppendLine("and (Name like @SearchTermLike or ID like @SearchTerm or DefinitionId like @SearchTerm or DefinitionVersionId like @SearchTerm or CorrelationId like @SearchTerm)"); + query.Sql.AppendLine($"and ({query.QuoteIdent("Name")} like @SearchTermLike or {query.QuoteIdent("Id")} like @SearchTerm or {query.QuoteIdent("DefinitionId")} like @SearchTerm or {query.QuoteIdent("DefinitionVersionId")} like @SearchTerm or {query.QuoteIdent("CorrelationId")} like @SearchTerm)"); query.Parameters.Add("@SearchTerm", searchTerm); query.Parameters.Add("@SearchTermLike", searchTermLike); return query; @@ -328,7 +328,7 @@ public static ParameterizedQuery OrderBy(this ParameterizedQuer public static ParameterizedQuery OrderBy(this ParameterizedQuery query, string field, OrderDirection direction) { var directionString = direction == OrderDirection.Ascending ? "asc" : "desc"; - query.Sql.AppendLine($"order by {field} {directionString}"); + query.Sql.AppendLine($"order by {query.QuoteIdent(field)} {directionString}"); return query; } @@ -342,7 +342,7 @@ public static ParameterizedQuery OrderBy(this ParameterizedQuery query, params O if (!orderFields.Any()) return query; - var clauses = string.Join(",", orderFields.Select(x => $"{x.Field} {(x.Direction == OrderDirection.Ascending ? "asc" : "desc")}")); + var clauses = string.Join(",", orderFields.Select(x => $"{query.QuoteIdent(x.Field)} {(x.Direction == OrderDirection.Ascending ? "asc" : "desc")}")); query.Sql.AppendLine($"order by {clauses}"); return query; } @@ -510,4 +510,10 @@ public static ParameterizedQuery Update(this ParameterizedQuery query, string ta if (type == typeof(byte[])) return DbType.Binary; return null; } + + private static string QuoteIdent(this ParameterizedQuery query, string name) => + query.Dialect.QuoteIdentifier(name); + + private static string BoolLit(this ParameterizedQuery query, bool value) => + query.Dialect.BooleanLiteral(value); } \ No newline at end of file diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs index d36a7e56..df474515 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Management/Stores/DapperWorkflowInstanceStore.cs @@ -194,7 +194,7 @@ public async ValueTask TryMarkInterruptedAsync(string workflowInstanceId, // Refuse every Finished row (#8419). Distinct param names avoid colliding // with SET Status = Running. q.Parameters.Add("@FinishedStatus", WorkflowStatus.Finished.ToString()); - q.Sql.AppendLine("and not Status = @FinishedStatus"); + q.Sql.AppendLine($"and not {q.Dialect.QuoteIdentifier("Status")} = @FinishedStatus"); }, cancellationToken); diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Services/PostgreSqlDbConnectionProvider.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Services/PostgreSqlDbConnectionProvider.cs index 39b62723..16829755 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Services/PostgreSqlDbConnectionProvider.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Services/PostgreSqlDbConnectionProvider.cs @@ -1,6 +1,8 @@ using System.Data; +using Dapper; using Elsa.Persistence.Dapper.Contracts; using Elsa.Persistence.Dapper.Dialects; +using Elsa.Persistence.Dapper.TypeHandlers.PostgreSql; using JetBrains.Annotations; using Npgsql; @@ -14,6 +16,13 @@ public class PostgreSqlDbConnectionProvider : IDbConnectionProvider { private readonly string _connectionString = ""; + static PostgreSqlDbConnectionProvider() + { + // Last AddTypeHandler for DateTimeOffset wins. Replace the SQLite string-only + // handler so Npgsql DateTime values can be read back as DateTimeOffset. + SqlMapper.AddTypeHandler(new DateTimeOffsetHandler()); + } + /// /// Initializes a new instance of the class. /// diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/TypeHandlers/PostgreSql/DateTimeOffsetHandler.cs b/src/modules/persistence/Elsa.Persistence.Dapper/TypeHandlers/PostgreSql/DateTimeOffsetHandler.cs new file mode 100644 index 00000000..3315e29f --- /dev/null +++ b/src/modules/persistence/Elsa.Persistence.Dapper/TypeHandlers/PostgreSql/DateTimeOffsetHandler.cs @@ -0,0 +1,29 @@ +using System.Data; +using Dapper; + +namespace Elsa.Persistence.Dapper.TypeHandlers.PostgreSql; + +/// +/// Maps PostgreSQL timestamptz values to . +/// +/// +/// Npgsql returns . The SQLite handler registered by +/// SqliteDbConnectionProvider assumes a string and throws +/// on that DateTime. This handler accepts +/// DateTime, DateTimeOffset, and string so PG reads work even when the SQLite +/// handler was registered earlier in the process. +/// +internal sealed class DateTimeOffsetHandler : SqlMapper.TypeHandler +{ + public override void SetValue(IDbDataParameter parameter, DateTimeOffset value) => parameter.Value = value; + + public override DateTimeOffset Parse(object value) => value switch + { + DateTimeOffset dto => dto, + DateTime dt => dt.Kind == DateTimeKind.Unspecified + ? new DateTimeOffset(DateTime.SpecifyKind(dt, DateTimeKind.Utc)) + : new DateTimeOffset(dt), + string s => DateTimeOffset.Parse(s), + _ => throw new InvalidCastException($"Cannot convert {value.GetType()} to DateTimeOffset.") + }; +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs new file mode 100644 index 00000000..118e5b9f --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgreSqlMigrationTests.cs @@ -0,0 +1,489 @@ +using Dapper; +using Elsa.Common.Multitenancy; +using Elsa.Extensions; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Modules.Management.Records; +using Elsa.Persistence.Dapper.Modules.Management.Stores; +using Elsa.Persistence.Dapper.Modules.Runtime.Records; +using Elsa.Persistence.Dapper.Services; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Common.Entities; +using Elsa.Common.Models; +using Elsa.Persistence.Dapper.Models; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.Options; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Options; +using Elsa.Workflows.Runtime.OrderDefinitions; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Fresh PostgreSQL Testcontainers coverage for issue #255: migrate, persist through +/// the quoted Dapper PG dialect, and run a simple workflow to completion on those stores. +/// +public sealed class DapperPostgreSqlMigrationTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:16") + .WithDatabase("elsa") + .WithUsername("elsa") + .WithPassword("elsa") + .Build(); + + private string _connectionString = null!; + + public async Task InitializeAsync() + { + try + { + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + catch + { + await DisposeAsync(); + throw; + } + } + + public async Task DisposeAsync() + { + await _container.DisposeAsync(); + } + + [Fact(DisplayName = "A fresh PostgreSQL DB migrates, persists records, and runs a WriteLine workflow to completion through Dapper stores")] + public async Task FreshPostgreSqlDatabase_MigratesPersistsAndRunsWorkflowToCompletion() + { + MigrateUp(); + + var columns = ActivityExecutionRecordColumns(); + Assert.Contains(columns, name => name.Equals("AggregateFaultCount", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(columns, name => name.Equals("SerializedMetadata", StringComparison.OrdinalIgnoreCase)); + Assert.True(TableExists("Roles")); + Assert.True(TableExists("WorkflowDefinitions")); + Assert.True(TableExists("WorkflowInstances")); + Assert.True(TableExists("Bookmarks")); + Assert.True(TableExists("ActivityExecutionRecords")); + Assert.True(TableExists("BookmarkQueueItems")); + + await PersistRecordsThroughDapperStores(); + await RunWriteLineWorkflowToCompletion(); + await ExerciseListSearchOrderVersionAndPagedDelete(); + } + + private async Task PersistRecordsThroughDapperStores() + { + var tenantAccessor = new TestTenantAccessor(); + var connectionProvider = new PostgreSqlDbConnectionProvider(_connectionString); + var activityStore = new Store(connectionProvider, tenantAccessor, "ActivityExecutionRecords"); + var instanceStore = new Store(connectionProvider, tenantAccessor, "WorkflowInstances"); + + var activity = new ActivityExecutionRecordRecord + { + Id = "rec-pg-1", + WorkflowInstanceId = "wf-pg-1", + ActivityId = "act-1", + ActivityNodeId = "node-1", + ActivityType = "Elsa.WriteLine", + ActivityTypeVersion = 1, + ActivityName = "WriteLine", + StartedAt = DateTimeOffset.UtcNow, + HasBookmarks = false, + Status = "Completed", + SerializedMetadata = """{"source":"fresh-pg"}""", + AggregateFaultCount = 2 + }; + var instance = new WorkflowInstanceRecord + { + Id = "wf-pg-1", + DefinitionId = "def-pg-1", + DefinitionVersionId = "def-pg-1:1", + Version = 1, + WorkflowState = "{}", + Status = "Finished", + SubStatus = "Finished", + IncidentCount = 0, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + await activityStore.SaveAsync(activity); + await instanceStore.SaveAsync(instance); + + var loadedActivity = await activityStore.FindAsync(q => q.Is(nameof(ActivityExecutionRecordRecord.Id), activity.Id), cancellationToken: CancellationToken.None); + var loadedInstance = await instanceStore.FindAsync(q => q.Is(nameof(WorkflowInstanceRecord.Id), instance.Id), cancellationToken: CancellationToken.None); + + Assert.NotNull(loadedActivity); + Assert.Equal(2, loadedActivity.AggregateFaultCount); + Assert.Equal("""{"source":"fresh-pg"}""", loadedActivity.SerializedMetadata); + Assert.NotNull(loadedInstance); + Assert.Equal("Finished", loadedInstance.Status); + Assert.Equal("def-pg-1", loadedInstance.DefinitionId); + } + + private async Task RunWriteLineWorkflowToCompletion() + { + using var services = CreateElsaHost(); + using var scope = services.CreateScope(); + var sp = scope.ServiceProvider; + + var workflow = Workflow.FromActivity(new WriteLine("hello from dapper postgres")); + var graph = await sp.GetRequiredService().BuildAsync(workflow); + var instanceId = $"wf-pg-run-{Guid.NewGuid():N}"; +#pragma warning disable CS0618 // IWorkflowHost persists through IWorkflowInstanceStore / IActivityExecutionStore; IWorkflowInvoker does not. + var host = await sp.GetRequiredService() + .CreateAsync(graph, new WorkflowHostOptions { NewWorkflowInstanceId = instanceId }); +#pragma warning restore CS0618 + + await host.RunWorkflowAsync(new RunWorkflowOptions()); + + Assert.Equal(WorkflowStatus.Finished, host.WorkflowState.Status); + Assert.Equal(WorkflowSubStatus.Finished, host.WorkflowState.SubStatus); + + var storedInstance = await sp.GetRequiredService() + .FindAsync(new WorkflowInstanceFilter { Id = instanceId }); + Assert.NotNull(storedInstance); + Assert.Equal(WorkflowStatus.Finished, storedInstance.Status); + + var storedActivities = (await sp.GetRequiredService() + .FindManyAsync(new ActivityExecutionRecordFilter { WorkflowInstanceId = instanceId })).ToList(); + Assert.NotEmpty(storedActivities); + Assert.All(storedActivities, record => Assert.Equal(ActivityStatus.Completed, record.Status)); + } + + private async Task ExerciseListSearchOrderVersionAndPagedDelete() + { + using var services = CreateElsaHost(); + using var scope = services.CreateScope(); + var sp = scope.ServiceProvider; + var definitions = sp.GetRequiredService(); + var instances = sp.GetRequiredService(); + + var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + await definitions.SaveAsync(Definition("def-alpha", "def-alpha:1", "Alpha", version: 1, latest: false, published: true, createdAt: t0)); + await definitions.SaveAsync(Definition("def-alpha", "def-alpha:2", "Alpha", version: 2, latest: true, published: false, createdAt: t0.AddMinutes(1))); + await definitions.SaveAsync(Definition("def-bravo", "def-bravo:1", "BravoNeedle", version: 1, latest: true, published: true, createdAt: t0.AddMinutes(2))); + + var listed = (await definitions.FindManyAsync( + new WorkflowDefinitionFilter { DefinitionIds = ["def-alpha", "def-bravo"] }, + new WorkflowDefinitionOrder(x => x.Name, OrderDirection.Descending), + CancellationToken.None)).ToList(); + Assert.Equal(3, listed.Count); + Assert.Equal("BravoNeedle", listed[0].Name); + + var searched = (await definitions.FindManyAsync( + new WorkflowDefinitionFilter { SearchTerm = "Needle" }, + CancellationToken.None)).ToList(); + Assert.Equal(["def-bravo:1"], searched.Select(x => x.Id)); + + var latest = (await definitions.FindManyAsync( + new WorkflowDefinitionFilter { VersionOptions = VersionOptions.Latest }, + new WorkflowDefinitionOrder(x => x.CreatedAt, OrderDirection.Ascending), + CancellationToken.None)).ToList(); + Assert.Equal(["def-alpha:2", "def-bravo:1"], latest.Select(x => x.Id)); + + var published = (await definitions.FindManyAsync( + new WorkflowDefinitionFilter { VersionOptions = VersionOptions.Published }, + CancellationToken.None)).ToList(); + Assert.Equal(2, published.Count); + Assert.All(published, definition => Assert.True(definition.IsPublished)); + + var specific = await definitions.FindAsync(new WorkflowDefinitionFilter + { + DefinitionId = "def-alpha", + VersionOptions = VersionOptions.SpecificVersion(2) + }); + Assert.NotNull(specific); + Assert.Equal("def-alpha:2", specific.Id); + + var latestOrPublished = (await definitions.FindManyAsync( + new WorkflowDefinitionFilter { DefinitionId = "def-alpha", VersionOptions = VersionOptions.LatestOrPublished }, + CancellationToken.None)).ToList(); + Assert.Equal(2, latestOrPublished.Count); + + var studioDefinitions = await definitions.FindSummariesAsync( + new WorkflowDefinitionFilter { VersionOptions = VersionOptions.Latest }, + new WorkflowDefinitionOrder(x => x.CreatedAt, OrderDirection.Ascending), + PageArgs.FromRange(0, 10)); + Assert.Contains(studioDefinitions.Items, summary => summary.DefinitionId == "def-bravo"); + + await sp.GetRequiredService().PopulateRegistryAsync(); + + var publisher = sp.GetRequiredService(); + var draft = await publisher.NewAsync(new WriteLine("published from dapper postgres")); + draft.Name = "PublishMe"; + await publisher.SaveDraftAsync(draft); + var publishedDefinition = await publisher.PublishAsync(draft.DefinitionId); + Assert.True(publishedDefinition.Succeeded); + + var graph = await sp.GetRequiredService() + .FindWorkflowGraphAsync(draft.DefinitionId, VersionOptions.Published); + Assert.NotNull(graph); + Assert.Equal(draft.DefinitionId, graph.Workflow.Identity.DefinitionId); + + var page = await instances.FindManyAsync( + new WorkflowInstanceFilter { DefinitionId = "def-pg-1" }, + PageArgs.FromRange(0, 10), + new WorkflowInstanceOrder(x => x.CreatedAt, OrderDirection.Descending)); + Assert.True(page.TotalCount >= 1); + + var named = (await instances.FindManyAsync( + new WorkflowInstanceFilter { SearchTerm = "def-pg-1" }, + new WorkflowInstanceOrder(x => x.Id, OrderDirection.Ascending))).ToList(); + Assert.Contains(named, instance => instance.DefinitionId == "def-pg-1"); + + var studioInstances = await instances.SummarizeManyAsync( + new WorkflowInstanceFilter { SearchTerm = "def-pg-1" }, + PageArgs.FromRange(0, 10), + new WorkflowInstanceOrder(x => x.CreatedAt, OrderDirection.Descending)); + Assert.True(studioInstances.TotalCount >= 1); + + var beforeLastUpdated = (await instances.FindManyAsync( + new WorkflowInstanceFilter { BeforeLastUpdated = DateTimeOffset.UtcNow.AddMinutes(1) }, + new WorkflowInstanceOrder(x => x.UpdatedAt, OrderDirection.Ascending))).ToList(); + Assert.Contains(beforeLastUpdated, instance => instance.Id == "wf-pg-1"); + + var journal = sp.GetRequiredService(); + await journal.SaveAsync(new WorkflowExecutionLogRecord + { + Id = "log-pg-1", + WorkflowDefinitionId = "def-pg-1", + WorkflowDefinitionVersionId = "def-pg-1:1", + WorkflowInstanceId = "wf-pg-1", + WorkflowVersion = 1, + ActivityInstanceId = "act-inst-1", + ActivityId = "act-1", + ActivityType = "Elsa.WriteLine", + ActivityTypeVersion = 1, + ActivityNodeId = "node-1", + Timestamp = DateTimeOffset.UtcNow, + Sequence = 1, + EventName = "Executed" + }); + var journalPage = await journal.FindManyAsync( + new WorkflowExecutionLogRecordFilter { WorkflowInstanceId = "wf-pg-1" }, + PageArgs.FromRange(0, 10), + new WorkflowExecutionLogRecordOrder(x => x.Timestamp, OrderDirection.Descending)); + Assert.Contains(journalPage.Items, record => record.Id == "log-pg-1"); + + var activities = sp.GetRequiredService(); + var orderedActivities = (await activities.FindManyAsync( + new ActivityExecutionRecordFilter { WorkflowInstanceId = "wf-pg-1" }, + new ActivityExecutionRecordOrder(x => x.StartedAt, OrderDirection.Descending))).ToList(); + Assert.Contains(orderedActivities, record => record.Id == "rec-pg-1"); + + InsertBookmarkQueueRow("bq-1", "wf-pg-1", t0); + InsertBookmarkQueueRow("bq-2", "wf-pg-1", t0.AddMinutes(1)); + var queue = sp.GetRequiredService(); + var queuePage = await queue.PageAsync( + PageArgs.FromRange(0, 10), + new BookmarkQueueFilter { WorkflowInstanceId = "wf-pg-1" }, + new BookmarkQueueItemOrder(x => x.CreatedAt, OrderDirection.Descending)); + Assert.Equal(["bq-2", "bq-1"], queuePage.Items.Select(x => x.Id)); + + var tenantAccessor = new TestTenantAccessor(); + var connectionProvider = new PostgreSqlDbConnectionProvider(_connectionString); + var instanceStore = new Store(connectionProvider, tenantAccessor, "WorkflowInstances"); + await instanceStore.SaveAsync(new WorkflowInstanceRecord + { + Id = "wf-page-old", + DefinitionId = "def-pg-page", + DefinitionVersionId = "def-pg-page:1", + Version = 1, + WorkflowState = "{}", + Status = "Finished", + SubStatus = "Finished", + Name = "Zebra", + CreatedAt = t0, + UpdatedAt = t0 + }); + await instanceStore.SaveAsync(new WorkflowInstanceRecord + { + Id = "wf-page-new", + DefinitionId = "def-pg-page", + DefinitionVersionId = "def-pg-page:1", + Version = 1, + WorkflowState = "{}", + Status = "Finished", + SubStatus = "Finished", + Name = "AlphaInst", + CreatedAt = t0.AddHours(1), + UpdatedAt = t0.AddHours(1) + }); + await instanceStore.SaveAsync(new WorkflowInstanceRecord + { + Id = "wf-page-mid", + DefinitionId = "def-pg-page", + DefinitionVersionId = "def-pg-page:1", + Version = 1, + WorkflowState = "{}", + Status = "Finished", + SubStatus = "Finished", + Name = "Middle", + CreatedAt = t0.AddMinutes(30), + UpdatedAt = t0.AddMinutes(30) + }); + + var ordered = (await instanceStore.FindManyAsync( + q => q.Is(nameof(WorkflowInstanceRecord.DefinitionId), "def-pg-page"), + nameof(WorkflowInstanceRecord.Name), + OrderDirection.Ascending, + tenantAgnostic: false)).ToList(); + Assert.Equal(["wf-page-new", "wf-page-mid", "wf-page-old"], ordered.Select(x => x.Id)); + + var searchHits = (await instanceStore.FindManyAsync( + q => q.AndWorkflowInstanceSearchTerm("Zebra"), + tenantAgnostic: false)).ToList(); + Assert.Equal(["wf-page-old"], searchHits.Select(x => x.Id)); + + var inner = connectionProvider.CreateQuery() + .From("WorkflowInstances", "Id") + .Is(nameof(WorkflowInstanceRecord.DefinitionId), "def-pg-page") + .OrderBy(new OrderField(nameof(WorkflowInstanceRecord.CreatedAt), OrderDirection.Descending)) + .Page(PageArgs.FromRange(0, 1)); + var delete = connectionProvider.CreateQuery().Delete("WorkflowInstances", "Id", inner); + delete.Parameters.AddDynamicParams(inner.Parameters); // Store.DeleteAsync drops these today (tracked separately) + using (var connection = connectionProvider.GetConnection()) + Assert.Equal(1, await connection.ExecuteAsync(delete.Sql.ToString(), delete.Parameters)); + + var remaining = (await instanceStore.FindManyAsync( + q => q.Is(nameof(WorkflowInstanceRecord.DefinitionId), "def-pg-page"), + nameof(WorkflowInstanceRecord.CreatedAt), + OrderDirection.Descending, + tenantAgnostic: false)).ToList(); + Assert.Equal(["wf-page-mid", "wf-page-old"], remaining.Select(x => x.Id)); + + await instanceStore.SaveAsync(new WorkflowInstanceRecord + { + Id = "wf-interrupt", + DefinitionId = "def-pg-page", + DefinitionVersionId = "def-pg-page:1", + Version = 1, + WorkflowState = "{}", + Status = "Running", + SubStatus = "Executing", + CreatedAt = t0, + UpdatedAt = t0 + }); + var interruptStore = new DapperWorkflowInstanceStore(instanceStore, sp.GetRequiredService()); + Assert.True(await interruptStore.TryMarkInterruptedAsync("wf-interrupt")); + } + + private void InsertBookmarkQueueRow(string id, string workflowInstanceId, DateTimeOffset createdAt) + { + using var connection = new NpgsqlConnection(_connectionString); + connection.Execute( + """ + INSERT INTO "BookmarkQueueItems" ("Id", "WorkflowInstanceId", "CreatedAt") + VALUES (@id, @workflowInstanceId, @createdAt) + """, + new { id, workflowInstanceId, createdAt }); + } + + private static WorkflowDefinition Definition( + string definitionId, + string id, + string name, + int version, + bool latest, + bool published, + DateTimeOffset createdAt) => + new() + { + Id = id, + DefinitionId = definitionId, + Name = name, + Version = version, + IsLatest = latest, + IsPublished = published, + CreatedAt = createdAt, + MaterializerName = "Json", + StringData = "{}" + }; + + private ServiceProvider CreateElsaHost() + { + var services = new ServiceCollection(); + services.AddLogging(); + var module = services.CreateModule(); + module.AddActivitiesFrom(); + module.UseDapper(dapper => + { + dapper.DbConnectionProvider = _ => new PostgreSqlDbConnectionProvider(_connectionString); + }); + module.UseWorkflowManagement(management => management.UseDapper()); + module.UseWorkflowRuntime(runtime => runtime.UseDapper()); + module.Apply(); + return services.BuildServiceProvider(validateScopes: true); + } + + private void MigrateUp() + { + using var services = CreateMigrator(); + using var scope = services.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService(); + runner.MigrateUp(); + } + + private ServiceProvider CreateMigrator() => + new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => rb + .AddPostgres() + .WithGlobalConnectionString(_connectionString) + .ScanIn(typeof(Elsa.Persistence.Dapper.Migrations.Runtime.Initial).Assembly).For.Migrations()) + .BuildServiceProvider(false); + + private IReadOnlyCollection ActivityExecutionRecordColumns() + { + using var connection = new NpgsqlConnection(_connectionString); + return connection.Query( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name ILIKE 'ActivityExecutionRecords' + """).ToList(); + } + + private bool TableExists(string tableName) + { + using var connection = new NpgsqlConnection(_connectionString); + return connection.ExecuteScalar( + """ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name ILIKE @tableName + """, + new { tableName }) == 1; + } + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs new file mode 100644 index 00000000..e7d0b743 --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperPostgresProcessorNameTests.cs @@ -0,0 +1,116 @@ +using Elsa.Persistence.Dapper.Migrations; +using FluentMigrator; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Documents FluentMigrator 7.2 processor identifiers and the alias-aware IfDatabase match. +/// +public sealed class DapperPostgresProcessorNameTests +{ + private static readonly string[] SqliteProviders = ["Sqlite"]; + + [Fact(DisplayName = "FluentMigrator 7.2 AddPostgres() reports PostgreSQL15_0 / PostgreSQL, not Postgres")] + public void AddPostgres_Processor_ReportsPostgreSQL_NotPostgres() + { + var processor = ResolveIds(rb => rb.AddPostgres()); + + Assert.Equal("PostgreSQL15_0", processor.DatabaseType); + Assert.Contains("PostgreSQL", processor.Names); + Assert.DoesNotContain("Postgres", processor.Names); + + var legacyGuard = new[] { "SqlServer", "Oracle", "MySql", "Postgres" }; + Assert.False(legacyGuard.Any(processor.Names.Contains), "IfDatabase(\"Postgres\") must not match FluentMigrator 7.2 AddPostgres()."); + Assert.True(Matches(MigrationDatabases.DateTimeOffsetProviders, processor)); + } + + [Fact(DisplayName = "AddSqlServer() takes the DateTimeOffset IfDatabase branch via the SqlServer alias")] + public void AddSqlServer_TakesDateTimeOffsetBranch() => + AssertExactlyOneCreateBranch(ResolveIds(rb => rb.AddSqlServer()), dateTimeOffset: true); + + [Fact(DisplayName = "AddSQLite() takes the Sqlite IfDatabase branch")] + public void AddSqlite_TakesSqliteBranch() => + AssertExactlyOneCreateBranch(ResolveIds(rb => rb.AddSQLite()), dateTimeOffset: false); + + [Fact(DisplayName = "AddPostgres() takes the DateTimeOffset IfDatabase branch via the PostgreSQL alias")] + public void AddPostgres_TakesDateTimeOffsetBranch() => + AssertExactlyOneCreateBranch(ResolveIds(rb => rb.AddPostgres()), dateTimeOffset: true); + + [Fact(DisplayName = "AddMySql() takes the DateTimeOffset IfDatabase branch via the MySql alias")] + public void AddMySql_TakesDateTimeOffsetBranch() => + AssertExactlyOneCreateBranch(ResolveIds(rb => rb.AddMySql()), dateTimeOffset: true); + + [Fact(DisplayName = "AddOracleManaged() takes the DateTimeOffset IfDatabase branch via the Oracle alias")] + public void AddOracleManaged_TakesDateTimeOffsetBranch() => + AssertExactlyOneCreateBranch(ResolveIds(rb => rb.AddOracleManaged()), dateTimeOffset: true); + + [Fact] + public void DateTimeOffsetProviders_MatchSqlServer2016ThroughAlias_NotDatabaseTypeAlone() + { + // AddSqlServer() reports DatabaseType SqlServer2016 with alias SqlServer. + // The params overload matches aliases; a DatabaseType-only predicate would miss it. + Assert.DoesNotContain("SqlServer2016", MigrationDatabases.DateTimeOffsetProviders); + Assert.True(Matches(MigrationDatabases.DateTimeOffsetProviders, "SqlServer2016", ["SqlServer"])); + Assert.False(Matches(MigrationDatabases.DateTimeOffsetProviders, "SqlServer2016", [])); + } + + [Theory] + [InlineData("Postgres")] + [InlineData("PostgreSQL")] + [InlineData("PostgreSQL92")] + [InlineData("SqlServer")] + [InlineData("Oracle")] + [InlineData("MySql")] + public void DateTimeOffsetProviders_ContainsCanonicalNames(string name) + { + Assert.Contains(name, MigrationDatabases.DateTimeOffsetProviders); + } + + [Fact] + public void DateTimeOffsetProviders_DoesNotContainSqlite() + { + Assert.DoesNotContain("Sqlite", MigrationDatabases.DateTimeOffsetProviders, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain("SQLite", MigrationDatabases.DateTimeOffsetProviders, StringComparer.OrdinalIgnoreCase); + } + + private static void AssertExactlyOneCreateBranch(ProcessorIds processor, bool dateTimeOffset) + { + var dateTimeOffsetApplies = Matches(MigrationDatabases.DateTimeOffsetProviders, processor); + var sqliteApplies = Matches(SqliteProviders, processor); + Assert.NotEqual(dateTimeOffsetApplies, sqliteApplies); + Assert.Equal(dateTimeOffset, dateTimeOffsetApplies); + } + + private static bool Matches(IReadOnlyCollection guard, ProcessorIds processor) => + Matches(guard, processor.DatabaseType, processor.Aliases); + + private static bool Matches(IReadOnlyCollection guard, string databaseType, IEnumerable aliases) + { + // Same semantics as FluentMigrator 7.2 IfDatabase(params string[]): + // exact OrdinalIgnoreCase against DatabaseType ∪ DatabaseTypeAliases. + var names = new HashSet(aliases, StringComparer.OrdinalIgnoreCase) { databaseType }; + return guard.Any(names.Contains); + } + + private static ProcessorIds ResolveIds(Action addProvider) + { + using var services = new ServiceCollection() + .AddFluentMigratorCore() + .ConfigureRunner(rb => + { + addProvider(rb); + rb.WithGlobalConnectionString("Host=localhost;Database=unused"); + }) + .BuildServiceProvider(false); + using var scope = services.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService(); + return new ProcessorIds(processor.DatabaseType, processor.DatabaseTypeAliases.ToArray()); + } + + private sealed record ProcessorIds(string DatabaseType, string[] Aliases) + { + public HashSet Names { get; } = new(Aliases, StringComparer.OrdinalIgnoreCase) { DatabaseType }; + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs index 34bbf453..75c5dc8a 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperRuntimeMigrationTests.cs @@ -80,6 +80,22 @@ public void UpgradeFromV3_5_AppliesV3_7WithoutDuplicateAggregateFaultCount() Assert.True(TableExists("Roles")); } + [Fact(DisplayName = "Replaying 10001/20001 when tables already exist does not fail (no VersionInfo rows)")] + public void ReplayingCreateTableMigrations_WhenTablesAlreadyExist_DoesNotFail() + { + MigrateUp(); + using (var connection = new SqliteConnection(_connectionString)) + connection.Execute("DELETE FROM VersionInfo WHERE Version IN (10001, 20001)"); + + MigrateUp(); + + Assert.True(TableExists("WorkflowDefinitions")); + Assert.True(TableExists("WorkflowInstances")); + Assert.True(TableExists("Bookmarks")); + Assert.True(TableExists("ActivityExecutionRecords")); + Assert.True(TableExists("Roles")); + } + [Fact(DisplayName = "Rolling back V3_7 leaves V3_5's AggregateFaultCount so V3_5.Down() does not double-drop")] public void RollingBackThroughV3_7AndV3_5_DoesNotDoubleDropAggregateFaultCount() { diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj index e80df5b7..6b1868be 100644 --- a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/Elsa.Persistence.Dapper.UnitTests.csproj @@ -1,8 +1,14 @@  + + + + + + diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs new file mode 100644 index 00000000..49fa53a0 --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/NonPgQuerySqlSnapshotTests.cs @@ -0,0 +1,252 @@ +using Elsa.Common.Entities; +using Elsa.Common.Models; +using Elsa.Persistence.Dapper.Contracts; +using Elsa.Persistence.Dapper.Dialects; +using Elsa.Persistence.Dapper.Extensions; +using Elsa.Persistence.Dapper.Models; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// Captured from the query builder on this branch before QuoteIdentifier was wired through +/// ParameterizedQueryBuilderExtensions. SQLite and SQL Server (the only non-PG dialects +/// in this repo; MySQL/Oracle use the same unquoted SqlDialectBase path) must stay +/// byte-identical to these strings. +/// +public sealed class NonPgQuerySqlSnapshotTests +{ + [Fact(DisplayName = "SQLite query-builder SQL is byte-identical to the pre-hook capture")] + public void Sqlite_QueryBuilderSql_IsByteIdenticalToPreHookCapture() => + AssertSnapshots(new SqliteDialect(), SqliteExpected); + + [Fact(DisplayName = "SQL Server query-builder SQL is byte-identical to the pre-hook capture")] + public void SqlServer_QueryBuilderSql_IsByteIdenticalToPreHookCapture() => + AssertSnapshots(new SqlServerDialect(), SqlServerExpected); + + [Fact(DisplayName = "Default QuoteIdentifier is a no-op on non-PG dialects")] + public void NonPg_QuoteIdentifier_ReturnsInputUnchanged() + { + ISqlDialect sqlite = new SqliteDialect(); + ISqlDialect sqlServer = new SqlServerDialect(); + Assert.Equal("CreatedAt", sqlite.QuoteIdentifier("CreatedAt")); + Assert.Equal("CreatedAt", sqlServer.QuoteIdentifier("CreatedAt")); + Assert.Same("CreatedAt", sqlite.QuoteIdentifier("CreatedAt")); + Assert.Equal("1", sqlite.BooleanLiteral(true)); + Assert.Equal("0", sqlServer.BooleanLiteral(false)); + } + + private static void AssertSnapshots(ISqlDialect dialect, IReadOnlyDictionary expected) + { + foreach (var (label, sql) in BuildQueries(dialect)) + { + Assert.True(expected.ContainsKey(label), $"missing expected snapshot for {label}"); + Assert.Equal(expected[label], sql); + } + + Assert.Equal(expected.Count, BuildQueries(dialect).Count()); + } + + internal static IEnumerable<(string Label, string Sql)> BuildQueries(ISqlDialect dialect) + { + yield return ("from-and", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions", "Id", "Name", "Version") + .Is("DefinitionId", "def-1") + .IsNot("Name", "skip") + .In("Id", new object[] { "a", "b" }) + .IsNull("Description") + .Sql.ToString()); + + yield return ("order-asc", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .OrderBy("CreatedAt", OrderDirection.Ascending) + .Sql.ToString()); + + yield return ("order-desc", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .OrderBy("CreatedAt", OrderDirection.Descending) + .Sql.ToString()); + + yield return ("order-multi", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .OrderBy( + new OrderField("Name", OrderDirection.Ascending), + new OrderField("CreatedAt", OrderDirection.Descending)) + .Sql.ToString()); + + yield return ("version-draft", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.Draft) + .Sql.ToString()); + + yield return ("version-latest", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.Latest) + .Sql.ToString()); + + yield return ("version-published", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.Published) + .Sql.ToString()); + + yield return ("version-latest-or-published", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.LatestOrPublished) + .Sql.ToString()); + + yield return ("version-latest-and-published", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.LatestAndPublished) + .Sql.ToString()); + + yield return ("version-specific", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .Is(VersionOptions.SpecificVersion(3)) + .Sql.ToString()); + + yield return ("definition-search", new ParameterizedQuery(dialect) + .From("WorkflowDefinitions") + .WorkflowDefinitionSearchTerm("alpha") + .Sql.ToString()); + + yield return ("instance-search", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .AndWorkflowInstanceSearchTerm("bravo") + .Sql.ToString()); + + yield return ("starts-with", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .StartsWith("Name", true, "pre") + .Sql.ToString()); + + yield return ("less-than", new ParameterizedQuery(dialect) + .From("WorkflowInstances") + .LessThan("UpdatedAt", DateTimeOffset.UnixEpoch) + .Sql.ToString()); + + var inner = new ParameterizedQuery(dialect) + .From("WorkflowInstances", "Id") + .Is("DefinitionId", "def-1") + .OrderBy(new OrderField("CreatedAt", OrderDirection.Descending)) + .Page(PageArgs.FromRange(0, 2)); + yield return ("paged-delete", new ParameterizedQuery(dialect) + .Delete("WorkflowInstances", "Id", inner) + .Sql.ToString()); + + yield return ("count", new ParameterizedQuery(dialect) + .Count("WorkflowDefinitions") + .Sql.ToString()); + + yield return ("count-distinct", new ParameterizedQuery(dialect) + .Count("distinct DefinitionId", "WorkflowDefinitions") + .Sql.ToString()); + + yield return ("upsert", new ParameterizedQuery(dialect) + .Upsert("WorkflowInstances", "Id", new SnapshotRecord { Id = "i1", Name = "n", Status = "Finished" }) + .Sql.ToString()); + } + + private static readonly IReadOnlyDictionary SharedExpected = new Dictionary + { + ["from-and"] = Join( + "select Id, Name, Version from WorkflowDefinitions where 1=1", + "and DefinitionId = @DefinitionId", + "and not Name = @Name", + "and Id in (@Id0, @Id1)", + "and Description is null"), + ["order-asc"] = Join( + "select * from WorkflowInstances where 1=1", + "order by CreatedAt asc"), + ["order-desc"] = Join( + "select * from WorkflowInstances where 1=1", + "order by CreatedAt desc"), + ["order-multi"] = Join( + "select * from WorkflowInstances where 1=1", + "order by Name asc,CreatedAt desc"), + ["version-draft"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and IsPublished = 0"), + ["version-latest"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and IsLatest = 1"), + ["version-published"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and IsPublished = 1"), + ["version-latest-or-published"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and (IsLatest = 1 or IsPublished = 1)"), + ["version-latest-and-published"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and IsLatest = 1 and IsPublished = 1"), + ["version-specific"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and Version = @Version"), + ["definition-search"] = Join( + "select * from WorkflowDefinitions where 1=1", + "and (Name like @SearchTermLike or Description like @SearchTermLike or Id like @SearchTerm or DefinitionId like @SearchTerm)"), + // ID → Id: also fixes instance search on case-sensitive SQL Server + // collations, where the column is Id. This is the only intentional non-PG SQL change. + ["instance-search"] = Join( + "select * from WorkflowInstances where 1=1", + "and (Name like @SearchTermLike or Id like @SearchTerm or DefinitionId like @SearchTerm or DefinitionVersionId like @SearchTerm or CorrelationId like @SearchTerm)"), + ["starts-with"] = Join( + "select * from WorkflowInstances where 1=1", + "and Name like @SearchTermLike"), + ["less-than"] = Join( + "select * from WorkflowInstances where 1=1", + "and UpdatedAt < @UpdatedAt"), + ["count"] = Join("select COUNT(*) from WorkflowDefinitions where 1=1"), + ["count-distinct"] = Join("select COUNT(distinct DefinitionId) from WorkflowDefinitions where 1=1"), + }; + + private static readonly IReadOnlyDictionary SqliteExpected = Merge(SharedExpected, new Dictionary + { + ["paged-delete"] = Join( + "delete from WorkflowInstances where 1=1", + "and Id in (", + "select Id from WorkflowInstances where 1=1", + "and DefinitionId = @DefinitionId", + "order by CreatedAt desc", + "limit 2", + "offset 0", + "", + "", + ")"), + ["upsert"] = Join("INSERT OR REPLACE INTO WorkflowInstances (Id, Name, Status) VALUES (@Id, @Name, @Status);"), + }); + + private static readonly IReadOnlyDictionary SqlServerExpected = Merge(SharedExpected, new Dictionary + { + ["paged-delete"] = Join( + "delete from WorkflowInstances where 1=1", + "and Id in (", + "select Id from WorkflowInstances where 1=1", + "and DefinitionId = @DefinitionId", + "order by CreatedAt desc", + "offset 0 rows", + "fetch next 2 rows only", + "", + "", + ")"), + ["upsert"] = "\n MERGE INTO WorkflowInstances WITH (HOLDLOCK) AS Target\n USING (VALUES (@Id, @Name, @Status))\n AS Source (Id, Name, Status)\n ON Target.Id = Source.Id\n WHEN MATCHED THEN \n UPDATE SET Name = Source.Name, Status = Source.Status\n WHEN NOT MATCHED THEN\n INSERT (Id, Name, Status)\n VALUES (Source.Id, Source.Name, Source.Status);" + Environment.NewLine, + }); + + private static string Join(params string[] lines) => + string.Join(Environment.NewLine, lines) + Environment.NewLine; + + private static IReadOnlyDictionary Merge( + IReadOnlyDictionary left, + IReadOnlyDictionary right) + { + var merged = new Dictionary(left); + foreach (var (key, value) in right) + merged[key] = value; + return merged; + } + + public sealed class SnapshotRecord + { + public string Id { get; init; } = null!; + public string Name { get; init; } = null!; + public string Status { get; init; } = null!; + } +} diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs new file mode 100644 index 00000000..af88388e --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/PostgreSqlDialectTests.cs @@ -0,0 +1,90 @@ +using Elsa.Common.Entities; +using Elsa.Persistence.Dapper.Contracts; +using Elsa.Persistence.Dapper.Dialects; + +namespace Elsa.Persistence.Dapper.UnitTests; + +/// +/// SQL-shape tests for the PostgreSQL dialect. No database required. +/// +public sealed class PostgreSqlDialectTests +{ + private readonly ISqlDialect _dialect = new PostgreSqlDialect(); + + [Fact(DisplayName = "From quotes the FluentMigrator mixed-case table name")] + public void From_QuotesTableAndStar() + { + Assert.Equal("select * from \"ActivityExecutionRecords\" where 1=1", _dialect.From("ActivityExecutionRecords")); + } + + [Fact(DisplayName = "From quotes each selected field")] + public void From_QuotesFields() + { + Assert.Equal( + "select \"Id\", \"Status\" from \"WorkflowInstances\" where 1=1", + _dialect.From("WorkflowInstances", "Id", "Status")); + } + + [Fact(DisplayName = "And / Count / Delete quote identifiers and leave parameter names unquoted")] + public void Filters_QuoteIdentifiersOnly() + { + Assert.Equal("and \"Id\" = @Id", _dialect.And("Id")); + Assert.Equal("and \"Id\" in (@Id0, @Id1)", _dialect.And("Id", ["@Id0", "@Id1"])); + Assert.Equal("and \"CompletedAt\" is null", _dialect.IsNull("CompletedAt")); + Assert.Equal("select COUNT(*) from \"Bookmarks\" where 1=1", _dialect.Count("Bookmarks")); + Assert.Equal("delete from \"Bookmarks\" where 1=1", _dialect.Delete("Bookmarks")); + Assert.Equal("order by \"CreatedAt\" desc", _dialect.OrderBy("CreatedAt", OrderDirection.Descending)); + } + + [Fact(DisplayName = "Upsert includes the primary key and quotes table, PK, and columns")] + public void Upsert_IncludesPrimaryKeyAndQuotes() + { + var sql = _dialect.Upsert("ActivityExecutionRecords", "Id", ["Status", "AggregateFaultCount"]); + + Assert.Contains("insert into \"ActivityExecutionRecords\" (\"Id\", \"Status\", \"AggregateFaultCount\")", sql, StringComparison.Ordinal); + Assert.Contains("values (@Id, @Status, @AggregateFaultCount)", sql, StringComparison.Ordinal); + Assert.Contains("on conflict(\"Id\")", sql, StringComparison.Ordinal); + Assert.Contains("\"Status\" = @Status", sql, StringComparison.Ordinal); + Assert.EndsWith(";", sql.Trim()); + Assert.DoesNotContain("insert into ActivityExecutionRecords", sql, StringComparison.Ordinal); + } + + [Fact] + public void Quote_IsIdempotentAndEscapesEmbeddedQuotes() + { + Assert.Equal("*", PostgreSqlDialect.Quote("*")); + Assert.Equal("\"Id\"", PostgreSqlDialect.Quote("Id")); + Assert.Equal("\"Id\"", PostgreSqlDialect.Quote("\"Id\"")); + Assert.Equal("\"foo\"\"bar\"", PostgreSqlDialect.Quote("foo\"bar")); + Assert.Equal("\"CreatedAt\"", _dialect.QuoteIdentifier("CreatedAt")); + } + + [Fact(DisplayName = "Shared query-builder inlines go through QuoteIdentifier on PostgreSQL")] + public void QueryBuilder_QuotesInlinedIdentifiers() + { + var queries = NonPgQuerySqlSnapshotTests.BuildQueries(_dialect).ToDictionary(x => x.Label, x => x.Sql); + + Assert.Contains("order by \"CreatedAt\" desc", queries["order-desc"], StringComparison.Ordinal); + Assert.Contains("and \"IsLatest\" = true", queries["version-latest"], StringComparison.Ordinal); + Assert.Contains("and \"IsPublished\" = false", queries["version-draft"], StringComparison.Ordinal); + Assert.Contains("\"Name\" like @SearchTermLike", queries["definition-search"], StringComparison.Ordinal); + Assert.Contains("\"Id\" like @SearchTerm", queries["instance-search"], StringComparison.Ordinal); + Assert.DoesNotContain("\"ID\"", queries["instance-search"], StringComparison.Ordinal); + Assert.Contains("and \"Id\" in (", queries["paged-delete"], StringComparison.Ordinal); + Assert.Contains("and \"UpdatedAt\" < @UpdatedAt", queries["less-than"], StringComparison.Ordinal); + Assert.DoesNotContain("= 1", queries["version-latest"], StringComparison.Ordinal); + Assert.DoesNotContain("order by CreatedAt desc\n", queries["order-desc"], StringComparison.Ordinal); + } + + [Fact(DisplayName = "PG DateTimeOffset handler accepts DateTime, DateTimeOffset, and string")] + public void DateTimeOffsetHandler_AcceptsNpgsqlAndSqliteShapes() + { + var handler = new Elsa.Persistence.Dapper.TypeHandlers.PostgreSql.DateTimeOffsetHandler(); + var utc = new DateTime(2026, 9, 28, 7, 52, 22, DateTimeKind.Utc); + var dto = new DateTimeOffset(utc); + + Assert.Equal(dto, handler.Parse(dto)); + Assert.Equal(dto, handler.Parse(utc)); + Assert.Equal(dto, handler.Parse("2026-09-28T07:52:22+00:00")); + } +}