diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9fbf094..a076be0 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -87,6 +87,15 @@ For convenient local development, a `docker-compose.yml` file is included in the Also, some tests use `Testcontainers` and need you to have Docker installed. Just keep that in mind. +#### Switching between TimescaleDB editions + +TimescaleDB ships in two editions: the **Community edition** (default image, full feature set) and the **Apache 2 edition** (`-oss` image tags, no columnstore/compression, continuous aggregates, or background policies). The library supports both (see `docs/05-apache-edition.md`), so manual testing against the Apache edition is sometimes needed. + +The auto-loaded `docker-compose.override.yml` adds a `db-apache` service for this. Both editions bind the same port `5432` with identical credentials. + +> [!WARNING] +> Each edition has its own data volume, and they are **not interchangeable** + ### πŸ§ͺ Testing This project uses a two-tier testing strategy to ensure code quality and correctness. diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..4d1af78 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,18 @@ +services: + db-apache: + image: timescale/timescaledb:latest-pg17-oss + container_name: cmdscale-ef-timescaledb-apache + restart: always + profiles: + - apache + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: R#!kro#GP43ra8Ae + POSTGRES_USER: timescale_admin + POSTGRES_DB: cmdscale-ef-timescaledb + volumes: + - cmdscale_ef_timescaledb_apache_data_volume:/var/lib/postgresql/data + +volumes: + cmdscale_ef_timescaledb_apache_data_volume: diff --git a/docker-compose.yml b/docker-compose.yml index c819fe3..5f9f5c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.9' - services: db: image: timescale/timescaledb:latest-pg17 diff --git a/docs/05-apache-edition.md b/docs/05-apache-edition.md new file mode 100644 index 0000000..91e5273 --- /dev/null +++ b/docs/05-apache-edition.md @@ -0,0 +1,39 @@ +# Apache 2 (OSS) Edition Support + +TimescaleDB ships in two editions: the community edition (the default `timescale/timescaledb` images) and the Apache 2 edition (the `-oss` image tags). The Apache edition omits every community-only capability See the [TimescaleDB editions reference](https://www.tigerdata.com/docs/about/latest/timescaledb-editions) for the upstream feature split. + +The target edition is a provider option. By default the provider targets the community edition. To target an Apache-edition server, opt in explicitly: + +```csharp +optionsBuilder.UseNpgsql(connectionString) + .UseTimescaleDb(o => o.UseApacheEdition()); +``` + +With `UseApacheEdition()`, community-only statements are omitted from generated migration SQL. Each omitted feature leaves a `-- Skipping Community Edition feature () - not available in Apache Edition` comment in the SQL (visible in `dotnet ef migrations script` output) and raises a warning through the EF Core logger while the SQL is generated. + +Migration SQL is produced at apply/script time from the operations stored in migration files, not at `dotnet ef migrations add` time. Toggling `UseApacheEdition()` therefore changes the SQL of existing migrations without regenerating them. + +> :warning: **Note:** Omitted features cause the model and the database to diverge on Apache servers. The configured model still carries the feature configuration; it simply is not applied to the database. The skip comment and the generation-time warning are the only signals. + +## Feature support with `UseApacheEdition()` + +| Feature | Behavior | +| --- | --- | +| Hypertables | Fully supported | +| Columnstore / compression settings | Omitted with skip comment and warning | +| Chunk skipping | Omitted with skip comment and warning | +| Compression (columnstore) policy | Omitted with skip comment and warning | +| Retention policy | Omitted with skip comment and warning | +| Reorder policy | Omitted with skip comment and warning | +| Continuous aggregates | Omitted with skip comment and warning | +| Continuous aggregate refresh policy | Omitted with skip comment and warning | +| Scaffolding (`dotnet ef dbcontext scaffold`) | Fully supported | + +Scaffolding reads only catalog views that exist in both editions, so scaffolding an Apache-edition database produces a valid model. + +## Edition mismatch + +The option describes the target server; the provider does not probe the server's license at runtime. + +- **Default (community) SQL against an Apache server:** the first community-only statement fails the migration with `functionality not supported under the current "apache" license`. Switch the context to `UseApacheEdition()`. +- **`UseApacheEdition()` SQL against a community server:** the migration succeeds, but every community-only feature in the model is silently absent from the database. Remove the option to apply the full model. diff --git a/src/Eftdb/Generators/CompressionPolicySqlGenerator.cs b/src/Eftdb/Generators/CompressionPolicySqlGenerator.cs index 1b2f30e..1011efb 100644 --- a/src/Eftdb/Generators/CompressionPolicySqlGenerator.cs +++ b/src/Eftdb/Generators/CompressionPolicySqlGenerator.cs @@ -9,7 +9,9 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators /// internal static class CompressionPolicySqlGenerator { - public static List Generate(AddCompressionPolicyOperation operation, bool useLegacyCompressionNames = false) + private const string CommunityWarning = "Skipping Community Edition feature (compression policy) - not available in Apache Edition"; + + public static List Generate(AddCompressionPolicyOperation operation, bool useLegacyCompressionNames = false, bool isApacheEdition = false) { List statements = [ @@ -25,10 +27,10 @@ public static List Generate(AddCompressionPolicyOperation operation, boo useLegacyCompressionNames) ]; - return statements; + return SqlBuilderHelper.SkipOnApacheEdition(statements, CommunityWarning, isApacheEdition); } - public static List Generate(AlterCompressionPolicyOperation operation, bool useLegacyCompressionNames = false) + public static List Generate(AlterCompressionPolicyOperation operation, bool useLegacyCompressionNames = false, bool isApacheEdition = false) { string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); @@ -47,10 +49,10 @@ public static List Generate(AlterCompressionPolicyOperation operation, b useLegacyCompressionNames) ]; - return statements; + return SqlBuilderHelper.SkipOnApacheEdition(statements, CommunityWarning, isApacheEdition); } - public static List Generate(DropCompressionPolicyOperation operation, bool useLegacyCompressionNames = false) + public static List Generate(DropCompressionPolicyOperation operation, bool useLegacyCompressionNames = false, bool isApacheEdition = false) { string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); @@ -58,7 +60,7 @@ public static List Generate(DropCompressionPolicyOperation operation, bo [ BuildRemovePolicySql(qualifiedTableName, useLegacyCompressionNames) ]; - return statements; + return SqlBuilderHelper.SkipOnApacheEdition(statements, CommunityWarning, isApacheEdition); } private static string BuildRemovePolicySql(string qualifiedTableName, bool useLegacy) diff --git a/src/Eftdb/Generators/CompressionSettingsSqlHelper.cs b/src/Eftdb/Generators/CompressionSettingsSqlHelper.cs index 5994d81..8d316aa 100644 --- a/src/Eftdb/Generators/CompressionSettingsSqlHelper.cs +++ b/src/Eftdb/Generators/CompressionSettingsSqlHelper.cs @@ -46,8 +46,8 @@ internal static string OrderByOptionName(bool useLegacy) => useLegacy ? "timescaledb.compress_orderby" : "timescaledb.orderby"; /// - /// Appends a community-feature-guarded compression statement to - /// when compression is configured for a newly created relation (hypertable or materialized view). + /// Appends the compression SET statement to when compression + /// is configured for a newly created relation (hypertable or materialized view). /// /// The statement list to append to. /// The unqualified relation name. @@ -56,7 +56,6 @@ internal static string OrderByOptionName(bool useLegacy) /// Segment-by column list, or . /// Order-by column list, or . /// The DDL keyword phrase used to target the relation (e.g., "ALTER TABLE" or "ALTER MATERIALIZED VIEW"). - /// The RAISE WARNING text for the Apache Edition path. /// When , emits pre-2.18 compression option names. internal static void AppendCreateCompressionStatements( List statements, @@ -66,7 +65,6 @@ internal static void AppendCreateCompressionStatements( IReadOnlyList? compressionSegmentBy, IReadOnlyList? compressionOrderBy, string alterDdl, - string warningText, bool useLegacy = false) { bool hasSegmentBy = compressionSegmentBy is { Count: > 0 }; @@ -95,7 +93,7 @@ internal static void AppendCreateCompressionStatements( string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(relationName, schema); string setClause = $"{alterDdl} {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});"; - statements.Add(SqlBuilderHelper.WrapCommunityFeatures([setClause], warningText)); + statements.Add(setClause); } /// diff --git a/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs b/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs index 8d1d22a..d72b13a 100644 --- a/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs +++ b/src/Eftdb/Generators/ContinuousAggregatePolicySqlGenerator.cs @@ -5,14 +5,16 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators /// /// Generates SQL for continuous aggregate refresh policy operations. /// - internal class ContinuousAggregatePolicySqlGenerator + internal static class ContinuousAggregatePolicySqlGenerator { + private const string CommunityWarning = "Skipping Community Edition feature (continuous aggregate policy) - not available in Apache Edition"; + /// /// Generates SQL statements for adding a continuous aggregate refresh policy. /// /// The add policy operation. /// A list of SQL statements to execute. - public static List Generate(AddContinuousAggregatePolicyOperation operation) + public static List Generate(AddContinuousAggregatePolicyOperation operation, bool isApacheEdition = false) { string qualifiedViewName = SqlBuilderHelper.Regclass(operation.MaterializedViewName, operation.Schema); @@ -79,7 +81,7 @@ public static List Generate(AddContinuousAggregatePolicyOperation operat string sql = $"SELECT add_continuous_aggregate_policy({string.Join(", ", arguments)});"; - return [sql]; + return SqlBuilderHelper.SkipOnApacheEdition([sql], CommunityWarning, isApacheEdition); } /// @@ -87,7 +89,7 @@ public static List Generate(AddContinuousAggregatePolicyOperation operat /// /// The remove policy operation. /// A list of SQL statements to execute. - public static List Generate(RemoveContinuousAggregatePolicyOperation operation) + public static List Generate(RemoveContinuousAggregatePolicyOperation operation, bool isApacheEdition = false) { string qualifiedViewName = SqlBuilderHelper.Regclass(operation.MaterializedViewName, operation.Schema); @@ -100,7 +102,7 @@ public static List Generate(RemoveContinuousAggregatePolicyOperation ope string sql = $"SELECT remove_continuous_aggregate_policy({string.Join(", ", arguments)});"; - return [sql]; + return SqlBuilderHelper.SkipOnApacheEdition([sql], CommunityWarning, isApacheEdition); } } } diff --git a/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs index 60f3989..7346d9b 100644 --- a/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs +++ b/src/Eftdb/Generators/ContinuousAggregateSqlGenerator.cs @@ -3,13 +3,18 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { - internal class ContinuousAggregateSqlGenerator + internal static class ContinuousAggregateSqlGenerator { - private const string CommunityWarning = "Skipping Community Edition features (compression) - not available in Apache Edition"; + private const string CaggCommunityWarning = "Skipping Community Edition feature (continuous aggregate) - not available in Apache Edition"; private const string AlterDdl = "ALTER MATERIALIZED VIEW"; - public static List Generate(CreateContinuousAggregateOperation operation, bool useLegacyCompressionNames = false) + public static List Generate(CreateContinuousAggregateOperation operation, bool useLegacyCompressionNames = false, bool isApacheEdition = false) { + if (isApacheEdition) + { + return [SqlBuilderHelper.SkipComment(CaggCommunityWarning)]; + } + string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema); List statements = []; @@ -67,7 +72,6 @@ private static List GenerateFromRawViewDefinition( operation.CompressionSegmentBy, operation.CompressionOrderBy, AlterDdl, - CommunityWarning, useLegacyCompressionNames); return statements; @@ -191,13 +195,12 @@ private static List GenerateFromStructuredQuery( operation.CompressionSegmentBy, operation.CompressionOrderBy, AlterDdl, - CommunityWarning, useLegacyCompressionNames); return statements; } - public static List Generate(AlterContinuousAggregateOperation operation, bool useLegacyCompressionNames = false) + public static List Generate(AlterContinuousAggregateOperation operation, bool useLegacyCompressionNames = false, bool isApacheEdition = false) { string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.MaterializedViewName, operation.Schema); List statements = []; @@ -248,10 +251,10 @@ public static List Generate(AlterContinuousAggregateOperation operation, if (compressionSettings.Count > 0) { string setClause = $"ALTER MATERIALIZED VIEW {qualifiedIdentifier} SET ({string.Join(", ", compressionSettings)});"; - statements.Add(SqlBuilderHelper.WrapCommunityFeatures([setClause], CommunityWarning)); + statements.Add(setClause); } - return statements; + return SqlBuilderHelper.SkipOnApacheEdition(statements, CaggCommunityWarning, isApacheEdition); } public static List Generate(DropContinuousAggregateOperation operation) diff --git a/src/Eftdb/Generators/HypertableSqlGenerator.cs b/src/Eftdb/Generators/HypertableSqlGenerator.cs index 64faeed..42b5637 100644 --- a/src/Eftdb/Generators/HypertableSqlGenerator.cs +++ b/src/Eftdb/Generators/HypertableSqlGenerator.cs @@ -5,11 +5,11 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { - internal class HypertableSqlGenerator + internal static class HypertableSqlGenerator { private const string CommunityWarning = "Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition"; - public static List Generate(CreateHypertableOperation operation, bool useLegacyCompressionNames = false) + public static List Generate(CreateHypertableOperation operation, bool useLegacyCompressionNames = false, bool isApacheEdition = false) { string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.TableName, operation.Schema); @@ -96,14 +96,11 @@ public static List Generate(CreateHypertableOperation operation, bool us } } - if (communityStatements.Count > 0) - { - statements.Add(SqlBuilderHelper.WrapCommunityFeatures(communityStatements, CommunityWarning)); - } + statements.AddRange(SqlBuilderHelper.SkipOnApacheEdition(communityStatements, CommunityWarning, isApacheEdition)); return statements; } - public static List Generate(AlterHypertableOperation operation, bool useLegacyCompressionNames = false) + public static List Generate(AlterHypertableOperation operation, bool useLegacyCompressionNames = false, bool isApacheEdition = false) { string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); string qualifiedIdentifier = SqlBuilderHelper.QualifiedIdentifier(operation.TableName, operation.Schema); @@ -126,10 +123,7 @@ public static List Generate(AlterHypertableOperation operation, bool use ApplyChunkSkippingChanges(operation, qualifiedTableName, communityStatements); ApplyDimensionChanges(operation, qualifiedTableName, statements); - if (communityStatements.Count > 0) - { - statements.Add(SqlBuilderHelper.WrapCommunityFeatures(communityStatements, CommunityWarning)); - } + statements.AddRange(SqlBuilderHelper.SkipOnApacheEdition(communityStatements, CommunityWarning, isApacheEdition)); return statements; } diff --git a/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs b/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs index 902af48..eb355b1 100644 --- a/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs +++ b/src/Eftdb/Generators/ReorderPolicySqlGenerator.cs @@ -2,11 +2,12 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { - internal class ReorderPolicySqlGenerator + internal static class ReorderPolicySqlGenerator { private const string ProcName = "policy_reorder"; + private const string CommunityWarning = "Skipping Community Edition feature (reorder policy) - not available in Apache Edition"; - public static List Generate(AddReorderPolicyOperation operation) + public static List Generate(AddReorderPolicyOperation operation, bool isApacheEdition = false) { List statements = [ @@ -20,10 +21,10 @@ public static List Generate(AddReorderPolicyOperation operation) statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, jobClauses)); } - return statements; + return SqlBuilderHelper.SkipOnApacheEdition(statements, CommunityWarning, isApacheEdition); } - public static List Generate(AlterReorderPolicyOperation operation) + public static List Generate(AlterReorderPolicyOperation operation, bool isApacheEdition = false) { string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); @@ -56,10 +57,10 @@ public static List Generate(AlterReorderPolicyOperation operation) } } - return statements; + return SqlBuilderHelper.SkipOnApacheEdition(statements, CommunityWarning, isApacheEdition); } - public static List Generate(DropReorderPolicyOperation operation) + public static List Generate(DropReorderPolicyOperation operation, bool isApacheEdition = false) { string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); @@ -67,7 +68,7 @@ public static List Generate(DropReorderPolicyOperation operation) [ $"SELECT remove_reorder_policy({qualifiedTableName}, if_exists => true);" ]; - return statements; + return SqlBuilderHelper.SkipOnApacheEdition(statements, CommunityWarning, isApacheEdition); } private static string BuildAddReorderPolicySql(string tableName, string schema, string indexName, DateTime? initialStart) diff --git a/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs b/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs index b07dfa6..5e304eb 100644 --- a/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs +++ b/src/Eftdb/Generators/RetentionPolicySqlGenerator.cs @@ -2,11 +2,12 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { - internal class RetentionPolicySqlGenerator + internal static class RetentionPolicySqlGenerator { private const string ProcName = "policy_retention"; + private const string CommunityWarning = "Skipping Community Edition feature (retention policy) - not available in Apache Edition"; - public static List Generate(AddRetentionPolicyOperation operation) + public static List Generate(AddRetentionPolicyOperation operation, bool isApacheEdition = false) { List statements = [ @@ -20,10 +21,10 @@ public static List Generate(AddRetentionPolicyOperation operation) statements.Add(PolicyJobSqlBuilder.BuildAlterJobSql(operation.TableName, operation.Schema, ProcName, jobClauses)); } - return statements; + return SqlBuilderHelper.SkipOnApacheEdition(statements, CommunityWarning, isApacheEdition); } - public static List Generate(AlterRetentionPolicyOperation operation) + public static List Generate(AlterRetentionPolicyOperation operation, bool isApacheEdition = false) { string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); @@ -59,10 +60,10 @@ public static List Generate(AlterRetentionPolicyOperation operation) } } - return statements; + return SqlBuilderHelper.SkipOnApacheEdition(statements, CommunityWarning, isApacheEdition); } - public static List Generate(DropRetentionPolicyOperation operation) + public static List Generate(DropRetentionPolicyOperation operation, bool isApacheEdition = false) { string qualifiedTableName = SqlBuilderHelper.Regclass(operation.TableName, operation.Schema); @@ -70,7 +71,7 @@ public static List Generate(DropRetentionPolicyOperation operation) [ $"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);" ]; - return statements; + return SqlBuilderHelper.SkipOnApacheEdition(statements, CommunityWarning, isApacheEdition); } private static string BuildAddRetentionPolicySql(string tableName, string schema, string? dropAfter, string? dropCreatedBefore, DateTime? initialStart) diff --git a/src/Eftdb/Generators/SqlBuilderHelper.cs b/src/Eftdb/Generators/SqlBuilderHelper.cs index 77d3ec9..2cc5305 100644 --- a/src/Eftdb/Generators/SqlBuilderHelper.cs +++ b/src/Eftdb/Generators/SqlBuilderHelper.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore.Migrations; -using System.Text; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators { @@ -133,35 +132,34 @@ public static string FormatTimestamp(DateTime value) => value.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture); /// - /// Wraps SQL statements in a Community Edition license-guard DO block. Statements execute - /// only when the TimescaleDB license is not apache; otherwise the supplied warning - /// is raised and the block exits without executing them. + /// The prefix that turns a skip message into a SQL comment statement. Shared by every + /// emitter of skip comments and by the migrations SQL generator that detects them. /// - /// The statements to execute inside the guarded block. - /// The text of the RAISE WARNING emitted on the Apache Edition path. - internal static string WrapCommunityFeatures(List sqlStatements, string warningText) + internal const string SkipCommentMarker = "-- "; + + /// + /// Formats a skip message as a SQL comment statement. + /// + internal static string SkipComment(string skipMessage) => $"{SkipCommentMarker}{skipMessage}"; + + /// + /// Applies the edition policy to Community-only statements. For the Community edition + /// (default) the statements are returned unchanged. When the provider is configured for + /// the Apache edition (UseApacheEdition()), the statements are replaced by a single + /// SQL comment carrying , so the skipped feature stays + /// visible in scripted migration output. An empty input stays empty on both editions. + /// + /// The Community-only statements. + /// The comment text emitted in place of the statements on the Apache edition. + /// Whether the provider targets the Apache edition. + internal static List SkipOnApacheEdition(List statements, string skipMessage, bool isApacheEdition) { - StringBuilder sb = new(); - sb.AppendLine("DO $$"); - sb.AppendLine("DECLARE"); - sb.AppendLine(" license TEXT;"); - sb.AppendLine("BEGIN"); - sb.AppendLine(" license := current_setting('timescaledb.license', true);"); - sb.AppendLine(" "); - sb.AppendLine(" IF license IS NULL OR license != 'apache' THEN"); - - foreach (string sql in sqlStatements) + if (statements.Count == 0 || !isApacheEdition) { - string cleanSql = EscapeStringLiteral(sql.TrimEnd(';')); - sb.AppendLine($" EXECUTE '{cleanSql}';"); + return statements; } - sb.AppendLine(" ELSE"); - sb.AppendLine($" RAISE WARNING '{EscapeStringLiteral(warningText)}';"); - sb.AppendLine(" END IF;"); - sb.AppendLine("END $$;"); - - return sb.ToString(); + return [SkipComment(skipMessage)]; } } } diff --git a/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs b/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs index 8b7db9f..c05432b 100644 --- a/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs +++ b/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs @@ -93,6 +93,8 @@ private static DbContextOptionsBuilder ApplyTimescaleDbExtension( /// private class TimescaleDbOptionsExtension(TimescaleDbOptions timescaleDbOptions) : IDbContextOptionsExtension { + internal TimescaleDbOptions TimescaleDbOptions => timescaleDbOptions; + private DbContextOptionsExtensionInfo? _info; public DbContextOptionsExtensionInfo Info => _info ??= new ExtensionInfo(this); @@ -113,11 +115,28 @@ public void Validate(IDbContextOptions options) { } /// private class ExtensionInfo(IDbContextOptionsExtension extension) : DbContextOptionsExtensionInfo(extension) { + private new TimescaleDbOptionsExtension Extension => (TimescaleDbOptionsExtension)base.Extension; + public override bool IsDatabaseProvider => false; public override string LogFragment => "using TimescaleDB extensions"; - public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) => other is ExtensionInfo; - public override int GetServiceProviderHashCode() => GetType().GetHashCode(); - public override void PopulateDebugInfo(IDictionary debugInfo) => debugInfo["TimescaleDB:Enabled"] = "True"; + + public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) + => other is ExtensionInfo otherInfo + && Extension.TimescaleDbOptions.UseLegacyCompressionNames == otherInfo.Extension.TimescaleDbOptions.UseLegacyCompressionNames + && Extension.TimescaleDbOptions.IsApacheEdition == otherInfo.Extension.TimescaleDbOptions.IsApacheEdition; + + public override int GetServiceProviderHashCode() + => HashCode.Combine( + GetType(), + Extension.TimescaleDbOptions.UseLegacyCompressionNames, + Extension.TimescaleDbOptions.IsApacheEdition); + + public override void PopulateDebugInfo(IDictionary debugInfo) + { + debugInfo["TimescaleDB:Enabled"] = "True"; + debugInfo["TimescaleDB:UseLegacyCompressionNames"] = Extension.TimescaleDbOptions.UseLegacyCompressionNames.ToString(); + debugInfo["TimescaleDB:IsApacheEdition"] = Extension.TimescaleDbOptions.IsApacheEdition.ToString(); + } } } diff --git a/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs b/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs index 4604607..3cde37e 100644 --- a/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs +++ b/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs @@ -1,8 +1,11 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.Extensions.Logging; using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations; @@ -12,9 +15,13 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB internal class TimescaleDbMigrationsSqlGenerator( MigrationsSqlGeneratorDependencies dependencies, INpgsqlSingletonOptions npgsqlSingletonOptions, - TimescaleDbOptions? timescaleDbOptions = null) : NpgsqlMigrationsSqlGenerator(dependencies, npgsqlSingletonOptions) + TimescaleDbOptions? timescaleDbOptions = null, + IDiagnosticsLogger? migrationsLogger = null) : NpgsqlMigrationsSqlGenerator(dependencies, npgsqlSingletonOptions) { + private static readonly string SkipCommentPrefix = SqlBuilderHelper.SkipComment("Skipping Community Edition feature"); + private readonly bool _useLegacyCompressionNames = timescaleDbOptions?.UseLegacyCompressionNames ?? false; + private readonly bool _isApacheEdition = timescaleDbOptions?.IsApacheEdition ?? false; protected override void Generate( MigrationOperation operation, @@ -27,56 +34,56 @@ protected override void Generate( switch (operation) { case CreateHypertableOperation hypertableOperation: - statements = HypertableSqlGenerator.Generate(hypertableOperation, _useLegacyCompressionNames); + statements = HypertableSqlGenerator.Generate(hypertableOperation, _useLegacyCompressionNames, _isApacheEdition); break; case AlterHypertableOperation alterHypertableOperation: - statements = HypertableSqlGenerator.Generate(alterHypertableOperation, _useLegacyCompressionNames); + statements = HypertableSqlGenerator.Generate(alterHypertableOperation, _useLegacyCompressionNames, _isApacheEdition); break; case AlterReorderPolicyOperation alterReorderPolicyOperation: - statements = ReorderPolicySqlGenerator.Generate(alterReorderPolicyOperation); + statements = ReorderPolicySqlGenerator.Generate(alterReorderPolicyOperation, _isApacheEdition); break; case AddReorderPolicyOperation addReorderPolicyOperation: - statements = ReorderPolicySqlGenerator.Generate(addReorderPolicyOperation); + statements = ReorderPolicySqlGenerator.Generate(addReorderPolicyOperation, _isApacheEdition); break; case DropReorderPolicyOperation dropReorderPolicyOperation: - statements = ReorderPolicySqlGenerator.Generate(dropReorderPolicyOperation); + statements = ReorderPolicySqlGenerator.Generate(dropReorderPolicyOperation, _isApacheEdition); break; case AddRetentionPolicyOperation addRetentionPolicyOperation: - statements = RetentionPolicySqlGenerator.Generate(addRetentionPolicyOperation); + statements = RetentionPolicySqlGenerator.Generate(addRetentionPolicyOperation, _isApacheEdition); break; case AlterRetentionPolicyOperation alterRetentionPolicyOperation: - statements = RetentionPolicySqlGenerator.Generate(alterRetentionPolicyOperation); + statements = RetentionPolicySqlGenerator.Generate(alterRetentionPolicyOperation, _isApacheEdition); break; case DropRetentionPolicyOperation dropRetentionPolicyOperation: - statements = RetentionPolicySqlGenerator.Generate(dropRetentionPolicyOperation); + statements = RetentionPolicySqlGenerator.Generate(dropRetentionPolicyOperation, _isApacheEdition); break; case AddCompressionPolicyOperation addCompressionPolicyOperation: - statements = CompressionPolicySqlGenerator.Generate(addCompressionPolicyOperation, _useLegacyCompressionNames); + statements = CompressionPolicySqlGenerator.Generate(addCompressionPolicyOperation, _useLegacyCompressionNames, _isApacheEdition); break; case AlterCompressionPolicyOperation alterCompressionPolicyOperation: - statements = CompressionPolicySqlGenerator.Generate(alterCompressionPolicyOperation, _useLegacyCompressionNames); + statements = CompressionPolicySqlGenerator.Generate(alterCompressionPolicyOperation, _useLegacyCompressionNames, _isApacheEdition); break; case DropCompressionPolicyOperation dropCompressionPolicyOperation: - statements = CompressionPolicySqlGenerator.Generate(dropCompressionPolicyOperation, _useLegacyCompressionNames); + statements = CompressionPolicySqlGenerator.Generate(dropCompressionPolicyOperation, _useLegacyCompressionNames, _isApacheEdition); break; case CreateContinuousAggregateOperation createContinuousAggregateOperation: - statements = ContinuousAggregateSqlGenerator.Generate(createContinuousAggregateOperation, _useLegacyCompressionNames); - suppressTransaction = true; + statements = ContinuousAggregateSqlGenerator.Generate(createContinuousAggregateOperation, _useLegacyCompressionNames, _isApacheEdition); + suppressTransaction = !_isApacheEdition; break; case AlterContinuousAggregateOperation alterContinuousAggregateOperation: - statements = ContinuousAggregateSqlGenerator.Generate(alterContinuousAggregateOperation, _useLegacyCompressionNames); + statements = ContinuousAggregateSqlGenerator.Generate(alterContinuousAggregateOperation, _useLegacyCompressionNames, _isApacheEdition); break; case DropContinuousAggregateOperation dropContinuousAggregateOperation: @@ -84,11 +91,11 @@ protected override void Generate( break; case AddContinuousAggregatePolicyOperation addContinuousAggregatePolicyOperation: - statements = ContinuousAggregatePolicySqlGenerator.Generate(addContinuousAggregatePolicyOperation); + statements = ContinuousAggregatePolicySqlGenerator.Generate(addContinuousAggregatePolicyOperation, _isApacheEdition); break; case RemoveContinuousAggregatePolicyOperation removeContinuousAggregatePolicyOperation: - statements = ContinuousAggregatePolicySqlGenerator.Generate(removeContinuousAggregatePolicyOperation); + statements = ContinuousAggregatePolicySqlGenerator.Generate(removeContinuousAggregatePolicyOperation, _isApacheEdition); break; default: @@ -96,11 +103,36 @@ protected override void Generate( return; } + LogSkippedCommunityFeatures(statements); + bool usePerform = Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent); SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction, usePerform); } + /// + /// Surfaces Apache-edition skip comments as generation-time warnings so skipped + /// Community-only features are visible in logs, not only in scripted SQL output. + /// + private void LogSkippedCommunityFeatures(List statements) + { + if (!_isApacheEdition) + { + return; + } + + ILogger logger = migrationsLogger?.Logger ?? Dependencies.Logger.Logger; + foreach (string statement in statements) + { + if (statement.StartsWith(SkipCommentPrefix, StringComparison.Ordinal)) + { + logger.LogWarning( + "{SkippedCommunityFeature}", + statement[SqlBuilderHelper.SkipCommentMarker.Length..]); + } + } + } + /// /// Handles raw SQL operations from migration files (migrationBuilder.Sql calls). /// In idempotent mode, replaces SELECT with PERFORM because the SQL is wrapped diff --git a/src/Eftdb/TimescaleDbOptions.cs b/src/Eftdb/TimescaleDbOptions.cs index f042e32..e414b8c 100644 --- a/src/Eftdb/TimescaleDbOptions.cs +++ b/src/Eftdb/TimescaleDbOptions.cs @@ -26,5 +26,24 @@ public TimescaleDbOptions UseLegacyCompressionSql() UseLegacyCompressionNames = true; return this; } + + /// + /// When , migration SQL targets the Apache (OSS) edition of + /// TimescaleDB: Community-only statements are omitted and replaced by a SQL comment, + /// and a warning is logged during SQL generation. + /// When (default), migration SQL targets the Community edition + /// and uses every configured feature; such SQL fails on an Apache server with a license + /// error when the model configures Community-only features. + /// + public bool IsApacheEdition { get; private set; } + + /// + /// Configures the provider for the Apache (OSS) edition of TimescaleDB. + /// + public TimescaleDbOptions UseApacheEdition() + { + IsApacheEdition = true; + return this; + } } } diff --git a/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj b/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj index e14a0c1..dc815e5 100644 --- a/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj +++ b/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj @@ -33,6 +33,10 @@ + + + + diff --git a/tests/Eftdb.FunctionalTests/TimeBucketQueryTests.cs b/tests/Eftdb.FunctionalTests/TimeBucketQueryTests.cs index 44ee50d..bd39d19 100644 --- a/tests/Eftdb.FunctionalTests/TimeBucketQueryTests.cs +++ b/tests/Eftdb.FunctionalTests/TimeBucketQueryTests.cs @@ -4,14 +4,21 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests; -public class TimeBucketQueryTests(TimescaleQueryFixture fixture) : IClassFixture +public class TimeBucketQueryTests(TimescaleQueryFixture fixture) + : TimeBucketQueryTestsBase(fixture), IClassFixture; + +public class TimeBucketApacheQueryTests(TimescaleApacheQueryFixture fixture) + : TimeBucketQueryTestsBase(fixture), IClassFixture; + +public abstract class TimeBucketQueryTestsBase(TFixture fixture) + where TFixture : TimescaleQueryFixtureBase { #region Should_Generate_TimeBucket_DateTime_In_Select [Fact] public async Task Should_Generate_TimeBucket_DateTime_In_Select() { - await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + await using TimescaleQueryFixtureBase.QueryTestContext context = fixture.CreateContext(); TimeSpan bucket = TimeSpan.FromMinutes(5); _ = await context.Metrics @@ -34,7 +41,7 @@ FROM query_metrics AS q [Fact] public async Task Should_Generate_TimeBucket_DateTime_In_GroupBy() { - await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + await using TimescaleQueryFixtureBase.QueryTestContext context = fixture.CreateContext(); TimeSpan bucket = TimeSpan.FromMinutes(5); _ = await context.Metrics @@ -66,7 +73,7 @@ GROUP BY q0."Key" [Fact] public async Task Should_Generate_TimeBucket_DateTime_In_Where() { - await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + await using TimescaleQueryFixtureBase.QueryTestContext context = fixture.CreateContext(); TimeSpan bucket = TimeSpan.FromMinutes(5); DateTime threshold = new(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc); @@ -92,7 +99,7 @@ WHERE time_bucket(@bucket, q."Timestamp") = @threshold [Fact] public async Task Should_Generate_TimeBucket_DateTime_In_OrderBy() { - await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + await using TimescaleQueryFixtureBase.QueryTestContext context = fixture.CreateContext(); TimeSpan bucket = TimeSpan.FromMinutes(5); _ = await context.Metrics @@ -116,7 +123,7 @@ ORDER BY time_bucket(@bucket, q."Timestamp") [Fact] public async Task Should_Generate_TimeBucket_DateTime_With_Offset() { - await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + await using TimescaleQueryFixtureBase.QueryTestContext context = fixture.CreateContext(); TimeSpan bucket = TimeSpan.FromMinutes(5); TimeSpan offset = TimeSpan.FromMinutes(1); @@ -141,7 +148,7 @@ FROM query_metrics AS q [Fact] public async Task Should_Generate_TimeBucket_Integer_In_GroupBy() { - await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + await using TimescaleQueryFixtureBase.QueryTestContext context = fixture.CreateContext(); int bucket = 5; _ = await context.Metrics diff --git a/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs b/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs index d15a581..9f749fd 100644 --- a/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs +++ b/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs @@ -1,4 +1,5 @@ -ο»Ώusing Microsoft.EntityFrameworkCore.Migrations; +ο»Ώusing CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.TestUtilities; using Testcontainers.PostgreSql; @@ -6,7 +7,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests.Utils { public class TimescaleMigrationsFixture : MigrationsInfrastructureFixtureBase, IAsyncLifetime { - private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("migration_tests_db") .WithUsername(TimescaleConnectionHelper.Username) .WithPassword(TimescaleConnectionHelper.Password) diff --git a/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs b/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs index 64af493..119786f 100644 --- a/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs +++ b/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs @@ -1,12 +1,17 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.TestUtilities; using Testcontainers.PostgreSql; namespace CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests.Utils; -public class TimescaleQueryFixture : IAsyncLifetime +public class TimescaleQueryFixture() : TimescaleQueryFixtureBase(TimescaleImages.Community); + +public class TimescaleApacheQueryFixture() : TimescaleQueryFixtureBase(TimescaleImages.Apache); + +public abstract class TimescaleQueryFixtureBase(string image) : IAsyncLifetime { - private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder(image) .WithDatabase("query_tests_db") .WithUsername(TimescaleConnectionHelper.Username) .WithPassword(TimescaleConnectionHelper.Password) diff --git a/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs b/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs index 6df407f..86a6228 100644 --- a/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs +++ b/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using NpgsqlTypes; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extensions; @@ -17,7 +18,7 @@ public class BulkCopyExtensionsTests : IAsyncLifetime public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Generators/ApacheEditionSqlGenerationTests.cs b/tests/Eftdb.Tests/Generators/ApacheEditionSqlGenerationTests.cs new file mode 100644 index 0000000..2e014c3 --- /dev/null +++ b/tests/Eftdb.Tests/Generators/ApacheEditionSqlGenerationTests.cs @@ -0,0 +1,299 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators +{ + /// + /// Validates that every generator, when targeting the Apache edition, replaces Community-only + /// statements with a single SQL skip comment while keeping the Apache-compatible statements intact. + /// + public class ApacheEditionSqlGenerationTests + { + #region Should_Skip_Community_SubStatements_On_Hypertable_Create + + [Fact] + public void Should_Skip_Community_SubStatements_On_Hypertable_Create() + { + // Arrange + CreateHypertableOperation operation = new() + { + TableName = "metrics", + Schema = "public", + TimeColumnName = "time", + EnableCompression = true, + ChunkSkipColumns = ["device_id"] + }; + + // Act + List statements = HypertableSqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + Assert.Contains(statements, s => s.Contains("create_hypertable")); + Assert.Single(statements, s => s == "-- Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition"); + Assert.DoesNotContain(statements, s => s.Contains("enable_columnstore")); + Assert.DoesNotContain(statements, s => s.Contains("enable_chunk_skipping")); + } + + #endregion + + #region Should_Not_Emit_Comment_On_Hypertable_Alter_ChunkInterval_Only + + [Fact] + public void Should_Not_Emit_Comment_On_Hypertable_Alter_ChunkInterval_Only() + { + // Arrange + AlterHypertableOperation operation = new() + { + TableName = "metrics", + Schema = "public", + ChunkTimeInterval = "1 day", + OldChunkTimeInterval = "7 days" + }; + + // Act + List statements = HypertableSqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + Assert.Single(statements); + Assert.Contains("set_chunk_time_interval", statements[0]); + Assert.DoesNotContain(statements, s => s.StartsWith("--")); + } + + #endregion + + #region Should_Skip_RetentionPolicy_Add + + [Fact] + public void Should_Skip_RetentionPolicy_Add() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + TableName = "metrics", + Schema = "public", + DropAfter = "30 days" + }; + + // Act + List statements = RetentionPolicySqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + string comment = Assert.Single(statements); + Assert.Equal("-- Skipping Community Edition feature (retention policy) - not available in Apache Edition", comment); + } + + #endregion + + #region Should_Skip_RetentionPolicy_Drop + + [Fact] + public void Should_Skip_RetentionPolicy_Drop() + { + // Arrange + DropRetentionPolicyOperation operation = new() + { + TableName = "metrics", + Schema = "public" + }; + + // Act + List statements = RetentionPolicySqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + string comment = Assert.Single(statements); + Assert.Equal("-- Skipping Community Edition feature (retention policy) - not available in Apache Edition", comment); + } + + #endregion + + #region Should_Skip_ReorderPolicy_Add + + [Fact] + public void Should_Skip_ReorderPolicy_Add() + { + // Arrange + AddReorderPolicyOperation operation = new() + { + TableName = "metrics", + Schema = "public", + IndexName = "ix_metrics_time" + }; + + // Act + List statements = ReorderPolicySqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + string comment = Assert.Single(statements); + Assert.Equal("-- Skipping Community Edition feature (reorder policy) - not available in Apache Edition", comment); + } + + #endregion + + #region Should_Skip_ReorderPolicy_Drop + + [Fact] + public void Should_Skip_ReorderPolicy_Drop() + { + // Arrange + DropReorderPolicyOperation operation = new() + { + TableName = "metrics", + Schema = "public" + }; + + // Act + List statements = ReorderPolicySqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + string comment = Assert.Single(statements); + Assert.Equal("-- Skipping Community Edition feature (reorder policy) - not available in Apache Edition", comment); + } + + #endregion + + #region Should_Skip_CompressionPolicy_Add + + [Fact] + public void Should_Skip_CompressionPolicy_Add() + { + // Arrange + AddCompressionPolicyOperation operation = new() + { + TableName = "metrics", + Schema = "public", + After = "7 days" + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + string comment = Assert.Single(statements); + Assert.Equal("-- Skipping Community Edition feature (compression policy) - not available in Apache Edition", comment); + } + + #endregion + + #region Should_Skip_CompressionPolicy_Drop + + [Fact] + public void Should_Skip_CompressionPolicy_Drop() + { + // Arrange + DropCompressionPolicyOperation operation = new() + { + TableName = "metrics", + Schema = "public" + }; + + // Act + List statements = CompressionPolicySqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + string comment = Assert.Single(statements); + Assert.Equal("-- Skipping Community Edition feature (compression policy) - not available in Apache Edition", comment); + } + + #endregion + + #region Should_Skip_ContinuousAggregatePolicy_Add + + [Fact] + public void Should_Skip_ContinuousAggregatePolicy_Add() + { + // Arrange + AddContinuousAggregatePolicyOperation operation = new() + { + MaterializedViewName = "hourly_cagg", + Schema = "public", + StartOffset = "1 day", + EndOffset = "1 hour" + }; + + // Act + List statements = ContinuousAggregatePolicySqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + string comment = Assert.Single(statements); + Assert.Equal("-- Skipping Community Edition feature (continuous aggregate policy) - not available in Apache Edition", comment); + } + + #endregion + + #region Should_Skip_ContinuousAggregate_Create_With_Data + + [Fact] + public void Should_Skip_ContinuousAggregate_Create_With_Data() + { + // Arrange + CreateContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_cagg", + Schema = "public", + ParentName = "metrics", + TimeBucketWidth = "1 hour", + TimeBucketSourceColumn = "time", + TimeBucketGroupBy = true, + AggregateFunctions = ["avg_val:Avg:value"], + WithNoData = false + }; + + // Act + List statements = ContinuousAggregateSqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + string comment = Assert.Single(statements); + Assert.Equal("-- Skipping Community Edition feature (continuous aggregate) - not available in Apache Edition", comment); + Assert.DoesNotContain("CREATE MATERIALIZED VIEW", comment); + } + + #endregion + + #region Should_Skip_ContinuousAggregate_Alter + + [Fact] + public void Should_Skip_ContinuousAggregate_Alter() + { + // Arrange + AlterContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_cagg", + Schema = "public", + MaterializedOnly = true, + OldMaterializedOnly = false + }; + + // Act + List statements = ContinuousAggregateSqlGenerator.Generate(operation, isApacheEdition: true); + + // Assert + string comment = Assert.Single(statements); + Assert.Equal("-- Skipping Community Edition feature (continuous aggregate) - not available in Apache Edition", comment); + } + + #endregion + + #region Should_Not_Skip_ContinuousAggregate_Drop + + [Fact] + public void Should_Not_Skip_ContinuousAggregate_Drop() + { + // Arrange + DropContinuousAggregateOperation operation = new() + { + MaterializedViewName = "hourly_cagg", + Schema = "public" + }; + + // Act + List statements = ContinuousAggregateSqlGenerator.Generate(operation); + + // Assert + string statement = Assert.Single(statements); + Assert.Equal("DROP MATERIALIZED VIEW IF EXISTS \"public\".\"hourly_cagg\";", statement); + Assert.DoesNotContain("--", statement); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs index a166a8c..0652b90 100644 --- a/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/ContinuousAggregateCompressionSqlGeneratorTests.cs @@ -13,10 +13,10 @@ private static List Generate(AlterContinuousAggregateOperation op) // ── Create with compression ─────────────────────────────────────────────── - #region Create_WithCompression_Only_Emits_License_Guard_Block + #region Create_WithCompression_Only_Emits_Clean_AlterStatement [Fact] - public void Create_WithCompression_Only_Emits_License_Guard_Block() + public void Create_WithCompression_Only_Emits_Clean_AlterStatement() { // Arrange CreateContinuousAggregateOperation op = new() @@ -37,7 +37,8 @@ public void Create_WithCompression_Only_Emits_License_Guard_Block() // Assert Assert.Equal(2, statements.Count); string compressionStmt = statements[1]; - Assert.Contains("DO $$", compressionStmt); + Assert.DoesNotContain("DO $$", compressionStmt); + Assert.Contains("ALTER MATERIALIZED VIEW", compressionStmt); Assert.Contains("timescaledb.enable_columnstore = true", compressionStmt); Assert.DoesNotContain("compress_segmentby", compressionStmt); Assert.DoesNotContain("compress_orderby", compressionStmt); @@ -71,7 +72,7 @@ public void Create_WithCompression_And_SegmentBy_Emits_SegmentBy() Assert.Equal(2, statements.Count); string compressionStmt = statements[1]; Assert.Contains("timescaledb.enable_columnstore = true", compressionStmt); - Assert.Contains("segmentby = ''\"region\"''", compressionStmt); + Assert.Contains("segmentby = '\"region\"'", compressionStmt); } #endregion @@ -102,7 +103,7 @@ public void Create_WithCompression_And_OrderBy_Emits_OrderBy() Assert.Equal(2, statements.Count); string compressionStmt = statements[1]; Assert.Contains("timescaledb.enable_columnstore = true", compressionStmt); - Assert.Contains("orderby = ''\"time\" DESC''", compressionStmt); + Assert.Contains("orderby = '\"time\" DESC'", compressionStmt); } #endregion @@ -167,10 +168,10 @@ public void Create_WithNoCompression_Emits_SingleStatement() #endregion - #region Create_WithViewDefinition_And_Compression_Emits_CreateThenGuard + #region Create_WithViewDefinition_And_Compression_Emits_CreateThenAlter [Fact] - public void Create_WithViewDefinition_And_Compression_Emits_CreateThenGuard() + public void Create_WithViewDefinition_And_Compression_Emits_CreateThenAlter() { // Arrange CreateContinuousAggregateOperation op = new() @@ -189,7 +190,8 @@ public void Create_WithViewDefinition_And_Compression_Emits_CreateThenGuard() // Assert Assert.Equal(2, statements.Count); Assert.Contains("CREATE MATERIALIZED VIEW", statements[0]); - Assert.Contains("DO $$", statements[1]); + Assert.DoesNotContain("DO $$", statements[1]); + Assert.Contains("ALTER MATERIALIZED VIEW", statements[1]); Assert.Contains("timescaledb.enable_columnstore = true", statements[1]); } @@ -226,10 +228,10 @@ public void Create_SegmentBy_QuotesColumnIdentifiers() // ── Alter with compression ──────────────────────────────────────────────── - #region Alter_EnableCompression_EmitsLicenseGuardWithTrue + #region Alter_EnableCompression_EmitsCleanAlterWithTrue [Fact] - public void Alter_EnableCompression_EmitsLicenseGuardWithTrue() + public void Alter_EnableCompression_EmitsCleanAlterWithTrue() { // Arrange AlterContinuousAggregateOperation op = new() @@ -245,16 +247,17 @@ public void Alter_EnableCompression_EmitsLicenseGuardWithTrue() // Assert string stmt = Assert.Single(statements); - Assert.Contains("DO $$", stmt); + Assert.DoesNotContain("DO $$", stmt); + Assert.Contains("ALTER MATERIALIZED VIEW", stmt); Assert.Contains("timescaledb.enable_columnstore = true", stmt); } #endregion - #region Alter_DisableCompression_EmitsLicenseGuardWithFalse + #region Alter_DisableCompression_EmitsCleanAlterWithFalse [Fact] - public void Alter_DisableCompression_EmitsLicenseGuardWithFalse() + public void Alter_DisableCompression_EmitsCleanAlterWithFalse() { // Arrange AlterContinuousAggregateOperation op = new() @@ -295,7 +298,7 @@ public void Alter_ChangeSegmentBy_EmitsNewSegmentBy() // Assert string stmt = Assert.Single(statements); - Assert.Contains("segmentby = ''\"device_id\"''", stmt); + Assert.Contains("segmentby = '\"device_id\"'", stmt); Assert.DoesNotContain("\"region\"", stmt); } @@ -346,7 +349,7 @@ public void Alter_ChangeOrderBy_EmitsNewOrderBy() List statements = Generate(op); // Assert - Assert.Contains("orderby = ''\"time\" DESC''", Assert.Single(statements)); + Assert.Contains("orderby = '\"time\" DESC'", Assert.Single(statements)); } #endregion @@ -405,10 +408,10 @@ public void Alter_CompressionWithOtherChanges_EmitsBothStatements() #endregion - #region Alter_WrapsCommunityFeaturesInLicenseGuard + #region Alter_EmitsCommunityFeaturesCleanly [Fact] - public void Alter_WrapsCommunityFeaturesInLicenseGuard() + public void Alter_EmitsCommunityFeaturesCleanly() { // Arrange AlterContinuousAggregateOperation op = new() @@ -424,11 +427,11 @@ public void Alter_WrapsCommunityFeaturesInLicenseGuard() // Assert string stmt = Assert.Single(statements); - Assert.Contains("DO $$", stmt); - Assert.Contains("DECLARE", stmt); - Assert.Contains("timescaledb.license", stmt); - Assert.Contains("IF license IS NULL OR license != 'apache' THEN", stmt); - Assert.Contains("RAISE WARNING", stmt); + Assert.DoesNotContain("DO $$", stmt); + Assert.DoesNotContain("timescaledb.license", stmt); + Assert.DoesNotContain("RAISE WARNING", stmt); + Assert.Contains("ALTER MATERIALIZED VIEW", stmt); + Assert.Contains("timescaledb.enable_columnstore = true", stmt); } #endregion diff --git a/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs index d391e5d..1cc0a97 100644 --- a/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableColumnstoreSqlGeneratorTests.cs @@ -36,18 +36,7 @@ public void Should_Generate_Create_With_SparseIndex() string expected = @" SELECT create_hypertable('public.""sensor_data""', 'ts'); - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = ''bloom(device_id)'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = 'bloom(device_id)'); "; // Act @@ -77,18 +66,7 @@ public void Should_Generate_Create_With_CompressChunkTimeInterval() string expected = @" SELECT create_hypertable('public.""sensor_data""', 'ts'); - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.compress_chunk_time_interval = ''24 hours'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.compress_chunk_time_interval = '24 hours'); "; // Act @@ -119,18 +97,7 @@ public void Should_Generate_Create_With_SparseIndex_And_CompressChunkTimeInterva string expected = @" SELECT create_hypertable('public.""sensor_data""', 'ts'); - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = ''bloom(device_id)'', timescaledb.compress_chunk_time_interval = ''7 days'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = 'bloom(device_id)', timescaledb.compress_chunk_time_interval = '7 days'); "; // Act @@ -160,18 +127,7 @@ public void Should_Generate_Create_With_Empty_SparseIndex() string expected = @" SELECT create_hypertable('public.""sensor_data""', 'ts'); - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = '''')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = ''); "; // Act @@ -235,12 +191,12 @@ public void Should_Not_Include_CompressChunkTimeInterval_When_Null_On_Create() #endregion - // ── Create: settings appear inside the community guard block ── + // ── Create: sparse index emitted as a clean ALTER TABLE after create ── - #region Should_Include_SparseIndex_Inside_Community_Guard_On_Create + #region Should_Emit_SparseIndex_As_Clean_Alter_On_Create [Fact] - public void Should_Include_SparseIndex_Inside_Community_Guard_On_Create() + public void Should_Emit_SparseIndex_As_Clean_Alter_On_Create() { // Arrange CreateHypertableOperation operation = new() @@ -255,12 +211,12 @@ public void Should_Include_SparseIndex_Inside_Community_Guard_On_Create() string result = GetGeneratedSql(operation); // Assert - Assert.Contains("DO $$", result); - Assert.Contains("timescaledb.license", result); - Assert.Contains("sparse_index", result); - int guardStart = result.IndexOf("DO $$", StringComparison.Ordinal); + Assert.DoesNotContain("DO $$", result); + Assert.DoesNotContain("timescaledb.license", result); + Assert.Contains("ALTER TABLE \"public\".\"sensor_data\" SET (timescaledb.sparse_index = 'bloom(device_id)');", result); + int createIdx = result.IndexOf("create_hypertable", StringComparison.Ordinal); int sparseIdx = result.IndexOf("sparse_index", StringComparison.Ordinal); - Assert.True(sparseIdx > guardStart, "sparse_index must appear inside the community guard block"); + Assert.True(sparseIdx > createIdx, "sparse_index must appear after create_hypertable"); } #endregion @@ -282,18 +238,7 @@ public void Should_Generate_Alter_Set_When_SparseIndex_Changed() }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = ''bloom(device_id), minmax(temperature)'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.sparse_index = 'bloom(device_id), minmax(temperature)'); "; // Act @@ -322,18 +267,7 @@ public void Should_Generate_Alter_Reset_When_SparseIndex_Removed() }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""sensor_data"" RESET (timescaledb.sparse_index)'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""sensor_data"" RESET (timescaledb.sparse_index); "; // Act @@ -362,18 +296,7 @@ public void Should_Generate_Alter_Set_When_CompressChunkTimeInterval_Changed() }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.compress_chunk_time_interval = ''7 days'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.compress_chunk_time_interval = '7 days'); "; // Act @@ -402,18 +325,7 @@ public void Should_Generate_Alter_Set_Zero_When_CompressChunkTimeInterval_Remove }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.compress_chunk_time_interval = ''0'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""sensor_data"" SET (timescaledb.compress_chunk_time_interval = '0'); "; // Act @@ -425,12 +337,12 @@ public void Should_Generate_Alter_Set_Zero_When_CompressChunkTimeInterval_Remove #endregion - // ── Alter: RESET statements appear inside community guard ── + // ── Alter: RESET and clear statements emitted cleanly ── - #region Should_Include_Reset_Statements_Inside_Community_Guard + #region Should_Emit_Reset_Statements_Cleanly [Fact] - public void Should_Include_Reset_Statements_Inside_Community_Guard() + public void Should_Emit_Reset_Statements_Cleanly() { // Arrange AlterHypertableOperation operation = new() @@ -447,18 +359,15 @@ public void Should_Include_Reset_Statements_Inside_Community_Guard() string result = GetGeneratedSql(operation); // Assert - Assert.Contains("DO $$", result); - Assert.Contains("timescaledb.license", result); - int guardStart = result.IndexOf("DO $$", StringComparison.Ordinal); - int sparseReset = result.IndexOf("RESET (timescaledb.sparse_index)", StringComparison.Ordinal); - int cctiClear = result.IndexOf("timescaledb.compress_chunk_time_interval = ''0''", StringComparison.Ordinal); - Assert.True(sparseReset > guardStart, "RESET (timescaledb.sparse_index) must appear inside community guard"); - Assert.True(cctiClear > guardStart, "SET (timescaledb.compress_chunk_time_interval = '0') must appear inside community guard"); + Assert.DoesNotContain("DO $$", result); + Assert.DoesNotContain("timescaledb.license", result); + Assert.Contains("ALTER TABLE \"public\".\"sensor_data\" RESET (timescaledb.sparse_index);", result); + Assert.Contains("timescaledb.compress_chunk_time_interval = '0'", result); } #endregion - // ── Alter: both removed β†’ RESET and SET '0' inside one guard ── + // ── Alter: both removed β†’ RESET and SET '0' emitted cleanly ── #region Should_Generate_Both_Removal_Statements_When_Both_Removed @@ -481,8 +390,8 @@ public void Should_Generate_Both_Removal_Statements_When_Both_Removed() // Assert Assert.Contains("RESET (timescaledb.sparse_index)", result); - Assert.Contains("timescaledb.compress_chunk_time_interval = ''0''", result); - Assert.Equal(1, result.Split("DO $$").Length - 1); + Assert.Contains("timescaledb.compress_chunk_time_interval = '0'", result); + Assert.DoesNotContain("DO $$", result); } #endregion diff --git a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs index 15f7d45..9f47069 100644 --- a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorComprehensiveTests.cs @@ -123,18 +123,7 @@ public void DesignTime_Create_CompressionWithoutChunkSkipping_GeneratesCorrectCo string expected = @" SELECT create_hypertable('public.""compressed_data""', 'time'); - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""compressed_data"" SET (timescaledb.enable_columnstore = true)'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""compressed_data"" SET (timescaledb.enable_columnstore = true); "; // Act @@ -361,18 +350,7 @@ public void DesignTime_Create_WithCompressionSegmentBy_GeneratesCorrectCode() string expected = @" SELECT create_hypertable('public.""segmented_data""', 'time'); - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""segmented_data"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = ''""tenant_id"", ""device_id""'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""segmented_data"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = '""tenant_id"", ""device_id""'); "; // Act @@ -396,18 +374,7 @@ public void DesignTime_Create_WithCompressionOrderBy_GeneratesCorrectCode() string expected = @" SELECT create_hypertable('public.""ordered_data""', 'time'); - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""ordered_data"" SET (timescaledb.enable_columnstore = true, timescaledb.orderby = ''""time"" DESC, ""value"" ASC NULLS LAST'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""ordered_data"" SET (timescaledb.enable_columnstore = true, timescaledb.orderby = '""time"" DESC, ""value"" ASC NULLS LAST'); "; // Act @@ -437,8 +404,8 @@ public void Runtime_Create_WithFullCompressionSettings_GeneratesUnifiedAlter() // Assert Assert.Contains("ALTER TABLE \"public\".\"full_compression\" SET", result); Assert.Contains("timescaledb.enable_columnstore = true", result); - Assert.Contains("timescaledb.segmentby = ''\"tenant_id\"''", result); - Assert.Contains("timescaledb.orderby = ''\"time\" DESC''", result); + Assert.Contains("timescaledb.segmentby = '\"tenant_id\"'", result); + Assert.Contains("timescaledb.orderby = '\"time\" DESC'", result); } #endregion @@ -603,18 +570,7 @@ public void DesignTime_Alter_DisablingCompression_GeneratesCorrectCode() }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""decompress"" SET (timescaledb.enable_columnstore = false)'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""decompress"" SET (timescaledb.enable_columnstore = false); "; // Act @@ -637,20 +593,9 @@ public void DesignTime_Alter_AddingChunkSkipColumn_GeneratesCorrectSequence() }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; - EXECUTE 'SELECT enable_chunk_skipping(''public.""add_skip""'', ''col2'')'; - EXECUTE 'SELECT enable_chunk_skipping(''public.""add_skip""'', ''col3'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('public.""add_skip""', 'col2'); + SELECT enable_chunk_skipping('public.""add_skip""', 'col3'); "; // Act @@ -673,18 +618,7 @@ public void DesignTime_Alter_RemovingChunkSkipColumn_GeneratesDisableCommands() }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'SELECT disable_chunk_skipping(''public.""remove_skip""'', ''remove_this'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + SELECT disable_chunk_skipping('public.""remove_skip""', 'remove_this'); "; // Act @@ -771,8 +705,8 @@ public void Runtime_Alter_ChunkSkipping_RequiresSETCommand() string result = GetRuntimeSql(operation); // Assert - Assert.Contains("SET timescaledb.enable_chunk_skipping = ''ON''", result); - Assert.Contains("enable_chunk_skipping(''public.\"skip_test\"'', ''new_col'')", result); + Assert.Contains("SET timescaledb.enable_chunk_skipping = 'ON'", result); + Assert.Contains("enable_chunk_skipping('public.\"skip_test\"', 'new_col')", result); } [Fact] @@ -863,18 +797,7 @@ public void DesignTime_Alter_AddingCompressionSegmentBy_GeneratesCorrectCode() }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""metrics"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = ''""device_id""'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""metrics"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = '""device_id""'); "; // Act @@ -900,7 +823,7 @@ public void Runtime_Alter_ChangingCompressionOrderBy_GeneratesCorrectSQL() string result = GetRuntimeSql(operation); // Assert - Assert.Contains("timescaledb.orderby = ''\"time\" DESC''", result); + Assert.Contains("timescaledb.orderby = '\"time\" DESC'", result); } [Fact] @@ -962,8 +885,8 @@ public void Runtime_Alter_ComplexCompressionUpdate_GeneratesUnifiedAlter() // Assert Assert.Contains("ALTER TABLE \"public\".\"metrics\" SET", result); - Assert.Contains("timescaledb.segmentby = ''\"new_col\"''", result); - Assert.Contains("timescaledb.orderby = ''''", result); + Assert.Contains("timescaledb.segmentby = '\"new_col\"'", result); + Assert.Contains("timescaledb.orderby = ''", result); } #endregion diff --git a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs index 70ed2a3..f958bfd 100644 --- a/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/HypertableSqlGeneratorTests.cs @@ -56,20 +56,9 @@ public void Generate_Create_with_all_options_generates_comprehensive_sql() string expected = @" SELECT create_hypertable('custom_schema.""FullTable""', 'EventTime', chunk_time_interval => INTERVAL '1 day'); SELECT add_dimension('custom_schema.""FullTable""', by_hash('LocationId', 4)); - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""custom_schema"".""FullTable"" SET (timescaledb.enable_columnstore = true)'; - EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; - EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""FullTable""'', ''DeviceId'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""custom_schema"".""FullTable"" SET (timescaledb.enable_columnstore = true); + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('custom_schema.""FullTable""', 'DeviceId'); "; // Act @@ -94,20 +83,9 @@ public void Generate_Alter_WhenAddingChunkSkippingToUncompressedTable_ShouldAlso }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""custom_schema"".""Metrics"" SET (timescaledb.enable_columnstore = true)'; - EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; - EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""Metrics""'', ''device_id'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""custom_schema"".""Metrics"" SET (timescaledb.enable_columnstore = true); + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('custom_schema.""Metrics""', 'device_id'); "; // Act @@ -130,18 +108,7 @@ public void Generate_Alter_when_changing_compression_generates_correct_sql() }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""SensorData"" SET (timescaledb.enable_columnstore = true)'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""SensorData"" SET (timescaledb.enable_columnstore = true); "; // Act @@ -167,18 +134,7 @@ public void Generate_Create_With_Compression_Segment_And_OrderBy_Generates_Corre string expected = @" SELECT create_hypertable('public.""CompressedTable""', 'Timestamp'); - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""CompressedTable"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = ''""TenantId"", ""DeviceId""'', timescaledb.orderby = ''""Timestamp"" DESC, ""Value"" ASC NULLS LAST'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""CompressedTable"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = '""TenantId"", ""DeviceId""', timescaledb.orderby = '""Timestamp"" DESC, ""Value"" ASC NULLS LAST'); "; // Act @@ -201,18 +157,7 @@ public void Generate_Alter_Adding_Compression_SegmentBy_Generates_Correct_Sql() }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = ''""DeviceId""'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""Metrics"" SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = '""DeviceId""'); "; // Act @@ -235,18 +180,7 @@ public void Generate_Alter_Modifying_Compression_OrderBy_Generates_Correct_Sql() }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.orderby = ''""Timestamp"" DESC'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""Metrics"" SET (timescaledb.orderby = '""Timestamp"" DESC'); "; // Act @@ -273,18 +207,7 @@ public void Generate_Alter_Removing_Compression_Configuration_Generates_Empty_St }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""Metrics"" SET (timescaledb.segmentby = '''', timescaledb.orderby = '''')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""Metrics"" SET (timescaledb.segmentby = '', timescaledb.orderby = ''); "; // Act @@ -307,20 +230,9 @@ public void Generate_Alter_when_adding_and_removing_skip_columns_generates_corre }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; - EXECUTE 'SELECT enable_chunk_skipping(''metrics_schema.""Metrics""'', ''service'')'; - EXECUTE 'SELECT disable_chunk_skipping(''metrics_schema.""Metrics""'', ''region'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('metrics_schema.""Metrics""', 'service'); + SELECT disable_chunk_skipping('metrics_schema.""Metrics""', 'region'); "; // Act @@ -367,19 +279,8 @@ public void Generate_Alter_WhenRemovingLastChunkSkipColumn_ShouldDisableCompress ChunkSkipColumns = [] }; string expected = @" - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""public"".""Logs"" SET (timescaledb.enable_columnstore = false)'; - EXECUTE 'SELECT disable_chunk_skipping(''public.""Logs""'', ''trace_id'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""public"".""Logs"" SET (timescaledb.enable_columnstore = false); + SELECT disable_chunk_skipping('public.""Logs""', 'trace_id'); "; // Act @@ -457,20 +358,9 @@ public void Generate_Create_When_MigrateData_True_With_All_Options_Generates_Com string expected = @" SELECT create_hypertable('custom_schema.""CompleteTable""', 'EventTime', migrate_data => true, chunk_time_interval => INTERVAL '1 day'); SELECT add_dimension('custom_schema.""CompleteTable""', by_hash('LocationId', 4)); - DO $$ - DECLARE - license TEXT; - BEGIN - license := current_setting('timescaledb.license', true); - - IF license IS NULL OR license != 'apache' THEN - EXECUTE 'ALTER TABLE ""custom_schema"".""CompleteTable"" SET (timescaledb.enable_columnstore = true)'; - EXECUTE 'SET timescaledb.enable_chunk_skipping = ''ON'''; - EXECUTE 'SELECT enable_chunk_skipping(''custom_schema.""CompleteTable""'', ''DeviceId'')'; - ELSE - RAISE WARNING 'Skipping Community Edition features (compression, chunk skipping) - not available in Apache Edition'; - END IF; - END $$; + ALTER TABLE ""custom_schema"".""CompleteTable"" SET (timescaledb.enable_columnstore = true); + SET timescaledb.enable_chunk_skipping = 'ON'; + SELECT enable_chunk_skipping('custom_schema.""CompleteTable""', 'DeviceId'); "; // Act diff --git a/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs b/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs index 04158a8..746d1c6 100644 --- a/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs +++ b/tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs @@ -409,6 +409,63 @@ public void BuildQueryString_MigrationCommandListBuilder_UsePerform_And_Suppress #endregion + #region SkipOnApacheEdition + + [Fact] + public void SkipOnApacheEdition_Community_ReturnsStatementsUnchanged() + { + // Arrange + List statements = ["SELECT 1;", "SELECT 2;"]; + + // Act + List result = SqlBuilderHelper.SkipOnApacheEdition(statements, "skip message", isApacheEdition: false); + + // Assert + Assert.Same(statements, result); + Assert.Equal(["SELECT 1;", "SELECT 2;"], result); + } + + [Fact] + public void SkipOnApacheEdition_Apache_ReplacesStatementsWithSingleComment() + { + // Arrange + List statements = ["SELECT 1;", "SELECT 2;"]; + + // Act + List result = SqlBuilderHelper.SkipOnApacheEdition(statements, "skip message", isApacheEdition: true); + + // Assert + Assert.Equal(["-- skip message"], result); + } + + [Fact] + public void SkipOnApacheEdition_EmptyList_StaysEmpty_OnCommunity() + { + // Arrange + List statements = []; + + // Act + List result = SqlBuilderHelper.SkipOnApacheEdition(statements, "skip message", isApacheEdition: false); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void SkipOnApacheEdition_EmptyList_StaysEmpty_OnApache() + { + // Arrange + List statements = []; + + // Act + List result = SqlBuilderHelper.SkipOnApacheEdition(statements, "skip message", isApacheEdition: true); + + // Assert + Assert.Empty(result); + } + + #endregion + #region BuildQueryString_Flushes_Trailing_Group_When_No_Terminating_Semicolon [Fact] diff --git a/tests/Eftdb.Tests/Integration/ApacheEditionIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ApacheEditionIntegrationTests.cs new file mode 100644 index 0000000..73fb7af --- /dev/null +++ b/tests/Eftdb.Tests/Integration/ApacheEditionIntegrationTests.cs @@ -0,0 +1,222 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Verifies UseApacheEdition() against the Apache (OSS) TimescaleDB image: models that +/// request Community-only features apply through the migration pipeline without throwing, and +/// the Community-only side effects are omitted from the generated SQL. +/// +public class ApacheEditionIntegrationTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder(TimescaleImages.Apache) + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Helper Methods + + private static async Task HasAnyPolicyJobAsync(DbContext context) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) + FROM timescaledb_information.jobs + WHERE proc_name IN ('policy_retention', 'policy_reorder', 'policy_compression', 'policy_columnstore', 'policy_refresh_continuous_aggregate'); + "; + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is long count && count > 0; + } + + private static async Task GetContinuousAggregateCountAsync(DbContext context) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) + FROM timescaledb_information.continuous_aggregates; + "; + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is long count ? (int)count : 0; + } + + #endregion + + #region Should_Apply_HypertablePolicies_Model_Without_Throwing_On_Apache + + private class GuardedMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public int DeviceId { get; set; } + public double Value { get; set; } + } + + private class GuardedPoliciesContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(o => o.UseApacheEdition()); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("apache_guarded_metrics"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time) + .EnableCompression(true) + .WithCompressionOrderBy(s => s.ByDescending(x => x.Time)) + .WithChunkSkipping(x => x.DeviceId); + entity.WithRetentionPolicy(dropAfter: "30 days"); + entity.WithReorderPolicy("apache_guarded_metrics_time_idx"); + entity.WithCompressionPolicy(after: "7 days"); + entity.HasIndex(e => new { e.Time, e.Id }) + .HasDatabaseName("apache_guarded_metrics_time_idx"); + }); + } + } + + [Fact] + public async Task Should_Apply_HypertablePolicies_Model_Without_Throwing_On_Apache() + { + await using GuardedPoliciesContext context = new(_connectionString!); + + await CreateDatabaseViaMigrationAsync(context); + + bool isHypertable = await HypertableProbe.IsHypertableAsync(context, "apache_guarded_metrics"); + Assert.True(isHypertable); + + bool compressionEnabled = await HypertableProbe.IsCompressionEnabledAsync(context, "apache_guarded_metrics"); + Assert.False(compressionEnabled); + + bool hasPolicyJob = await HasAnyPolicyJobAsync(context); + Assert.False(hasPolicyJob); + } + + #endregion + + #region Should_Apply_ContinuousAggregate_WithData_Model_Without_Throwing_On_Apache + + private class CaggSourceMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CaggAggregate + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class GuardedCaggContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(o => o.UseApacheEdition()); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("apache_cagg_source"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "apache_cagg_with_data", + "1 hour", + x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithNoData(false) + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); + + entity.Property(x => x.TimeBucket).HasColumnName("time_bucket"); + entity.Property(x => x.AvgValue).HasColumnName("AvgValue"); + }); + } + } + + [Fact] + public async Task Should_Apply_ContinuousAggregate_WithData_Model_Without_Throwing_On_Apache() + { + await using GuardedCaggContext context = new(_connectionString!); + + await CreateDatabaseViaMigrationAsync(context); + + int caggCount = await GetContinuousAggregateCountAsync(context); + Assert.Equal(0, caggCount); + + bool hasPolicyJob = await HasAnyPolicyJobAsync(context); + Assert.False(hasPolicyJob); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/ComplexTypeIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ComplexTypeIntegrationTests.cs index 564419a..b374a74 100644 --- a/tests/Eftdb.Tests/Integration/ComplexTypeIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ComplexTypeIntegrationTests.cs @@ -9,6 +9,7 @@ using Npgsql; using System.ComponentModel.DataAnnotations.Schema; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -19,7 +20,7 @@ public class ComplexTypeIntegrationTests : MigrationTestBase, IAsyncLifetime public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/CompressionPolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/CompressionPolicyIntegrationTests.cs index 431124d..5c3f4c5 100644 --- a/tests/Eftdb.Tests/Integration/CompressionPolicyIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/CompressionPolicyIntegrationTests.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -13,7 +14,7 @@ public class CompressionPolicyIntegrationTests : MigrationTestBase, IAsyncLifeti public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/CompressionPolicyRoundTripTests.cs b/tests/Eftdb.Tests/Integration/CompressionPolicyRoundTripTests.cs index ef72f66..5023d19 100644 --- a/tests/Eftdb.Tests/Integration/CompressionPolicyRoundTripTests.cs +++ b/tests/Eftdb.Tests/Integration/CompressionPolicyRoundTripTests.cs @@ -19,6 +19,7 @@ using Npgsql; using System.Reflection; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -36,7 +37,7 @@ public sealed class CompressionPolicyRoundTripTests : MigrationTestBase, IAsyncL public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/CompressionPolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/CompressionPolicyScaffoldingExtractorTests.cs index 7a93f7d..e156d13 100644 --- a/tests/Eftdb.Tests/Integration/CompressionPolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/CompressionPolicyScaffoldingExtractorTests.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -16,7 +17,7 @@ public class CompressionPolicyScaffoldingExtractorTests : MigrationTestBase, IAs public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionIntegrationTests.cs index 3ed3ca8..1a9492a 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionIntegrationTests.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -16,7 +17,7 @@ public class ContinuousAggregateCompressionIntegrationTests : MigrationTestBase, public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionRoundTripTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionRoundTripTests.cs index b50f31c..cad4744 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionRoundTripTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionRoundTripTests.cs @@ -18,6 +18,7 @@ using Npgsql; using System.Reflection; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -33,7 +34,7 @@ public sealed class ContinuousAggregateCompressionRoundTripTests : MigrationTest public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionScaffoldingExtractorTests.cs index 0c7563c..edca6b9 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateCompressionScaffoldingExtractorTests.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -16,7 +17,7 @@ public class ContinuousAggregateCompressionScaffoldingExtractorTests : Migration public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs index 2cf6089..51c91c5 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs @@ -3,6 +3,7 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using Microsoft.EntityFrameworkCore; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration { @@ -13,7 +14,7 @@ public class ContinuousAggregateIntegrationTests : MigrationTestBase, IAsyncLife public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs index 30b1982..1b6d2fe 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Migrations.Operations; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -16,7 +17,7 @@ public class ContinuousAggregatePolicyIntegrationTests : MigrationTestBase, IAsy public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs index d6e4b25..2f74768 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs @@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -17,7 +18,7 @@ public class ContinuousAggregatePolicyScaffoldingExtractorTests : MigrationTestB public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs index 995e916..fedeca8 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -16,7 +17,7 @@ public class ContinuousAggregateScaffoldingExtractorTests : MigrationTestBase, I public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/HypertableApacheIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableApacheIntegrationTests.cs new file mode 100644 index 0000000..e8b1bf8 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/HypertableApacheIntegrationTests.cs @@ -0,0 +1,13 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Runs the license-neutral hypertable facts from +/// against the Apache-edition (OSS) TimescaleDB image to prove they do not depend on any +/// Community-only feature. +/// +public class HypertableApacheIntegrationTests : HypertableIntegrationTestsBase +{ + protected override string Image => TimescaleImages.Apache; +} diff --git a/tests/Eftdb.Tests/Integration/HypertableApacheScaffoldingSmokeTests.cs b/tests/Eftdb.Tests/Integration/HypertableApacheScaffoldingSmokeTests.cs new file mode 100644 index 0000000..d33a2e2 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/HypertableApacheScaffoldingSmokeTests.cs @@ -0,0 +1,87 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Features.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// Smoke test proving the hypertable scaffolding pipeline runs against the Apache (OSS) image +/// and extracts a plain hypertable's annotations without throwing. +/// +public class HypertableApacheScaffoldingSmokeTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder(TimescaleImages.Apache) + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Should_Scaffold_Plain_Hypertable_On_Apache + + private class ApacheScaffoldMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ApacheScaffoldContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("apache_scaffold_metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Scaffold_Plain_Hypertable_On_Apache() + { + await using ApacheScaffoldContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + HypertableScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.True(result.ContainsKey(("public", "apache_scaffold_metrics"))); + + HypertableScaffoldingExtractor.HypertableInfo info = + (HypertableScaffoldingExtractor.HypertableInfo)result[("public", "apache_scaffold_metrics")]; + Assert.Equal("Timestamp", info.TimeColumnName); + Assert.NotNull(info.ChunkTimeInterval); + Assert.False(info.CompressionEnabled); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/HypertableColumnstoreIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableColumnstoreIntegrationTests.cs index 6566887..b316397 100644 --- a/tests/Eftdb.Tests/Integration/HypertableColumnstoreIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableColumnstoreIntegrationTests.cs @@ -11,6 +11,7 @@ using System.Diagnostics; using System.Text.Json; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; #pragma warning disable EF1001 // Internal EF Core API usage required for testing scaffolding infrastructure @@ -23,7 +24,7 @@ public class HypertableColumnstoreIntegrationTests : MigrationTestBase, IAsyncLi public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs index a39aef8..94f4748 100644 --- a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs @@ -1,15 +1,19 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; using Microsoft.EntityFrameworkCore; using Npgsql; -using Testcontainers.PostgreSql; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; -public class HypertableIntegrationTests : MigrationTestBase, IAsyncLifetime +/// +/// Community-edition hypertable integration tests. Inherits the license-neutral facts from +/// and adds the compression and chunk-skipping +/// facts that only apply on the Community edition. +/// +public class HypertableIntegrationTests : HypertableIntegrationTestsBase { - private PostgreSqlContainer? _container; - private string? _connectionString; + protected override string Image => TimescaleImages.Community; private class CompressionSettingInfo { @@ -20,116 +24,8 @@ private class CompressionSettingInfo public bool IsNullsFirst { get; set; } } - public async ValueTask InitializeAsync() - { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") - .WithDatabase("test_db") - .WithUsername("test_user") - .WithPassword("test_password") - .Build(); - - await _container.StartAsync(); - _connectionString = _container.GetConnectionString(); - } - - public async ValueTask DisposeAsync() - { - GC.SuppressFinalize(this); - - if (_container != null) - { - await _container.DisposeAsync(); - } - } - #region Helper Methods - private static async Task IsHypertableAsync(DbContext context, string tableName) - { - NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); - bool wasOpen = connection.State == System.Data.ConnectionState.Open; - - if (!wasOpen) - { - await connection.OpenAsync(); - } - - await using NpgsqlCommand command = connection.CreateCommand(); - command.CommandText = @" - SELECT COUNT(*) > 0 - FROM timescaledb_information.hypertables - WHERE hypertable_name = @tableName; - "; - command.Parameters.AddWithValue("tableName", tableName); - - object? result = await command.ExecuteScalarAsync(); - - if (!wasOpen) - { - await connection.CloseAsync(); - } - - return result is bool boolResult && boolResult; - } - - private static async Task GetChunkIntervalAsync(DbContext context, string tableName) - { - NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); - bool wasOpen = connection.State == System.Data.ConnectionState.Open; - - if (!wasOpen) - { - await connection.OpenAsync(); - } - - await using NpgsqlCommand command = connection.CreateCommand(); - command.CommandText = @" - SELECT time_interval::text - FROM timescaledb_information.dimensions - WHERE hypertable_name = @tableName - AND dimension_type = 'Time' - LIMIT 1; - "; - command.Parameters.AddWithValue("tableName", tableName); - - object? result = await command.ExecuteScalarAsync(); - - if (!wasOpen) - { - await connection.CloseAsync(); - } - - return result?.ToString() ?? string.Empty; - } - - private static async Task IsCompressionEnabledAsync(DbContext context, string tableName) - { - NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); - bool wasOpen = connection.State == System.Data.ConnectionState.Open; - - if (!wasOpen) - { - await connection.OpenAsync(); - } - - await using NpgsqlCommand command = connection.CreateCommand(); - command.CommandText = @" - SELECT compression_enabled - FROM timescaledb_information.hypertables - WHERE hypertable_name = @tableName; - "; - command.Parameters.AddWithValue("tableName", tableName); - - object? result = await command.ExecuteScalarAsync(); - - if (!wasOpen) - { - await connection.CloseAsync(); - } - - return result is bool boolResult && boolResult; - } - private static async Task> GetCompressionSettingsAsync(DbContext context, string tableName) { NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); @@ -142,11 +38,11 @@ private static async Task> GetCompressionSettingsAs await using NpgsqlCommand command = connection.CreateCommand(); command.CommandText = @" - SELECT - attname, - segmentby_column_index, - orderby_column_index, - orderby_asc, + SELECT + attname, + segmentby_column_index, + orderby_column_index, + orderby_asc, orderby_nullsfirst FROM timescaledb_information.compression_settings WHERE hypertable_name = @tableName @@ -211,165 +107,6 @@ FROM _timescaledb_catalog.chunk_column_stats ccs return columns; } - private static async Task> GetDimensionsAsync(DbContext context, string tableName) - { - NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); - bool wasOpen = connection.State == System.Data.ConnectionState.Open; - - if (!wasOpen) - { - await connection.OpenAsync(); - } - - await using NpgsqlCommand command = connection.CreateCommand(); - command.CommandText = @" - SELECT column_name, num_partitions - FROM timescaledb_information.dimensions - WHERE hypertable_name = @tableName; - "; - command.Parameters.AddWithValue("tableName", tableName); - - List dimensions = []; - await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - dimensions.Add(new DimensionInfo - { - ColumnName = reader.GetString(0), - NumberPartitions = reader.IsDBNull(1) ? null : reader.GetInt32(1) - }); - } - - if (!wasOpen) - { - await connection.CloseAsync(); - } - - return dimensions; - } - - private static async Task GetChunkCountAsync(DbContext context, string tableName) - { - NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); - bool wasOpen = connection.State == System.Data.ConnectionState.Open; - - if (!wasOpen) - { - await connection.OpenAsync(); - } - - await using NpgsqlCommand command = connection.CreateCommand(); - command.CommandText = @" - SELECT COUNT(*) - FROM timescaledb_information.chunks - WHERE hypertable_schema = 'public' AND hypertable_name = @tableName; - "; - command.Parameters.AddWithValue("tableName", tableName); - - object? result = await command.ExecuteScalarAsync(); - - if (!wasOpen) - { - await connection.CloseAsync(); - } - - return result is long longResult ? (int)longResult : - result is int intResult ? intResult : 0; - } - - private class DimensionInfo - { - public string ColumnName { get; set; } = string.Empty; - public int? NumberPartitions { get; set; } - } - - #endregion - - #region Should_Create_Minimal_Hypertable - - private class MinimalHypertableMetric - { - public DateTime Timestamp { get; set; } - public double Value { get; set; } - } - - private class MinimalHypertableContext(string connectionString) : DbContext - { - public DbSet Metrics => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.ToTable("Metrics"); - entity.HasNoKey(); - entity.IsHypertable(x => x.Timestamp); - }); - } - } - - [Fact] - public async Task Should_Create_Minimal_Hypertable() - { - await using MinimalHypertableContext context = new(_connectionString!); - await CreateDatabaseViaMigrationAsync(context); - - DateTime timestamp = new(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc); - double value = 100.5; - await context.Database.ExecuteSqlInterpolatedAsync( - $"INSERT INTO \"Metrics\" (\"Timestamp\", \"Value\") VALUES ({timestamp}, {value})", TestContext.Current.CancellationToken); - - bool isHypertable = await IsHypertableAsync(context, "Metrics"); - Assert.True(isHypertable); - - List metrics = await context.Metrics.ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal(100.5, Assert.Single(metrics).Value); - } - - #endregion - - #region Should_Create_Hypertable_With_CustomChunkInterval - - private class CustomChunkIntervalData - { - public DateTime Timestamp { get; set; } - public int DeviceId { get; set; } - public double Temperature { get; set; } - } - - private class CustomChunkIntervalContext(string connectionString) : DbContext - { - public DbSet SensorData => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.ToTable("sensor_data"); - entity.HasNoKey(); - entity.IsHypertable(x => x.Timestamp) - .WithChunkTimeInterval("1 day"); - }); - } - } - - [Fact] - public async Task Should_Create_Hypertable_With_CustomChunkInterval() - { - await using CustomChunkIntervalContext context = new(_connectionString!); - await CreateDatabaseViaMigrationAsync(context); - - string chunkInterval = await GetChunkIntervalAsync(context, "sensor_data"); - - Assert.Contains("1 day", chunkInterval); - } - #endregion #region Should_Create_Hypertable_With_Compression_Enabled @@ -405,7 +142,7 @@ public async Task Should_Create_Hypertable_With_Compression_Enabled() await using CompressionEnabledContext context = new(_connectionString!); await CreateDatabaseViaMigrationAsync(context); - bool compressionEnabled = await IsCompressionEnabledAsync(context, "compressed_metrics"); + bool compressionEnabled = await HypertableProbe.IsCompressionEnabledAsync(context, "compressed_metrics"); Assert.True(compressionEnabled); } @@ -446,7 +183,7 @@ public async Task Should_Create_Hypertable_With_CompressionSegmentBy() await using SegmentByContext context = new(_connectionString!); await CreateDatabaseViaMigrationAsync(context); - bool isCompressed = await IsCompressionEnabledAsync(context, "segment_by_metrics"); + bool isCompressed = await HypertableProbe.IsCompressionEnabledAsync(context, "segment_by_metrics"); Assert.True(isCompressed); List settings = await GetCompressionSettingsAsync(context, "segment_by_metrics"); @@ -496,7 +233,7 @@ public async Task Should_Create_Hypertable_With_CompressionOrderBy() await using OrderByContext context = new(_connectionString!); await CreateDatabaseViaMigrationAsync(context); - bool isCompressed = await IsCompressionEnabledAsync(context, "order_by_metrics"); + bool isCompressed = await HypertableProbe.IsCompressionEnabledAsync(context, "order_by_metrics"); Assert.True(isCompressed); List settings = await GetCompressionSettingsAsync(context, "order_by_metrics"); @@ -596,238 +333,13 @@ public async Task Should_Create_Hypertable_With_ChunkSkipping() List skipColumns = await GetChunkSkipColumnsAsync(context, "skippable_data"); - bool compressionEnabled = await IsCompressionEnabledAsync(context, "skippable_data"); + bool compressionEnabled = await HypertableProbe.IsCompressionEnabledAsync(context, "skippable_data"); Assert.True(compressionEnabled); Assert.Contains("DeviceId", skipColumns); } #endregion - #region Should_Create_Hypertable_With_HashDimension - - private class HashDimensionData - { - public DateTime Timestamp { get; set; } - public int LocationId { get; set; } - public double Value { get; set; } - } - - private class HashDimensionContext(string connectionString) : DbContext - { - public DbSet PartitionedData => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.ToTable("partitioned_data"); - entity.HasNoKey(); - entity.IsHypertable(x => x.Timestamp) - .HasDimension(Dimension.CreateHash("LocationId", 4)); - }); - } - } - - [Fact] - public async Task Should_Create_Hypertable_With_HashDimension() - { - await using HashDimensionContext context = new(_connectionString!); - await CreateDatabaseViaMigrationAsync(context); - - List dimensions = await GetDimensionsAsync(context, "partitioned_data"); - - Assert.Equal(2, dimensions.Count); - - DimensionInfo? hashDimension = dimensions.FirstOrDefault(d => d.ColumnName == "LocationId"); - Assert.NotNull(hashDimension); - Assert.Equal(4, hashDimension.NumberPartitions); - } - - #endregion - - #region Should_Create_Hypertable_With_RangeDimension - - private class RangeDimensionData - { - public DateTime Timestamp { get; set; } - public DateTime ProcessedTime { get; set; } - public double Value { get; set; } - } - - private class RangeDimensionContext(string connectionString) : DbContext - { - public DbSet MultiTimeData => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.ToTable("multi_time_data"); - entity.HasNoKey(); - entity.IsHypertable(x => x.Timestamp) - .HasDimension(Dimension.CreateRange("ProcessedTime", "7 days")); - }); - } - } - - [Fact] - public async Task Should_Create_Hypertable_With_RangeDimension() - { - await using RangeDimensionContext context = new(_connectionString!); - await CreateDatabaseViaMigrationAsync(context); - - List dimensions = await GetDimensionsAsync(context, "multi_time_data"); - - Assert.Equal(2, dimensions.Count); - - DimensionInfo? rangeDimension = dimensions.FirstOrDefault(d => d.ColumnName == "ProcessedTime"); - Assert.NotNull(rangeDimension); - } - - #endregion - - #region Should_Create_Hypertable_With_RangeDimension_IntegerInterval - - private class IntegerRangeDimensionData - { - public DateTime Timestamp { get; set; } - public int SequenceNumber { get; set; } - public double Value { get; set; } - } - - private class IntegerRangeDimensionContext(string connectionString) : DbContext - { - public DbSet SequencedData => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.ToTable("sequenced_data"); - entity.HasNoKey(); - entity.IsHypertable(x => x.Timestamp) - .HasDimension(Dimension.CreateRange("SequenceNumber", "10000")); - }); - } - } - - [Fact] - public async Task Should_Create_Hypertable_With_RangeDimension_IntegerInterval() - { - await using IntegerRangeDimensionContext context = new(_connectionString!); - await CreateDatabaseViaMigrationAsync(context); - - List dimensions = await GetDimensionsAsync(context, "sequenced_data"); - - Assert.Equal(2, dimensions.Count); - - DimensionInfo? rangeDimension = dimensions.FirstOrDefault(d => d.ColumnName == "SequenceNumber"); - Assert.NotNull(rangeDimension); - Assert.Null(rangeDimension.NumberPartitions); - } - - #endregion - - #region Should_Create_Hypertable_With_RangeDimension_TimeInterval - - private class TimeRangeDimensionData - { - public DateTime EventTime { get; set; } - public DateTime ProcessingTime { get; set; } - public string EventType { get; set; } = string.Empty; - } - - private class TimeRangeDimensionContext(string connectionString) : DbContext - { - public DbSet DualTimeData => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.ToTable("dual_time_events"); - entity.HasNoKey(); - entity.IsHypertable(x => x.EventTime) - .HasDimension(Dimension.CreateRange("ProcessingTime", "2 hours")); - }); - } - } - - [Fact] - public async Task Should_Create_Hypertable_With_RangeDimension_TimeInterval() - { - await using TimeRangeDimensionContext context = new(_connectionString!); - await CreateDatabaseViaMigrationAsync(context); - - List dimensions = await GetDimensionsAsync(context, "dual_time_events"); - - Assert.Equal(2, dimensions.Count); - - DimensionInfo? rangeDimension = dimensions.FirstOrDefault(d => d.ColumnName == "ProcessingTime"); - Assert.NotNull(rangeDimension); - Assert.Null(rangeDimension.NumberPartitions); - } - - #endregion - - #region Should_Create_Hypertable_With_MultipleDimensions - - private class MultipleDimensionsData - { - public DateTime EventTime { get; set; } - public int DeviceId { get; set; } - public string Region { get; set; } = string.Empty; - public string EventType { get; set; } = string.Empty; - } - - private class MultipleDimensionsContext(string connectionString) : DbContext - { - public DbSet EventData => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.ToTable("distributed_events"); - entity.HasNoKey(); - entity.IsHypertable(x => x.EventTime) - .HasDimension(Dimension.CreateHash("DeviceId", 4)) - .HasDimension(Dimension.CreateHash("Region", 2)); - }); - } - } - - [Fact] - public async Task Should_Create_Hypertable_With_MultipleDimensions() - { - await using MultipleDimensionsContext context = new(_connectionString!); - await CreateDatabaseViaMigrationAsync(context); - - List dimensions = await GetDimensionsAsync(context, "distributed_events"); - - Assert.Equal(3, dimensions.Count); - Assert.Contains(dimensions, d => d.ColumnName == "EventTime"); - Assert.Contains(dimensions, d => d.ColumnName == "DeviceId"); - Assert.Contains(dimensions, d => d.ColumnName == "Region"); - } - - #endregion - #region Should_Create_Hypertable_With_AllOptions private class AllOptionsData @@ -866,9 +378,9 @@ public async Task Should_Create_Hypertable_With_AllOptions() await using AllOptionsContext context = new(_connectionString!); await CreateDatabaseViaMigrationAsync(context); - bool isHypertable = await IsHypertableAsync(context, "comprehensive_table"); - string chunkInterval = await GetChunkIntervalAsync(context, "comprehensive_table"); - bool compressionEnabled = await IsCompressionEnabledAsync(context, "comprehensive_table"); + bool isHypertable = await HypertableProbe.IsHypertableAsync(context, "comprehensive_table"); + string chunkInterval = await HypertableProbe.GetChunkIntervalAsync(context, "comprehensive_table"); + bool compressionEnabled = await HypertableProbe.IsCompressionEnabledAsync(context, "comprehensive_table"); List skipColumns = await GetChunkSkipColumnsAsync(context, "comprehensive_table"); List dimensions = await GetDimensionsAsync(context, "comprehensive_table"); @@ -881,121 +393,6 @@ public async Task Should_Create_Hypertable_With_AllOptions() #endregion - #region Should_Insert_And_Query_Data_From_Hypertable - - private class IoTDataRecord - { - public DateTime Timestamp { get; set; } - public string DeviceId { get; set; } = string.Empty; - public double Temperature { get; set; } - public double Humidity { get; set; } - } - - private class DataOperationsContext(string connectionString) : DbContext - { - public DbSet IoTData => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.ToTable("IoTData"); - entity.HasNoKey(); - entity.IsHypertable(x => x.Timestamp) - .WithChunkTimeInterval("1 day"); - }); - } - } - - [Fact] - public async Task Should_Insert_And_Query_Data_From_Hypertable() - { - await using DataOperationsContext context = new(_connectionString!); - await CreateDatabaseViaMigrationAsync(context); - - await context.Database.ExecuteSqlInterpolatedAsync($@" - INSERT INTO ""IoTData"" (""Timestamp"", ""DeviceId"", ""Temperature"", ""Humidity"") - VALUES - ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {20.5}, {45.0}), - ({new DateTime(2025, 1, 1, 11, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {21.0}, {46.0}), - ({new DateTime(2025, 1, 2, 10, 0, 0, DateTimeKind.Utc)}, {"device_2"}, {19.5}, {50.0})", TestContext.Current.CancellationToken); - - List data = await context.IoTData.ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal(3, data.Count); - - List device1Data = await context.IoTData.Where(d => d.DeviceId == "device_1").ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal(2, device1Data.Count); - - int chunkCount = await GetChunkCountAsync(context, "IoTData"); - Assert.True(chunkCount >= 1); - } - - #endregion - - #region Should_Handle_LargeDataset - - private class PerformanceTestData - { - public DateTime Timestamp { get; set; } - public int SensorId { get; set; } - public double Value { get; set; } - } - - private class PerformanceTestContext(string connectionString) : DbContext - { - public DbSet PerformanceTest => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.ToTable("PerformanceTest"); - entity.HasNoKey(); - entity.IsHypertable(x => x.Timestamp) - .WithChunkTimeInterval("1 hour"); - }); - } - } - - [Fact] - public async Task Should_Handle_LargeDataset() - { - await using PerformanceTestContext context = new(_connectionString!); - await CreateDatabaseViaMigrationAsync(context); - - DateTime baseTime = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); - List valueRows = []; - - for (int i = 0; i < 100; i++) - { - DateTime timestamp = baseTime.AddMinutes(i); - valueRows.Add(FormattableString.Invariant($"('{timestamp:yyyy-MM-dd HH:mm:ss}+00', {i % 10}, {15.0 + i * 0.1})")); - } - - string sql = $@"INSERT INTO ""PerformanceTest"" (""Timestamp"", ""SensorId"", ""Value"") - VALUES {string.Join(", ", valueRows)}"; - await context.Database.ExecuteSqlRawAsync(sql, [], TestContext.Current.CancellationToken); - - int count = await context.PerformanceTest.CountAsync(TestContext.Current.CancellationToken); - Assert.Equal(100, count); - - List sensor0Data = await context.PerformanceTest - .Where(d => d.SensorId == 0) - .ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal(10, sensor0Data.Count); - - int chunkCount = await GetChunkCountAsync(context, "PerformanceTest"); - Assert.True(chunkCount >= 1); - } - - #endregion - #region Should_Create_Hypertable_Before_Compression private class OperationOrderingMetric @@ -1029,8 +426,8 @@ public async Task Should_Create_Hypertable_Before_Compression() await using OperationOrderingContext context = new(_connectionString!); await CreateDatabaseViaMigrationAsync(context); - bool isHypertable = await IsHypertableAsync(context, "ordered_ops"); - bool compressionEnabled = await IsCompressionEnabledAsync(context, "ordered_ops"); + bool isHypertable = await HypertableProbe.IsHypertableAsync(context, "ordered_ops"); + bool compressionEnabled = await HypertableProbe.IsCompressionEnabledAsync(context, "ordered_ops"); Assert.True(isHypertable); Assert.True(compressionEnabled); @@ -1072,7 +469,7 @@ public async Task Should_Enable_Compression_Before_ChunkSkipping() await using CompressionChunkSkippingContext context = new(_connectionString!); await CreateDatabaseViaMigrationAsync(context); - bool compressionEnabled = await IsCompressionEnabledAsync(context, "compression_chunk_skip"); + bool compressionEnabled = await HypertableProbe.IsCompressionEnabledAsync(context, "compression_chunk_skip"); List skipColumns = await GetChunkSkipColumnsAsync(context, "compression_chunk_skip"); Assert.True(compressionEnabled); diff --git a/tests/Eftdb.Tests/Integration/HypertableIntegrationTestsBase.cs b/tests/Eftdb.Tests/Integration/HypertableIntegrationTestsBase.cs new file mode 100644 index 0000000..5a8b83b --- /dev/null +++ b/tests/Eftdb.Tests/Integration/HypertableIntegrationTestsBase.cs @@ -0,0 +1,546 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +/// +/// License-neutral hypertable integration facts (plain creation, chunk interval, dimensions, +/// data operations) that hold on both the Community and Apache editions. Concrete subclasses +/// pin the container image through . +/// +public abstract class HypertableIntegrationTestsBase : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + protected string? _connectionString; + + protected abstract string Image { get; } + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder(Image) + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Helper Methods + + protected static async Task> GetDimensionsAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT column_name, num_partitions + FROM timescaledb_information.dimensions + WHERE hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + + List dimensions = []; + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + dimensions.Add(new DimensionInfo + { + ColumnName = reader.GetString(0), + NumberPartitions = reader.IsDBNull(1) ? null : reader.GetInt32(1) + }); + } + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return dimensions; + } + + protected static async Task GetChunkCountAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) + FROM timescaledb_information.chunks + WHERE hypertable_schema = 'public' AND hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is long longResult ? (int)longResult : + result is int intResult ? intResult : 0; + } + + protected class DimensionInfo + { + public string ColumnName { get; set; } = string.Empty; + public int? NumberPartitions { get; set; } + } + + #endregion + + #region Should_Create_Minimal_Hypertable + + private class MinimalHypertableMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalHypertableContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Create_Minimal_Hypertable() + { + await using MinimalHypertableContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + DateTime timestamp = new(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc); + double value = 100.5; + await context.Database.ExecuteSqlInterpolatedAsync( + $"INSERT INTO \"Metrics\" (\"Timestamp\", \"Value\") VALUES ({timestamp}, {value})", TestContext.Current.CancellationToken); + + bool isHypertable = await HypertableProbe.IsHypertableAsync(context, "Metrics"); + Assert.True(isHypertable); + + List metrics = await context.Metrics.ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(100.5, Assert.Single(metrics).Value); + } + + #endregion + + #region Should_Create_Hypertable_With_CustomChunkInterval + + private class CustomChunkIntervalData + { + public DateTime Timestamp { get; set; } + public int DeviceId { get; set; } + public double Temperature { get; set; } + } + + private class CustomChunkIntervalContext(string connectionString) : DbContext + { + public DbSet SensorData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("sensor_data"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_CustomChunkInterval() + { + await using CustomChunkIntervalContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + string chunkInterval = await HypertableProbe.GetChunkIntervalAsync(context, "sensor_data"); + + Assert.Contains("1 day", chunkInterval); + } + + #endregion + + #region Should_Create_Hypertable_With_HashDimension + + private class HashDimensionData + { + public DateTime Timestamp { get; set; } + public int LocationId { get; set; } + public double Value { get; set; } + } + + private class HashDimensionContext(string connectionString) : DbContext + { + public DbSet PartitionedData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("partitioned_data"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateHash("LocationId", 4)); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_HashDimension() + { + await using HashDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List dimensions = await GetDimensionsAsync(context, "partitioned_data"); + + Assert.Equal(2, dimensions.Count); + + DimensionInfo? hashDimension = dimensions.FirstOrDefault(d => d.ColumnName == "LocationId"); + Assert.NotNull(hashDimension); + Assert.Equal(4, hashDimension.NumberPartitions); + } + + #endregion + + #region Should_Create_Hypertable_With_RangeDimension + + private class RangeDimensionData + { + public DateTime Timestamp { get; set; } + public DateTime ProcessedTime { get; set; } + public double Value { get; set; } + } + + private class RangeDimensionContext(string connectionString) : DbContext + { + public DbSet MultiTimeData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("multi_time_data"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("ProcessedTime", "7 days")); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_RangeDimension() + { + await using RangeDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List dimensions = await GetDimensionsAsync(context, "multi_time_data"); + + Assert.Equal(2, dimensions.Count); + + DimensionInfo? rangeDimension = dimensions.FirstOrDefault(d => d.ColumnName == "ProcessedTime"); + Assert.NotNull(rangeDimension); + } + + #endregion + + #region Should_Create_Hypertable_With_RangeDimension_IntegerInterval + + private class IntegerRangeDimensionData + { + public DateTime Timestamp { get; set; } + public int SequenceNumber { get; set; } + public double Value { get; set; } + } + + private class IntegerRangeDimensionContext(string connectionString) : DbContext + { + public DbSet SequencedData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("sequenced_data"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .HasDimension(Dimension.CreateRange("SequenceNumber", "10000")); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_RangeDimension_IntegerInterval() + { + await using IntegerRangeDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List dimensions = await GetDimensionsAsync(context, "sequenced_data"); + + Assert.Equal(2, dimensions.Count); + + DimensionInfo? rangeDimension = dimensions.FirstOrDefault(d => d.ColumnName == "SequenceNumber"); + Assert.NotNull(rangeDimension); + Assert.Null(rangeDimension.NumberPartitions); + } + + #endregion + + #region Should_Create_Hypertable_With_RangeDimension_TimeInterval + + private class TimeRangeDimensionData + { + public DateTime EventTime { get; set; } + public DateTime ProcessingTime { get; set; } + public string EventType { get; set; } = string.Empty; + } + + private class TimeRangeDimensionContext(string connectionString) : DbContext + { + public DbSet DualTimeData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("dual_time_events"); + entity.HasNoKey(); + entity.IsHypertable(x => x.EventTime) + .HasDimension(Dimension.CreateRange("ProcessingTime", "2 hours")); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_RangeDimension_TimeInterval() + { + await using TimeRangeDimensionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List dimensions = await GetDimensionsAsync(context, "dual_time_events"); + + Assert.Equal(2, dimensions.Count); + + DimensionInfo? rangeDimension = dimensions.FirstOrDefault(d => d.ColumnName == "ProcessingTime"); + Assert.NotNull(rangeDimension); + Assert.Null(rangeDimension.NumberPartitions); + } + + #endregion + + #region Should_Create_Hypertable_With_MultipleDimensions + + private class MultipleDimensionsData + { + public DateTime EventTime { get; set; } + public int DeviceId { get; set; } + public string Region { get; set; } = string.Empty; + public string EventType { get; set; } = string.Empty; + } + + private class MultipleDimensionsContext(string connectionString) : DbContext + { + public DbSet EventData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("distributed_events"); + entity.HasNoKey(); + entity.IsHypertable(x => x.EventTime) + .HasDimension(Dimension.CreateHash("DeviceId", 4)) + .HasDimension(Dimension.CreateHash("Region", 2)); + }); + } + } + + [Fact] + public async Task Should_Create_Hypertable_With_MultipleDimensions() + { + await using MultipleDimensionsContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + List dimensions = await GetDimensionsAsync(context, "distributed_events"); + + Assert.Equal(3, dimensions.Count); + Assert.Contains(dimensions, d => d.ColumnName == "EventTime"); + Assert.Contains(dimensions, d => d.ColumnName == "DeviceId"); + Assert.Contains(dimensions, d => d.ColumnName == "Region"); + } + + #endregion + + #region Should_Insert_And_Query_Data_From_Hypertable + + private class IoTDataRecord + { + public DateTime Timestamp { get; set; } + public string DeviceId { get; set; } = string.Empty; + public double Temperature { get; set; } + public double Humidity { get; set; } + } + + private class DataOperationsContext(string connectionString) : DbContext + { + public DbSet IoTData => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("IoTData"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } + } + + [Fact] + public async Task Should_Insert_And_Query_Data_From_Hypertable() + { + await using DataOperationsContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + await context.Database.ExecuteSqlInterpolatedAsync($@" + INSERT INTO ""IoTData"" (""Timestamp"", ""DeviceId"", ""Temperature"", ""Humidity"") + VALUES + ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {20.5}, {45.0}), + ({new DateTime(2025, 1, 1, 11, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {21.0}, {46.0}), + ({new DateTime(2025, 1, 2, 10, 0, 0, DateTimeKind.Utc)}, {"device_2"}, {19.5}, {50.0})", TestContext.Current.CancellationToken); + + List data = await context.IoTData.ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(3, data.Count); + + List device1Data = await context.IoTData.Where(d => d.DeviceId == "device_1").ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, device1Data.Count); + + int chunkCount = await GetChunkCountAsync(context, "IoTData"); + Assert.True(chunkCount >= 1); + } + + #endregion + + #region Should_Handle_LargeDataset + + private class PerformanceTestData + { + public DateTime Timestamp { get; set; } + public int SensorId { get; set; } + public double Value { get; set; } + } + + private class PerformanceTestContext(string connectionString) : DbContext + { + public DbSet PerformanceTest => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("PerformanceTest"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 hour"); + }); + } + } + + [Fact] + public async Task Should_Handle_LargeDataset() + { + await using PerformanceTestContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + DateTime baseTime = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + List valueRows = []; + + for (int i = 0; i < 100; i++) + { + DateTime timestamp = baseTime.AddMinutes(i); + valueRows.Add(FormattableString.Invariant($"('{timestamp:yyyy-MM-dd HH:mm:ss}+00', {i % 10}, {15.0 + i * 0.1})")); + } + + string sql = $@"INSERT INTO ""PerformanceTest"" (""Timestamp"", ""SensorId"", ""Value"") + VALUES {string.Join(", ", valueRows)}"; + await context.Database.ExecuteSqlRawAsync(sql, [], TestContext.Current.CancellationToken); + + int count = await context.PerformanceTest.CountAsync(TestContext.Current.CancellationToken); + Assert.Equal(100, count); + + List sensor0Data = await context.PerformanceTest + .Where(d => d.SensorId == 0) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(10, sensor0Data.Count); + + int chunkCount = await GetChunkCountAsync(context, "PerformanceTest"); + Assert.True(chunkCount >= 1); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs index 0cb7348..0328239 100644 --- a/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations.Operations; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -16,7 +17,7 @@ public class HypertableMigrateDataIntegrationTests : MigrationTestBase, IAsyncLi public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/HypertableMixedCaseOrderbyScaffoldingTests.cs b/tests/Eftdb.Tests/Integration/HypertableMixedCaseOrderbyScaffoldingTests.cs index 266c1f5..65f4a7a 100644 --- a/tests/Eftdb.Tests/Integration/HypertableMixedCaseOrderbyScaffoldingTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableMixedCaseOrderbyScaffoldingTests.cs @@ -10,6 +10,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Diagnostics.Internal; using System.Diagnostics; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; #pragma warning disable EF1001 // Internal EF Core API usage required for testing scaffolding infrastructure @@ -22,7 +23,7 @@ public class HypertableMixedCaseOrderbyScaffoldingTests : MigrationTestBase, IAs public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs index 6579808..dcb6db4 100644 --- a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -15,7 +16,7 @@ public class HypertableScaffoldingExtractorTests : MigrationTestBase, IAsyncLife public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs b/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs index 38cff22..9491a4f 100644 --- a/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs +++ b/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs @@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore.Migrations.Operations; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -17,7 +18,7 @@ public class MigrationLifecycleTests : MigrationTestBase, IAsyncLifetime public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs index a41b4d9..8567b06 100644 --- a/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs @@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore.Migrations.Operations; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -17,7 +18,7 @@ public class ReorderPolicyIntegrationTests : IAsyncLifetime public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs index 922544b..1ad447c 100644 --- a/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -15,7 +16,7 @@ public class ReorderPolicyScaffoldingExtractorTests : MigrationTestBase, IAsyncL public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs index 86fcd69..ffcafba 100644 --- a/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -13,7 +14,7 @@ public class RetentionPolicyIntegrationTests : MigrationTestBase, IAsyncLifetime public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs index 77c0083..dfb1f4e 100644 --- a/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using Npgsql; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -16,7 +17,7 @@ public class RetentionPolicyScaffoldingExtractorTests : MigrationTestBase, IAsyn public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs b/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs index 2f56edf..ac8b200 100644 --- a/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs +++ b/tests/Eftdb.Tests/Integration/ScaffoldRoundTripTests.cs @@ -26,6 +26,7 @@ using Npgsql; using System.Reflection; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -53,7 +54,7 @@ public sealed class ScaffoldRoundTripTests : MigrationTestBase, IAsyncLifetime public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs b/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs index 25da2fe..1d74d70 100644 --- a/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs @@ -2,6 +2,7 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Query; using Microsoft.EntityFrameworkCore; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; @@ -12,7 +13,7 @@ public class TimeBucketIntegrationTests : MigrationTestBase, IAsyncLifetime public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs index 9097964..9619c94 100644 --- a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs +++ b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs @@ -13,6 +13,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Diagnostics.Internal; using System.Diagnostics; using Testcontainers.PostgreSql; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; #pragma warning disable EF1001 // Internal EF Core API usage required for testing scaffolding infrastructure @@ -29,7 +30,7 @@ public class TimescaleDatabaseModelFactoryTests : MigrationTestBase, IAsyncLifet public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + _container = new PostgreSqlBuilder(TimescaleImages.Community) .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/tests/Eftdb.Tests/Utils/HypertableProbe.cs b/tests/Eftdb.Tests/Utils/HypertableProbe.cs new file mode 100644 index 0000000..5b85b8a --- /dev/null +++ b/tests/Eftdb.Tests/Utils/HypertableProbe.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils +{ + /// + /// Shared TimescaleDB catalog probes used by hypertable integration tests to inspect + /// server-side state (hypertable registration, chunk interval, compression) after a migration. + /// + internal static class HypertableProbe + { + public static async Task IsHypertableAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.hypertables + WHERE hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool boolResult && boolResult; + } + + public static async Task GetChunkIntervalAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT time_interval::text + FROM timescaledb_information.dimensions + WHERE hypertable_name = @tableName + AND dimension_type = 'Time' + LIMIT 1; + "; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result?.ToString() ?? string.Empty; + } + + public static async Task IsCompressionEnabledAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT compression_enabled + FROM timescaledb_information.hypertables + WHERE hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool boolResult && boolResult; + } + } +} diff --git a/tests/Eftdb.Tests/Utils/TimescaleImages.cs b/tests/Eftdb.Tests/Utils/TimescaleImages.cs new file mode 100644 index 0000000..ab7ed24 --- /dev/null +++ b/tests/Eftdb.Tests/Utils/TimescaleImages.cs @@ -0,0 +1,11 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils +{ + /// + /// Centralizes the TimescaleDB container images used by Testcontainers-backed tests. + /// + internal static class TimescaleImages + { + public const string Community = "timescale/timescaledb:latest-pg17"; + public const string Apache = "timescale/timescaledb:latest-pg17-oss"; + } +}