diff --git a/docker-compose.yml b/docker-compose.yml index 2af2ba8..d83a5ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,5 +10,18 @@ services: volumes: - mysql:/var/lib/mysql + postgres: + image: postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: secret + POSTGRES_DB: persistedcachedb + ports: + - "5432:5432" + volumes: + - postgres:/var/lib/postgresql/data + volumes: - mysql: \ No newline at end of file + mysql: + postgres: \ No newline at end of file diff --git a/examples/PersistedCache.Example/PersistedCache.Example.csproj b/examples/PersistedCache.Example/PersistedCache.Example.csproj index 2d92b76..9770386 100644 --- a/examples/PersistedCache.Example/PersistedCache.Example.csproj +++ b/examples/PersistedCache.Example/PersistedCache.Example.csproj @@ -13,6 +13,7 @@ + diff --git a/examples/PersistedCache.Example/Program.cs b/examples/PersistedCache.Example/Program.cs index dfc3f19..7aba6b7 100644 --- a/examples/PersistedCache.Example/Program.cs +++ b/examples/PersistedCache.Example/Program.cs @@ -1,5 +1,5 @@ using PersistedCache; -using PersistedCache.MySql; +using PersistedCache.PostgreSql; var builder = WebApplication.CreateBuilder(args); @@ -7,7 +7,12 @@ // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddMySqlPersistedCache(builder.Configuration.GetConnectionString("MySql")!, options => +// builder.Services.AddMySqlPersistedCache(builder.Configuration.GetConnectionString("MySql")!, options => +// { +// options.TableName = "persisted_cache"; +// }); + +builder.Services.AddPostgreSqlPersistedCache(builder.Configuration.GetConnectionString("PostgreSql")!, options => { options.TableName = "persisted_cache"; }); @@ -28,7 +33,7 @@ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; -app.MapGet("/weatherforecast", (IPersistedCache cache) => cache.Get("weather_forecast")) +app.MapGet("/weatherforecast", (IPersistedCache cache) => cache.Get("weather_forecast")) .WithName("GetWeatherForecast") .WithOpenApi(); diff --git a/examples/PersistedCache.Example/appsettings.Development.json b/examples/PersistedCache.Example/appsettings.Development.json index 2a45da7..075f576 100644 --- a/examples/PersistedCache.Example/appsettings.Development.json +++ b/examples/PersistedCache.Example/appsettings.Development.json @@ -6,6 +6,7 @@ } }, "ConnectionStrings": { - "MySql": "Server=localhost;Port=3306;Database=persistedcachedb;Uid=root;Pwd=root;" + "MySql": "Server=localhost;Port=3306;Database=persistedcachedb;Uid=root;Pwd=root;", + "PostgreSql": "Host=localhost;Port=5432;Database=persistedcachedb;User ID=postgres;Password=secret;" } } diff --git a/src/PersistedCache.MySql/MySqlCacheDriver.cs b/src/PersistedCache.MySql/MySqlCacheDriver.cs index 53c2cfd..03ac641 100644 --- a/src/PersistedCache.MySql/MySqlCacheDriver.cs +++ b/src/PersistedCache.MySql/MySqlCacheDriver.cs @@ -16,7 +16,7 @@ public MySqlCacheDriver(SqlPersistedCacheOptions options) public string SetupStorageScript => /*lang=MySQL*/ $""" - CREATE TABLE IF NOT EXISTS {_options.TableName} ( + CREATE TABLE IF NOT EXISTS `{_options.TableName}` ( `key` VARCHAR(255) NOT NULL PRIMARY KEY, `value` JSON NOT NULL, `expiry` DATETIME(6) NOT NULL, @@ -31,7 +31,7 @@ public MySqlCacheDriver(SqlPersistedCacheOptions options) /*lang=MySQL*/ $""" SELECT `value` - FROM {_options.TableName} + FROM `{_options.TableName}` WHERE `key` = @Key AND `expiry` > @Expiry; """; @@ -39,7 +39,7 @@ public MySqlCacheDriver(SqlPersistedCacheOptions options) public string SetScript => /*lang=MySQL*/ $""" - INSERT INTO {_options.TableName} (`key`, `value`, `expiry`) + INSERT INTO `{_options.TableName}` (`key`, `value`, `expiry`) VALUES (@Key, @Value, @Expiry) ON DUPLICATE KEY UPDATE `value` = @value, `expiry` = @expiry; """; @@ -47,7 +47,7 @@ public MySqlCacheDriver(SqlPersistedCacheOptions options) public string ForgetScript => /*lang=MySQL*/ $""" - DELETE FROM {_options.TableName} + DELETE FROM `{_options.TableName}` WHERE `key` = @Key; """; @@ -58,14 +58,14 @@ DELETE FROM {_options.TableName} public string FlushPatternScript => /*lang=MySQL*/ $""" - DELETE FROM {_options.TableName} + DELETE FROM `{_options.TableName}` WHERE `key` LIKE @Pattern; """; public string PurgeScript => /*lang=MySQL*/ $""" - DELETE FROM {_options.TableName} + DELETE FROM `{_options.TableName}` WHERE `expiry` <= @Expiry; """; diff --git a/src/PersistedCache.MySql/PersistedCache.MySql.csproj b/src/PersistedCache.MySql/PersistedCache.MySql.csproj index c150253..4ad2375 100644 --- a/src/PersistedCache.MySql/PersistedCache.MySql.csproj +++ b/src/PersistedCache.MySql/PersistedCache.MySql.csproj @@ -26,10 +26,14 @@ true + + + + + - diff --git a/src/PersistedCache.MySql/packages.lock.json b/src/PersistedCache.MySql/packages.lock.json index b9f0e41..68fb316 100644 --- a/src/PersistedCache.MySql/packages.lock.json +++ b/src/PersistedCache.MySql/packages.lock.json @@ -32,17 +32,6 @@ "Microsoft.NETCore.Platforms": "1.1.0" } }, - "PersistedCache": { - "type": "Direct", - "requested": "[0.0.1, )", - "resolved": "0.0.1", - "contentHash": "rzdPkRiuasUHk4Ft9Bw9Q0uGBuJgNt3gq52qn+P8k4Mx3CDbnnE5MZY7FQzKyNh4nsXEUlTX+ZJd42ncQGMw2A==", - "dependencies": { - "Dapper": "2.1.35", - "Microsoft.Extensions.Hosting.Abstractions": "6.0.0", - "System.Text.Json": "6.0.9" - } - }, "BouncyCastle.Cryptography": { "type": "Transitive", "resolved": "2.3.1", @@ -373,6 +362,14 @@ "Microsoft.Bcl.AsyncInterfaces": "5.0.0", "System.Memory": "4.5.5" } + }, + "persistedcache": { + "type": "Project", + "dependencies": { + "Dapper": "[2.1.35, )", + "Microsoft.Extensions.Hosting.Abstractions": "[6.0.0, )", + "System.Text.Json": "[6.0.9, )" + } } } } diff --git a/src/PersistedCache.PostgreSql/PersistedCache.PostgreSql.csproj b/src/PersistedCache.PostgreSql/PersistedCache.PostgreSql.csproj index e545614..637fb64 100644 --- a/src/PersistedCache.PostgreSql/PersistedCache.PostgreSql.csproj +++ b/src/PersistedCache.PostgreSql/PersistedCache.PostgreSql.csproj @@ -27,9 +27,13 @@ true + + + + - + diff --git a/src/PersistedCache.PostgreSql/PostgreSqlCacheDriver.cs b/src/PersistedCache.PostgreSql/PostgreSqlCacheDriver.cs index 19e23a6..3ab7c12 100644 --- a/src/PersistedCache.PostgreSql/PostgreSqlCacheDriver.cs +++ b/src/PersistedCache.PostgreSql/PostgreSqlCacheDriver.cs @@ -6,11 +6,11 @@ namespace PersistedCache.PostgreSql; public class PostgreSqlCacheDriver : ISqlCacheDriver { - private readonly SqlPersistedCacheOptions _options; + private readonly PostgreSqlPersistedCacheOptions _options; - public PostgreSqlCacheDriver(SqlPersistedCacheOptions options) + public PostgreSqlCacheDriver(ISqlPersistedCacheOptions options) { - _options = options; + _options = (PostgreSqlPersistedCacheOptions)options; } public string SetupStorageScript => @@ -19,13 +19,13 @@ public PostgreSqlCacheDriver(SqlPersistedCacheOptions options) DO $$ BEGIN - CREATE TABLE IF NOT EXISTS "{_options.TableName}" ( + CREATE TABLE IF NOT EXISTS "{_options.Schema}"."{_options.TableName}" ( "key" VARCHAR(255) NOT NULL PRIMARY KEY, "value" JSONB NOT NULL, "expiry" TIMESTAMP(6) WITH TIME ZONE NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_key_expiry ON "{_options.TableName}" ("key", "expiry"); - CREATE INDEX IF NOT EXISTS idx_expiry ON "{_options.TableName}" ("expiry"); + CREATE INDEX IF NOT EXISTS idx_key_expiry ON "{_options.Schema}"."{_options.TableName}" ("key", "expiry"); + CREATE INDEX IF NOT EXISTS idx_expiry ON "{_options.Schema}"."{_options.TableName}" ("expiry"); END; $$ """; @@ -34,7 +34,7 @@ public PostgreSqlCacheDriver(SqlPersistedCacheOptions options) /*lang=PostgreSQL*/ $""" SELECT "value" - FROM "{_options.TableName}" + FROM "{_options.Schema}"."{_options.TableName}" WHERE "key" = @Key AND "expiry" > @Expiry; """; @@ -42,34 +42,34 @@ public PostgreSqlCacheDriver(SqlPersistedCacheOptions options) public string SetScript => /*lang=PostgreSQL*/ $""" - INSERT INTO "{_options.TableName}" ("key", "value", "expiry") - VALUES (@Key, to_json(@Value), @Expiry) + INSERT INTO "{_options.Schema}"."{_options.TableName}" ("key", "value", "expiry") + VALUES (@Key, cast(@Value as jsonb), @Expiry) ON CONFLICT ("key") DO UPDATE - SET "value" = to_json(@Value), "expiry" = @Expiry; + SET "value" = cast(@Value as jsonb), "expiry" = @Expiry; """; public string ForgetScript => /*lang=PostgreSQL*/ $""" - DELETE FROM "{_options.TableName}" + DELETE FROM "{_options.Schema}"."{_options.TableName}" WHERE "key" = @Key; """; public string FlushScript => /*lang=PostgreSQL*/ - $"""DELETE FROM "{_options.TableName}";"""; + $"""DELETE FROM "{_options.Schema}"."{_options.TableName}";"""; public string FlushPatternScript => /*lang=PostgreSQL*/ $""" - DELETE FROM "{_options.TableName}" + DELETE FROM "{_options.Schema}"."{_options.TableName}" WHERE "key" ILIKE @Pattern; """; public string PurgeScript => /*lang=PostgreSQL*/ $""" - DELETE FROM "{_options.TableName}" + DELETE FROM "{_options.Schema}"."{_options.TableName}" WHERE "expiry" <= @Expiry; """; diff --git a/src/PersistedCache.PostgreSql/PostgreSqlPersistedCacheExtensions.cs b/src/PersistedCache.PostgreSql/PostgreSqlPersistedCacheExtensions.cs index 73845ec..948cfd4 100644 --- a/src/PersistedCache.PostgreSql/PostgreSqlPersistedCacheExtensions.cs +++ b/src/PersistedCache.PostgreSql/PostgreSqlPersistedCacheExtensions.cs @@ -13,7 +13,7 @@ public static class PostgreSqlPersistedCacheExtensions public static IServiceCollection AddPostgreSqlPersistedCache(this IServiceCollection services, string connectionString) { - return services.AddPostgreSqlPersistedCache(new SqlPersistedCacheOptions(connectionString)); + return services.AddPostgreSqlPersistedCache(new PostgreSqlPersistedCacheOptions(connectionString)); } /// @@ -23,9 +23,9 @@ public static IServiceCollection AddPostgreSqlPersistedCache(this IServiceCollec /// Connection string to the PostgreSQL database. /// Action to configure the cache options. public static IServiceCollection AddPostgreSqlPersistedCache(this IServiceCollection services, - string connectionString, Action configure) + string connectionString, Action configure) { - var options = new SqlPersistedCacheOptions(connectionString); + var options = new PostgreSqlPersistedCacheOptions(connectionString); configure(options); return services.AddPostgreSqlPersistedCache(options); @@ -37,9 +37,9 @@ public static IServiceCollection AddPostgreSqlPersistedCache(this IServiceCollec /// Service collection. /// Options for the cache. public static IServiceCollection AddPostgreSqlPersistedCache(this IServiceCollection services, - SqlPersistedCacheOptions options) + PostgreSqlPersistedCacheOptions options) { - services.AddSingleton(options); + services.AddSingleton(options); services.AddSingleton(); services.AddSingleton(); diff --git a/src/PersistedCache.PostgreSql/PostgreSqlPersistedCacheOptions.cs b/src/PersistedCache.PostgreSql/PostgreSqlPersistedCacheOptions.cs new file mode 100644 index 0000000..46a248d --- /dev/null +++ b/src/PersistedCache.PostgreSql/PostgreSqlPersistedCacheOptions.cs @@ -0,0 +1,15 @@ +using PersistedCache.Sql; + +namespace PersistedCache.PostgreSql; + +public class PostgreSqlPersistedCacheOptions : SqlPersistedCacheOptions +{ + public PostgreSqlPersistedCacheOptions(string connectionString) : base(connectionString) + { + } + + /// + /// The search path/schema to use for the cache (default: public). + /// + public string Schema { get; set; } = "public"; +} \ No newline at end of file diff --git a/src/PersistedCache.PostgreSql/packages.lock.json b/src/PersistedCache.PostgreSql/packages.lock.json index 0635faa..de408d5 100644 --- a/src/PersistedCache.PostgreSql/packages.lock.json +++ b/src/PersistedCache.PostgreSql/packages.lock.json @@ -25,17 +25,6 @@ "System.Threading.Channels": "6.0.0" } }, - "PersistedCache": { - "type": "Direct", - "requested": "[0.0.1, )", - "resolved": "0.0.1", - "contentHash": "rzdPkRiuasUHk4Ft9Bw9Q0uGBuJgNt3gq52qn+P8k4Mx3CDbnnE5MZY7FQzKyNh4nsXEUlTX+ZJd42ncQGMw2A==", - "dependencies": { - "Dapper": "2.1.35", - "Microsoft.Extensions.Hosting.Abstractions": "6.0.0", - "System.Text.Json": "6.0.9" - } - }, "Dapper": { "type": "Transitive", "resolved": "2.1.35", @@ -204,6 +193,14 @@ "dependencies": { "System.Runtime.CompilerServices.Unsafe": "4.5.3" } + }, + "persistedcache": { + "type": "Project", + "dependencies": { + "Dapper": "[2.1.35, )", + "Microsoft.Extensions.Hosting.Abstractions": "[6.0.0, )", + "System.Text.Json": "[6.0.9, )" + } } } } diff --git a/src/PersistedCache/Sql/ISqlPersistedCacheOptions.cs b/src/PersistedCache/Sql/ISqlPersistedCacheOptions.cs new file mode 100644 index 0000000..3b7ac51 --- /dev/null +++ b/src/PersistedCache/Sql/ISqlPersistedCacheOptions.cs @@ -0,0 +1,37 @@ +using System.Text.Json; + +namespace PersistedCache.Sql; + +public interface ISqlPersistedCacheOptions +{ + /// + /// The name of the table to use for the cache. + /// + /// Thrown when the table name is null or empty. + string TableName { get; set; } + + /// + /// Whether to create the table if it does not exist. + /// + bool CreateTableIfNotExists { get; set; } + + /// + /// The connection string to use for the cache. + /// + string ConnectionString { get; } + + /// + /// Whether to purge expired entries. + /// + bool PurgeExpiredEntries { get; set; } + + /// + /// The interval at which to purge expired entries. + /// + TimeSpan PurgeInterval { get; set; } + + /// + /// The options to use for JSON serialization. + /// + JsonSerializerOptions JsonOptions { get; set; } +} \ No newline at end of file diff --git a/src/PersistedCache/Sql/SqlPersistedCache.cs b/src/PersistedCache/Sql/SqlPersistedCache.cs index 40e63b7..09a89ff 100644 --- a/src/PersistedCache/Sql/SqlPersistedCache.cs +++ b/src/PersistedCache/Sql/SqlPersistedCache.cs @@ -6,10 +6,10 @@ namespace PersistedCache.Sql public class SqlPersistedCache : IPersistedCache { private readonly ISqlCacheDriver _driver; - private readonly SqlPersistedCacheOptions _options; + private readonly ISqlPersistedCacheOptions _options; private readonly SqlConnectionFactory _connectionFactory; - public SqlPersistedCache(ISqlCacheDriver driver, SqlPersistedCacheOptions options) + public SqlPersistedCache(ISqlCacheDriver driver, ISqlPersistedCacheOptions options) { _driver = driver; _options = options; diff --git a/src/PersistedCache/Sql/SqlPersistedCacheOptions.cs b/src/PersistedCache/Sql/SqlPersistedCacheOptions.cs index 12120d3..5af4efc 100644 --- a/src/PersistedCache/Sql/SqlPersistedCacheOptions.cs +++ b/src/PersistedCache/Sql/SqlPersistedCacheOptions.cs @@ -2,7 +2,7 @@ namespace PersistedCache.Sql { - public class SqlPersistedCacheOptions + public class SqlPersistedCacheOptions : ISqlPersistedCacheOptions { private TimeSpan _purgeInterval = TimeSpan.FromHours(24); private string _tableName = "persisted_cache"; diff --git a/src/PersistedCache/Sql/SqlPurgeCacheBackgroundJob.cs b/src/PersistedCache/Sql/SqlPurgeCacheBackgroundJob.cs index ea2f291..d7ca167 100644 --- a/src/PersistedCache/Sql/SqlPurgeCacheBackgroundJob.cs +++ b/src/PersistedCache/Sql/SqlPurgeCacheBackgroundJob.cs @@ -6,9 +6,9 @@ public class SqlPurgeCacheBackgroundJob : IHostedService, IDisposable { private Timer? _timer; private readonly IPersistedCache _cache; - private readonly SqlPersistedCacheOptions _options; + private readonly ISqlPersistedCacheOptions _options; - public SqlPurgeCacheBackgroundJob(IPersistedCache cache, SqlPersistedCacheOptions options) + public SqlPurgeCacheBackgroundJob(IPersistedCache cache, ISqlPersistedCacheOptions options) { _cache = cache; _options = options; diff --git a/tests/PersistedCache.Tests/Common/BaseDatabaseFixture.cs b/tests/PersistedCache.Tests/Common/BaseDatabaseFixture.cs index 5dca927..aa5a099 100644 --- a/tests/PersistedCache.Tests/Common/BaseDatabaseFixture.cs +++ b/tests/PersistedCache.Tests/Common/BaseDatabaseFixture.cs @@ -1,5 +1,6 @@ using Dapper; using DotNet.Testcontainers.Containers; +using PersistedCache.PostgreSql; using PersistedCache.Sql; namespace PersistedCache.Tests.Common; @@ -9,6 +10,9 @@ public abstract class BaseDatabaseFixture : IAsyncLifetime where TDrive public IPersistedCache PersistedCache { get; private set; } = null!; protected DockerContainer Container = null!; + + protected abstract char LeftEscapeCharacter { get; } + protected abstract char RightEscapeCharacter { get; } private ISqlCacheDriver _driver = null!; @@ -16,12 +20,21 @@ public async Task InitializeAsync() { await Container.StartAsync(); - var options = new SqlPersistedCacheOptions((Container as IDatabaseContainer)!.GetConnectionString()) + ISqlPersistedCacheOptions options = new SqlPersistedCacheOptions((Container as IDatabaseContainer)!.GetConnectionString()) { CreateTableIfNotExists = false, TableName = TestConstants.TableName, }; + if (typeof(TDriver) == typeof(PostgreSqlCacheDriver)) + { + options = new PostgreSqlPersistedCacheOptions((Container as IDatabaseContainer)!.GetConnectionString()) + { + CreateTableIfNotExists = false, + TableName = TestConstants.TableName, + }; + } + var driver = (TDriver)Activator.CreateInstance(typeof(TDriver), options)!; SetupStorage(driver); @@ -37,6 +50,8 @@ public async Task DisposeAsync() public IEnumerable ExecuteSql(string sql) { + sql = sql.Replace("<|", $"{LeftEscapeCharacter}") + .Replace("|>", $"{RightEscapeCharacter}"); using var connection = _driver.CreateConnection(); var result = connection.Query(sql); return result; diff --git a/tests/PersistedCache.Tests/Fixtures/MySqlFixture.cs b/tests/PersistedCache.Tests/Fixtures/MySqlFixture.cs index 4cb666f..81cbac7 100644 --- a/tests/PersistedCache.Tests/Fixtures/MySqlFixture.cs +++ b/tests/PersistedCache.Tests/Fixtures/MySqlFixture.cs @@ -16,4 +16,7 @@ public MySqlFixture() .WithPassword("root") .Build(); } + + protected override char LeftEscapeCharacter => '`'; + protected override char RightEscapeCharacter => '`'; } \ No newline at end of file diff --git a/tests/PersistedCache.Tests/Fixtures/PostgreSqlFixture.cs b/tests/PersistedCache.Tests/Fixtures/PostgreSqlFixture.cs index 35184e7..25af3f3 100644 --- a/tests/PersistedCache.Tests/Fixtures/PostgreSqlFixture.cs +++ b/tests/PersistedCache.Tests/Fixtures/PostgreSqlFixture.cs @@ -15,4 +15,7 @@ public PostgreSqlFixture() .WithPassword("postgres") .Build(); } + + protected override char LeftEscapeCharacter => '"'; + protected override char RightEscapeCharacter => '"'; } \ No newline at end of file diff --git a/tests/PersistedCache.Tests/PullTests.cs b/tests/PersistedCache.Tests/PullTests.cs index 1059888..93d35dc 100644 --- a/tests/PersistedCache.Tests/PullTests.cs +++ b/tests/PersistedCache.Tests/PullTests.cs @@ -63,7 +63,7 @@ public void Pull_WithExpiredKey_ReturnsNullButRemovesKey() // Assert result.Should().BeNull(); - var resultAfterPull = _executeSql($"SELECT * FROM {TestConstants.TableName} WHERE `key` = '{key}'"); + var resultAfterPull = _executeSql($"SELECT * FROM <|{TestConstants.TableName}|> WHERE <|key|> = '{key}'"); resultAfterPull.Should().BeEmpty(); } diff --git a/tests/PersistedCache.Tests/PurgeTests.cs b/tests/PersistedCache.Tests/PurgeTests.cs index 4d89a61..10c69c6 100644 --- a/tests/PersistedCache.Tests/PurgeTests.cs +++ b/tests/PersistedCache.Tests/PurgeTests.cs @@ -34,7 +34,7 @@ public void Purge_RemovesExpiredKeys() _cache.Purge(); // Assert - var result = _executeSql($"SELECT * FROM {TestConstants.TableName}"); + var result = _executeSql($"SELECT * FROM <|{TestConstants.TableName}|>"); result.Should().HaveCount(2); } @@ -49,7 +49,7 @@ public void Purge_WhenNoExpiredKeys_RemovesNothing() _cache.Purge(); // Assert - var result = _executeSql($"SELECT * FROM {TestConstants.TableName}"); + var result = _executeSql($"SELECT * FROM <|{TestConstants.TableName}|>"); result.Should().HaveCount(2); } }