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"));
+ }
+}