├── .editorconfig ├── .github └── workflows │ └── dotnet.yml ├── .gitignore ├── LICENSE ├── README.md ├── SqliteCache.AspNetCore ├── SqliteCache.AspNetCore.csproj └── SqliteCacheServiceCollectionExtensions.cs ├── SqliteCache.Tests ├── BasicTests.cs ├── BulkInsertTests.cs ├── ClearCacheTests.cs ├── DependencyInjection.cs ├── SqliteCache.Tests.csproj └── TestLogger.cs ├── SqliteCache.sln ├── SqliteCache ├── DbCommandPool.cs ├── DbCommands.cs ├── NeoSmart.Caching.Sqlite.snk ├── NullLogger.cs ├── Resources.Designer.cs ├── Resources.resx ├── SqliteCache.cs ├── SqliteCache.csproj ├── SqliteCacheOptions.cs └── SqliteCacheServiceCollectionExtensions.cs └── publish.fish /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{cs,vb}] 2 | dotnet_style_operator_placement_when_wrapping = beginning_of_line 3 | tab_width = 4 4 | indent_size = 4 5 | end_of_line = crlf 6 | dotnet_style_coalesce_expression = true:suggestion 7 | dotnet_style_null_propagation = true:suggestion 8 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion 9 | dotnet_style_prefer_auto_properties = true:silent 10 | dotnet_style_object_initializer = true:suggestion 11 | dotnet_style_collection_initializer = true:suggestion 12 | dotnet_style_prefer_simplified_boolean_expressions = true:suggestion 13 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent 14 | dotnet_style_prefer_conditional_expression_over_return = true:silent 15 | dotnet_style_explicit_tuple_names = true:suggestion 16 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 17 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 18 | dotnet_style_prefer_compound_assignment = true:suggestion 19 | dotnet_style_prefer_simplified_interpolation = true:suggestion 20 | dotnet_style_namespace_match_folder = true:suggestion 21 | [*.cs] 22 | csharp_indent_labels = one_less_than_current 23 | csharp_using_directive_placement = outside_namespace:silent 24 | csharp_prefer_simple_using_statement = true:suggestion 25 | csharp_prefer_braces = true:silent 26 | csharp_style_namespace_declarations = block_scoped:silent 27 | csharp_style_prefer_method_group_conversion = true:silent 28 | csharp_style_prefer_top_level_statements = true:silent 29 | csharp_style_expression_bodied_methods = false:silent 30 | csharp_style_expression_bodied_constructors = false:silent 31 | csharp_style_expression_bodied_operators = false:silent 32 | csharp_style_expression_bodied_properties = true:silent 33 | csharp_style_expression_bodied_indexers = true:silent 34 | csharp_style_expression_bodied_accessors = true:silent 35 | csharp_style_expression_bodied_lambdas = true:silent 36 | csharp_style_expression_bodied_local_functions = false:silent 37 | csharp_style_throw_expression = true:suggestion 38 | csharp_style_prefer_null_check_over_type_check = true:suggestion 39 | csharp_prefer_simple_default_expression = true:suggestion 40 | csharp_style_prefer_local_over_anonymous_function = true:suggestion 41 | csharp_style_prefer_index_operator = true:suggestion 42 | csharp_style_prefer_range_operator = true:suggestion 43 | csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion 44 | csharp_style_prefer_tuple_swap = true:suggestion 45 | csharp_style_prefer_utf8_string_literals = true:suggestion 46 | csharp_space_around_binary_operators = before_and_after 47 | [*.{cs,vb}] 48 | #### Naming styles #### 49 | 50 | # Naming rules 51 | 52 | dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion 53 | dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface 54 | dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i 55 | 56 | dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion 57 | dotnet_naming_rule.types_should_be_pascal_case.symbols = types 58 | dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case 59 | 60 | dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion 61 | dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members 62 | dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case 63 | 64 | # Symbol specifications 65 | 66 | dotnet_naming_symbols.interface.applicable_kinds = interface 67 | dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 68 | dotnet_naming_symbols.interface.required_modifiers = 69 | 70 | dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum 71 | dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 72 | dotnet_naming_symbols.types.required_modifiers = 73 | 74 | dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method 75 | dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 76 | dotnet_naming_symbols.non_field_members.required_modifiers = 77 | 78 | # Naming styles 79 | 80 | dotnet_naming_style.begins_with_i.required_prefix = I 81 | dotnet_naming_style.begins_with_i.required_suffix = 82 | dotnet_naming_style.begins_with_i.word_separator = 83 | dotnet_naming_style.begins_with_i.capitalization = pascal_case 84 | 85 | dotnet_naming_style.pascal_case.required_prefix = 86 | dotnet_naming_style.pascal_case.required_suffix = 87 | dotnet_naming_style.pascal_case.word_separator = 88 | dotnet_naming_style.pascal_case.capitalization = pascal_case 89 | 90 | dotnet_naming_style.pascal_case.required_prefix = 91 | dotnet_naming_style.pascal_case.required_suffix = 92 | dotnet_naming_style.pascal_case.word_separator = 93 | dotnet_naming_style.pascal_case.capitalization = pascal_case 94 | indent_style = space 95 | -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | name: .NET 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | matrix: 16 | os: [ ubuntu-latest, windows-latest, macos-latest ] 17 | dotnet: [ '6.0', '8.0', '9.0' ] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Setup .NET 22 | uses: actions/setup-dotnet@v4 23 | id: stepid 24 | with: 25 | dotnet-version: ${{ matrix.dotnet }} 26 | # By default, tests will be executed under the latest installed version! 27 | - name: Create temporary global.json 28 | run: echo '{"sdk":{"version":"${{steps.stepid.outputs.dotnet-version}}"}}' > ./global.json 29 | - name: Restore packages 30 | run: dotnet restore -p:TargetFrameworks="net${{ matrix.dotnet }}" -p:LangVersion="latest" 31 | - name: Build solution 32 | run: dotnet build --no-restore --configuration Release -p:TargetFrameworks="net${{ matrix.dotnet }}" -p:LangVersion="latest" --verbosity normal 33 | - name: Run tests 34 | run: dotnet test --configuration Release --no-build --verbosity normal -p:TargetFrameworks="net${{ matrix.dotnet }}" -p:LangVersion="latest" 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vs 2 | obj 3 | bin 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Developed and maintained by Mahmoud Al-Qudsi 4 | Copyright (c) 2019 NeoSmart Technologies 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SqliteCache for ASP.NET Core 2 | 3 | [SqliteCache](https://neosmart.net/blog/sqlite-cache-for-asp-net-core) is a persistent cache 4 | implementing `IDistributedCache` for .NET and ASP.NET Core projects. 5 | 6 | SqliteCache uses a locally stored SQLite database file (taking advantage of SQLite's battle-tested 7 | safe multi-threaded access features) to replicate persistent caching, allowing developers to mimic 8 | the behavior of staging or production targets without all the overhead or hassle of a traditional 9 | `IDistributedCache` implementation. You can read more about its design and inspiration in [the 10 | official release post](https://neosmart.net/blog/sqlite-cache-for-asp-net-core) on the NeoSmart 11 | blog. 12 | 13 | ## Why `NeoSmart.Caching.Sqlite`? 14 | 15 | The currently available options for caching in ASP.NET Core projects are either all ephemeral 16 | in-memory cache offerings (`IMemoryCache` and co.) -- aka non-persistent -- or else have a whole 17 | slew of dependencies and requirements that require at the very least administrator privileges and 18 | background services hogging up system resources and needing updates and maintenance to requiring 19 | multiple machines and a persistent network configuration. 20 | 21 | * `NeoSmart.Caching.Sqlite` has no dependencies on background services that hog system resources and 22 | need to be updated or maintained (*cough* *cough* NCache *cough* *cough*) 23 | * `NeoSmart.Caching.Sqlite` is fully cross-platform and runs the same on your Windows PC or your 24 | colleagues' Linux, FreeBSD, and macOS workstations (unlike, say, Redis) 25 | * `NeoSmart.Caching.Sqlite` doesn't need administrator privileges to install - or even any installation 26 | for that matter (SQL Express LocalDB, this one is aimed at you) 27 | * `NeoSmart.Caching.Sqlite` is a fully contained `IDistributedCache` offering that is installed and 28 | updated alongside the rest of your packages via NuGet, Paket, or whatever other option you're 29 | already using to manage your dependencies. 30 | 31 | ## Installation 32 | 33 | SqliteCache is available via the NuGet, and can be installed in the Package Manager Console as 34 | follows: 35 | 36 | ``` 37 | Install-Package NeoSmart.Caching.Sqlite 38 | ``` 39 | 40 | **If using this in an ASP.NET Core project**, you can install `NeoSmart.Caching.Sqlite.AspNetCore` (also 41 | or instead) to get a convenient helper method for dependency injection (used below): 42 | 43 | ``` 44 | Install-Package NeoSmart.Caching.Sqlite.AspNetCore 45 | ``` 46 | 47 | Note: If you install `NeoSmart.Caching.Sqlite.AspNetCore` you do not need to manually install 48 | `NeoSmart.Caching.Sqlite`, as it it will be installed automatically/transitively. 49 | 50 | ## Usage 51 | 52 | Using SqliteCache is straight-forward, and should be extremely familiar for anyone that's configured 53 | an ASP.NET Core application before. *Starting by adding a namespace import `using 54 | NeoSmart.Caching.Sqlite` makes things easier as the editor will pull in the correct extension 55 | methods.* 56 | 57 | If using SqliteCache in an ASP.NET Core project, the SQLite-backed cache should be added as an 58 | `IDistributedCache` type by adding the following to your `ConfigureServices` method, by default 59 | located in `Startup.cs`, after using the correct namespace `NeoSmart.Caching.Sqlite`: 60 | 61 | ```csharp 62 | // using NeoSmart.Caching.Sqlite; 63 | 64 | public void ConfigureServices(IServiceCollection services) 65 | { 66 | ... 67 | 68 | // Note: this *must* come before services.AddMvc() and/or services.AddRazorPages()! 69 | services.AddSqliteCache(options => { 70 | options.CachePath = @"C:\data\bazaar\cache.db"; 71 | }); 72 | 73 | services.AddMvc(); 74 | 75 | ... 76 | } 77 | ``` 78 | 79 | Afterwards, the `SqliteCache` instance will be made available to both the framework and the 80 | application via dependency injection, and can be imported and used via either the 81 | `IDistributedCache` abstract type or the concrete `SqliteCache` type: 82 | 83 | ```csharp 84 | // using Microsoft.Extensions.Caching.Distributed; 85 | public class FooModel(DbContext db, IDistributedCache cache) 86 | { 87 | _db = db; 88 | _cache = cache; 89 | 90 | cache.SetString("foo", "bar"); 91 | Assert.AreEqual(cache.GetString("foo"), "bar"); 92 | 93 | Assert.AreEqual(typeof(NeoSmart.Caching.Sqlite.SqliteCache), 94 | cache.GetType()); 95 | } 96 | ``` 97 | 98 | To take advantage of SqliteCache-specific features or functionality that aren't exposed via the 99 | `IDistributedCache` façade, you'll need to inject `SqliteCache` into your classes/methods rather than 100 | `IDistributedCache`. For example, to globally clear the cache after performing some operation: 101 | 102 | ```csharp 103 | // using NeoSmart.Caching.Sqlite; 104 | public class BarModel(DbContext db, SqliteCache cache) 105 | { 106 | _db = db; 107 | _cache = cache; 108 | } 109 | 110 | public ActionResult OnPostAsync() 111 | { 112 | ... 113 | await _db.SomethingDestructiveAsync(); 114 | 115 | // We need to invalidate all the cache, since it's too hard to 116 | // account for the changes this operation caused for legacy reasons. 117 | await _cache.ClearAsync(); 118 | 119 | ... 120 | } 121 | ``` 122 | 123 | ## License 124 | 125 | SqliteCache is developed and maintained by Mahmoud Al-Qudsi of NeoSmart Technologies. The project is 126 | provided free to the community under the terms of the MIT open source license. 127 | 128 | ## Contributing 129 | 130 | We are open to pull requests and contributions aimed at the code, documentation, unit tests, or 131 | anything else. If you're mulling an extensive contribution, file an issue first to make sure we're 132 | all on the same page, otherwise, PR away! 133 | 134 | -------------------------------------------------------------------------------- /SqliteCache.AspNetCore/SqliteCache.AspNetCore.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | NeoSmart.Caching.Sqlite.AspNetCore 5 | NeoSmart.Caching.Sqlite.AspNetCore 6 | netstandard2.1 7 | enable 8 | 11 9 | 9.0.1 10 | true 11 | ../SqliteCache/NeoSmart.Caching.Sqlite.snk 12 | true 13 | true 14 | snupkg 15 | README.md 16 | Mahmoud Al-Qudsi, neosmart, mqudsi 17 | NeoSmart Technologies 18 | ASP.NET Core dependency-injection integrations for NeoSmart.Caching.Sqlite 19 | NeoSmart Technologies 2019-2025 20 | MIT 21 | https://neosmart.net/blog/sqlite-cache-for-asp-net-core 22 | https://github.com/neosmart/AspSqliteCache 23 | git 24 | idistributedcache, cache, sqlite, sqlitecache, aspnetcore 25 | 26 | 27 | 28 | 29 | True 30 | \ 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /SqliteCache.AspNetCore/SqliteCacheServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using Microsoft.Extensions.Caching.Distributed; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace NeoSmart.Caching.Sqlite.AspNetCore 7 | { 8 | [EditorBrowsable(EditorBrowsableState.Never)] 9 | public static class AspSqliteCacheServiceCollectionExtensions 10 | { 11 | [EditorBrowsable(EditorBrowsableState.Never)] 12 | [Obsolete("Use this method from the NeoSmart.Caching.Sqlite namespace directly")] 13 | public static IServiceCollection AddSqliteCache(this IServiceCollection services, 14 | Action setupAction) 15 | { 16 | return Sqlite.AspSqliteCacheServiceCollectionExtensions 17 | .AddSqliteCache(services, setupAction); 18 | } 19 | } 20 | } 21 | 22 | namespace NeoSmart.Caching.Sqlite 23 | { 24 | public static class AspSqliteCacheServiceCollectionExtensions 25 | { 26 | /// 27 | /// Registers SqliteCache as a dependency-injected singleton, available 28 | /// both as IDistributedCache and SqliteCache. 29 | /// 30 | /// 31 | public static IServiceCollection AddSqliteCache(this IServiceCollection services, 32 | Action setupAction) 33 | { 34 | if (services is null) 35 | { 36 | throw new ArgumentNullException(nameof(services)); 37 | } 38 | else if (setupAction is null) 39 | { 40 | throw new ArgumentNullException(nameof(setupAction)); 41 | } 42 | 43 | SQLitePCL.Batteries_V2.Init(); 44 | services.AddOptions(); 45 | services.AddSingleton(); 46 | services.AddSingleton(services => services.GetRequiredService()); 47 | services.Configure(setupAction); 48 | return services; 49 | } 50 | 51 | /// 52 | /// Registers SqliteCache as a dependency-injected singleton, available 53 | /// both as IDistributedCache and SqliteCache. 54 | /// 55 | /// 56 | /// The path where the SQLite database should be stored. It 57 | /// is created if it does not exist. (The path should be a file path, not a 58 | /// directory. Make sure the application has RW access at runtime.) 59 | /// 60 | public static IServiceCollection AddSqliteCache(this IServiceCollection services, 61 | string path) 62 | { 63 | return AddSqliteCache(services, options => options.CachePath = path); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /SqliteCache.Tests/BasicTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.Caching.Distributed; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.VisualStudio.TestTools.UnitTesting; 7 | 8 | namespace NeoSmart.Caching.Sqlite.Tests 9 | { 10 | [TestClass] 11 | public class BasicTests : IDisposable 12 | { 13 | [AssemblyInitialize] 14 | public static void SetSqliteProvider(TestContext _) 15 | { 16 | // SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_e_sqlite3()); 17 | SQLitePCL.Batteries_V2.Init(); 18 | } 19 | 20 | public static readonly Encoding DefaultEncoding = new UTF8Encoding(false); 21 | private readonly SqliteCacheOptions Configuration = new SqliteCacheOptions() 22 | { 23 | MemoryOnly = false, 24 | CachePath = $"BasicTests-{Guid.NewGuid()}.db", 25 | }; 26 | 27 | public void Dispose() 28 | { 29 | var logger = new TestLogger(); 30 | logger.LogInformation("Delete db at path {DbPath}", Configuration.CachePath); 31 | try 32 | { 33 | System.IO.File.Delete(Configuration.CachePath); 34 | } 35 | catch (Exception ex) 36 | { 37 | logger.LogWarning(ex, "Unable to delete db file at {DbPath}", Configuration.CachePath); 38 | } 39 | } 40 | 41 | private SqliteCache CreateDefault(bool persistent = true) 42 | { 43 | var logger = new TestLogger(); 44 | logger.LogInformation("Creating a connection to db {DbPath}", Configuration.CachePath); 45 | var cacheDb = new SqliteCache(Configuration with { MemoryOnly = !persistent }, logger); 46 | 47 | return cacheDb; 48 | } 49 | 50 | [TestMethod] 51 | public async Task BasicSetGet() 52 | { 53 | using (var cache = CreateDefault(true)) 54 | { 55 | var bytes = cache.Get("hello"); 56 | Assert.IsNull(bytes); 57 | 58 | cache.Set("hello", DefaultEncoding.GetBytes("hello"), new DistributedCacheEntryOptions() 59 | { 60 | AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(1) 61 | }); 62 | 63 | bytes = cache.Get("hello"); 64 | Assert.IsNotNull(bytes); 65 | 66 | CollectionAssert.AreEqual(bytes, DefaultEncoding.GetBytes("hello")); 67 | } 68 | 69 | // Check persistence 70 | using (var cache = CreateDefault(true)) 71 | { 72 | var bytes = await cache.GetAsync("hello"); 73 | CollectionAssert.AreEqual(bytes, DefaultEncoding.GetBytes("hello")); 74 | } 75 | } 76 | 77 | [TestMethod] 78 | public void ExpiredIgnored() 79 | { 80 | using (var cache = CreateDefault()) 81 | { 82 | cache.Set("hi there", DefaultEncoding.GetBytes("hello"), 83 | new DistributedCacheEntryOptions() 84 | .SetAbsoluteExpiration(DateTimeOffset.UtcNow.AddDays(-1))); 85 | 86 | Assert.IsNull(cache.Get("hi there")); 87 | } 88 | } 89 | 90 | [TestMethod] 91 | public void ExpiredRenewal() 92 | { 93 | using (var cache = CreateDefault()) 94 | { 95 | cache.Set("hi there", DefaultEncoding.GetBytes("hello"), 96 | new DistributedCacheEntryOptions() 97 | { 98 | AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(-1), 99 | SlidingExpiration = TimeSpan.FromDays(2), 100 | }); 101 | 102 | Assert.IsNotNull(cache.Get("hi there")); 103 | } 104 | } 105 | 106 | [TestMethod] 107 | public void ExpirationStoredInUtc() 108 | { 109 | var expiryUtc = DateTimeOffset.UtcNow.AddMinutes(-1); 110 | var expiryLocal = expiryUtc.ToOffset(TimeSpan.FromHours(5)); 111 | 112 | using (var cache = CreateDefault()) 113 | { 114 | cache.Set("key", DefaultEncoding.GetBytes("value"), new DistributedCacheEntryOptions 115 | { 116 | AbsoluteExpiration = expiryLocal, 117 | }); 118 | 119 | Assert.IsNull(cache.Get("key")); 120 | } 121 | } 122 | 123 | [TestMethod] 124 | public void DoubleDispose() 125 | { 126 | using (var cache = CreateDefault(true)) 127 | { 128 | cache.Dispose(); 129 | } 130 | } 131 | 132 | #if NETCOREAPP3_0_OR_GREATER 133 | [TestMethod] 134 | public async Task AsyncDispose() 135 | { 136 | await using (var cache = CreateDefault(true)) 137 | { 138 | await cache.SetAsync("foo", DefaultEncoding.GetBytes("hello")); 139 | var bytes = await cache.GetAsync("foo"); 140 | CollectionAssert.AreEqual(bytes, DefaultEncoding.GetBytes("hello")); 141 | } 142 | } 143 | #endif 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /SqliteCache.Tests/BulkInsertTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using Microsoft.Extensions.Caching.Distributed; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.VisualStudio.TestTools.UnitTesting; 8 | 9 | namespace NeoSmart.Caching.Sqlite.Tests 10 | { 11 | [TestClass] 12 | public class BulkInsertTests : IDisposable 13 | { 14 | public static readonly Encoding DefaultEncoding = new UTF8Encoding(false); 15 | private readonly SqliteCacheOptions Configuration = new SqliteCacheOptions() 16 | { 17 | MemoryOnly = false, 18 | CachePath = $"BulkInsert-{Guid.NewGuid()}.db", 19 | }; 20 | 21 | public void Dispose() 22 | { 23 | var logger = new TestLogger(); 24 | logger.LogInformation("Delete db at path {DbPath}", Configuration.CachePath); 25 | try 26 | { 27 | System.IO.File.Delete(Configuration.CachePath); 28 | } 29 | catch(Exception ex) 30 | { 31 | logger.LogWarning(ex, "Unable to delete db file at {DbPath}", Configuration.CachePath); 32 | } 33 | } 34 | 35 | private SqliteCache CreateDefault(bool persistent = false) 36 | { 37 | var logger = new TestLogger(); 38 | logger.LogInformation("Creating a connection to db {DbPath}", Configuration.CachePath); 39 | var cacheDb = new SqliteCache(Configuration with { MemoryOnly = !persistent }, logger); 40 | 41 | return cacheDb; 42 | } 43 | 44 | [TestMethod] 45 | public async Task BasicBulkTests() 46 | { 47 | using (var cache = CreateDefault(true)) 48 | { 49 | var item1 = cache.Get("firstItem"); 50 | Assert.IsNull(item1); 51 | 52 | var item2 = cache.Get("secondItem"); 53 | Assert.IsNull(item2); 54 | 55 | List> testObject = new List> 56 | { 57 | new KeyValuePair("firstItem", DefaultEncoding.GetBytes("test one")), 58 | new KeyValuePair("secondItem", DefaultEncoding.GetBytes("test two")) 59 | }; 60 | 61 | await cache.SetBulkAsync(testObject, new DistributedCacheEntryOptions() 62 | { 63 | AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(1) 64 | }); 65 | 66 | item1 = cache.Get("firstItem"); 67 | Assert.IsNotNull(item1); 68 | CollectionAssert.AreEqual(item1, DefaultEncoding.GetBytes("test one")); 69 | 70 | item2 = cache.Get("secondItem"); 71 | Assert.IsNotNull(item2); 72 | CollectionAssert.AreEqual(item2, DefaultEncoding.GetBytes("test two")); 73 | } 74 | 75 | // Check persistence 76 | using (var cache = CreateDefault(true)) 77 | { 78 | var bytes = await cache.GetAsync("firstItem"); 79 | Assert.IsNotNull(bytes); 80 | CollectionAssert.AreEqual(bytes, DefaultEncoding.GetBytes("test one")); 81 | 82 | bytes = await cache.GetAsync("secondItem"); 83 | Assert.IsNotNull(bytes); 84 | CollectionAssert.AreEqual(bytes, DefaultEncoding.GetBytes("test two")); 85 | } 86 | } 87 | 88 | [TestMethod] 89 | public async Task MultipleBulkCalls() 90 | { 91 | using (var cache = CreateDefault()) 92 | { 93 | var item1 = cache.Get("firstItem"); 94 | Assert.IsNull(item1); 95 | 96 | var item2 = cache.Get("secondItem"); 97 | Assert.IsNull(item2); 98 | 99 | List> testObject = new List> 100 | { 101 | new KeyValuePair("firstItem", DefaultEncoding.GetBytes("test one")) 102 | }; 103 | 104 | await cache.SetBulkAsync(testObject, new DistributedCacheEntryOptions() 105 | { 106 | AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(1) 107 | }); 108 | 109 | testObject[0] = new KeyValuePair("secondItem", DefaultEncoding.GetBytes("test two")); 110 | 111 | await cache.SetBulkAsync(testObject, new DistributedCacheEntryOptions() 112 | { 113 | AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(1) 114 | }); 115 | 116 | item1 = cache.Get("firstItem"); 117 | Assert.IsNotNull(item1); 118 | 119 | CollectionAssert.AreEqual(item1, DefaultEncoding.GetBytes("test one")); 120 | 121 | item2 = cache.Get("secondItem"); 122 | Assert.IsNotNull(item2); 123 | 124 | CollectionAssert.AreEqual(item2, DefaultEncoding.GetBytes("test two")); 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /SqliteCache.Tests/ClearCacheTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Caching.Distributed; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.VisualStudio.TestTools.UnitTesting; 5 | 6 | namespace NeoSmart.Caching.Sqlite.Tests 7 | { 8 | [TestClass] 9 | public class ClearCacheTests : IDisposable 10 | { 11 | private readonly SqliteCacheOptions Configuration = new SqliteCacheOptions() 12 | { 13 | MemoryOnly = false, 14 | CachePath = $"ClearCache-{Guid.NewGuid()}.db", 15 | }; 16 | 17 | public void Dispose() 18 | { 19 | var logger = new TestLogger(); 20 | logger.LogInformation("Delete db at path {DbPath}", Configuration.CachePath); 21 | try 22 | { 23 | System.IO.File.Delete(Configuration.CachePath); 24 | } 25 | catch(Exception ex) 26 | { 27 | logger.LogWarning(ex, "Unable to delete db file at {DbPath}", Configuration.CachePath); 28 | } 29 | } 30 | 31 | private SqliteCache CreateDefault(bool persistent = false) 32 | { 33 | var logger = new TestLogger(); 34 | logger.LogInformation("Creating a connection to db {DbPath}", Configuration.CachePath); 35 | var cacheDb = new SqliteCache(Configuration with { MemoryOnly = !persistent }, logger); 36 | 37 | return cacheDb; 38 | } 39 | 40 | [TestMethod] 41 | public void ItemsRemovedAfterClear() 42 | { 43 | using (var cache = CreateDefault(true)) 44 | { 45 | var expiry = new DistributedCacheEntryOptions().SetAbsoluteExpiration(DateTimeOffset.UtcNow.AddDays(1)); 46 | cache.SetString("one", "foo", expiry); 47 | cache.SetString("two", "bar", expiry); 48 | 49 | Assert.AreEqual(cache.GetString("one"), "foo"); 50 | Assert.AreEqual(cache.GetString("two"), "bar"); 51 | 52 | // Test and check 53 | cache.Clear(); 54 | 55 | var item1 = cache.Get("one"); 56 | Assert.IsNull(item1); 57 | 58 | var item2 = cache.Get("two"); 59 | Assert.IsNull(item2); 60 | } 61 | } 62 | } 63 | } 64 | 65 | -------------------------------------------------------------------------------- /SqliteCache.Tests/DependencyInjection.cs: -------------------------------------------------------------------------------- 1 | #if NETCOREAPP1_0_OR_GREATER 2 | using Microsoft.Extensions.Caching.Distributed; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.VisualStudio.TestTools.UnitTesting; 5 | using NeoSmart.Caching.Sqlite.AspNetCore; 6 | using System; 7 | 8 | namespace NeoSmart.Caching.Sqlite.Tests 9 | { 10 | [TestClass] 11 | public class DependencyInjection 12 | { 13 | public IServiceProvider CreateServices() 14 | { 15 | var builder = new ServiceCollection(); 16 | builder.AddSqliteCache("test.db"); 17 | 18 | return builder.BuildServiceProvider(); 19 | } 20 | 21 | [TestMethod] 22 | public void TestInterfaceInjection() 23 | { 24 | var services = CreateServices(); 25 | var cache = services.GetRequiredService(); 26 | Assert.IsInstanceOfType(cache, typeof(SqliteCache)); 27 | } 28 | 29 | [TestMethod] 30 | public void TestTypeInjection() 31 | { 32 | var services = CreateServices(); 33 | var cache = services.GetRequiredService(); 34 | Assert.IsInstanceOfType(cache, typeof(SqliteCache)); 35 | } 36 | 37 | /// 38 | /// Verify that `AddSqliteCache()` causes service lookups for both IDistributedCache 39 | /// and lookups for SqliteCache to return the same singleton instance and not two 40 | /// separate instances of SqliteCache. 41 | /// 42 | [TestMethod] 43 | public void TestInjectionSameness() 44 | { 45 | var services = CreateServices(); 46 | Assert.AreSame(services.GetRequiredService(), services.GetRequiredService()); 47 | } 48 | } 49 | } 50 | #endif 51 | -------------------------------------------------------------------------------- /SqliteCache.Tests/SqliteCache.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | preview 5 | false 6 | NeoSmart.Caching.Sqlite.Tests 7 | NeoSmart.Caching.Sqlite.Tests 8 | true 9 | 10 | 11 | 12 | 13 | net462;net6.0;net8.0 14 | 15 | 16 | net6.0;net8.0 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /SqliteCache.Tests/TestLogger.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System; 3 | 4 | namespace NeoSmart.Caching.Sqlite.Tests 5 | { 6 | class TestLogger : ILogger 7 | { 8 | public readonly struct VoidScope : IDisposable 9 | { 10 | public void Dispose() {} 11 | } 12 | 13 | public IDisposable BeginScope(TState state) 14 | { 15 | return new VoidScope(); 16 | } 17 | 18 | public bool IsEnabled(LogLevel logLevel) 19 | { 20 | return true; 21 | } 22 | 23 | public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) 24 | { 25 | Console.WriteLine($"{logLevel} {eventId}: {formatter(state, exception)}"); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /SqliteCache.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.1.31911.260 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SqliteCache", "SqliteCache\SqliteCache.csproj", "{8734C4C5-AB9E-443D-B871-4B77334AE855}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SqliteCache.Tests", "SqliteCache.Tests\SqliteCache.Tests.csproj", "{1B6F63DC-522C-4FD1-BF5D-B853163A803E}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{127BA6AB-C8C5-4710-A322-B86BE76AE7AC}" 11 | ProjectSection(SolutionItems) = preProject 12 | .editorconfig = .editorconfig 13 | README.md = README.md 14 | EndProjectSection 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqliteCache.AspNetCore", "SqliteCache.AspNetCore\SqliteCache.AspNetCore.csproj", "{BD7FAF15-DB65-4782-BE73-7024DF8C4068}" 17 | EndProject 18 | Global 19 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 20 | Debug|Any CPU = Debug|Any CPU 21 | Release|Any CPU = Release|Any CPU 22 | EndGlobalSection 23 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 24 | {8734C4C5-AB9E-443D-B871-4B77334AE855}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {8734C4C5-AB9E-443D-B871-4B77334AE855}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {8734C4C5-AB9E-443D-B871-4B77334AE855}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {8734C4C5-AB9E-443D-B871-4B77334AE855}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {1B6F63DC-522C-4FD1-BF5D-B853163A803E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {1B6F63DC-522C-4FD1-BF5D-B853163A803E}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {1B6F63DC-522C-4FD1-BF5D-B853163A803E}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {1B6F63DC-522C-4FD1-BF5D-B853163A803E}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {BD7FAF15-DB65-4782-BE73-7024DF8C4068}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {BD7FAF15-DB65-4782-BE73-7024DF8C4068}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {BD7FAF15-DB65-4782-BE73-7024DF8C4068}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {BD7FAF15-DB65-4782-BE73-7024DF8C4068}.Release|Any CPU.Build.0 = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(SolutionProperties) = preSolution 38 | HideSolutionNode = FALSE 39 | EndGlobalSection 40 | GlobalSection(ExtensibilityGlobals) = postSolution 41 | SolutionGuid = {1E29BEAF-B352-4CC7-8886-FD79CC7B190B} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /SqliteCache/DbCommandPool.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Data.Sqlite; 2 | using Microsoft.Extensions.Logging; 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Threading.Tasks; 6 | 7 | namespace NeoSmart.Caching.Sqlite 8 | { 9 | class DbCommandPool : IDisposable 10 | #if NETCOREAPP3_1_OR_GREATER 11 | , IAsyncDisposable 12 | #endif 13 | { 14 | /// 15 | /// Number of connections to open to the database at startup. Ramps up as concurrency increases. 16 | /// 17 | private const int InitialConcurrency = 4; 18 | private readonly ILogger _logger; 19 | private readonly ConcurrentBag[] _commands = new ConcurrentBag[DbCommands.Count]; 20 | private readonly ConcurrentBag _connections = new ConcurrentBag(); 21 | private readonly string _connectionString; 22 | 23 | public DbCommandPool(SqliteConnection db, ILogger logger) 24 | { 25 | _connectionString = db.ConnectionString; 26 | _logger = logger; 27 | 28 | _logger.LogTrace("Initializing db command pool"); 29 | for (int i = 0; i < _commands.Length; ++i) 30 | { 31 | _commands[i] = new ConcurrentBag(); 32 | } 33 | 34 | _logger.LogTrace("Creating {InitialConnections} initial connections in the pool", InitialConcurrency); 35 | for (int i = 0; i < InitialConcurrency; ++i) 36 | { 37 | var connection = new SqliteConnection(_connectionString); 38 | _logger.LogTrace("Opening connection to {SqliteCacheDbPath}", _connectionString); 39 | connection.Open(); 40 | _connections.Add(connection); 41 | } 42 | } 43 | 44 | public void Use(Operation type, Action handler) 45 | { 46 | Use(type, (cmd) => 47 | { 48 | handler(cmd); 49 | return true; 50 | }); 51 | } 52 | 53 | public R Use(Func handler) 54 | { 55 | if (!_connections.TryTake(out var db)) 56 | { 57 | _logger.LogTrace("Adding a new connection to the connection pool"); 58 | db = new SqliteConnection(_connectionString); 59 | _logger.LogTrace("Opening connection to {SqliteCacheDbPath}", _connectionString); 60 | db.Open(); 61 | } 62 | 63 | try 64 | { 65 | return handler(db); 66 | } 67 | finally 68 | { 69 | _connections.Add(db); 70 | } 71 | } 72 | 73 | public R Use(Operation type, Func handler) 74 | { 75 | return Use((conn) => 76 | { 77 | var pool = _commands[(int)type]; 78 | if (!pool.TryTake(out var command)) 79 | { 80 | _logger.LogTrace("Adding a new {DbCommand} command to the command pool", type); 81 | command = new SqliteCommand(DbCommands.Commands[(int)type], conn); 82 | } 83 | 84 | try 85 | { 86 | command.Connection = conn; 87 | return handler(command); 88 | } 89 | finally 90 | { 91 | command.Connection = null; 92 | command.Parameters.Clear(); 93 | pool.Add(command); 94 | } 95 | }); 96 | } 97 | 98 | public async Task UseAsync(Func> handler) 99 | { 100 | if (!_connections.TryTake(out var db)) 101 | { 102 | _logger.LogTrace("Adding a new connection to the connection pool"); 103 | db = new SqliteConnection(_connectionString); 104 | _logger.LogTrace("Opening connection to {SqliteCacheDbPath}", _connectionString); 105 | await db.OpenAsync().ConfigureAwait(false); 106 | } 107 | 108 | try 109 | { 110 | return await handler(db).ConfigureAwait(false); 111 | } 112 | finally 113 | { 114 | _connections.Add(db); 115 | } 116 | } 117 | 118 | public Task UseAsync(Operation type, Func> handler) 119 | { 120 | return UseAsync(async (conn) => 121 | { 122 | var pool = _commands[(int)type]; 123 | if (!pool.TryTake(out var command)) 124 | { 125 | _logger.LogTrace("Adding a new {DbCommand} command to the command pool", type); 126 | command = new SqliteCommand(DbCommands.Commands[(int)type], conn); 127 | } 128 | 129 | try 130 | { 131 | command.Connection = conn; 132 | return await handler(command).ConfigureAwait(false); 133 | } 134 | finally 135 | { 136 | command.Connection = null; 137 | command.Parameters.Clear(); 138 | pool.Add(command); 139 | } 140 | }); 141 | } 142 | 143 | public void Dispose() 144 | { 145 | foreach (var pool in _commands) 146 | { 147 | while (pool.TryTake(out var cmd)) 148 | { 149 | cmd.Dispose(); 150 | } 151 | } 152 | 153 | foreach (var conn in _connections) 154 | { 155 | _logger.LogTrace("Closing connection to {SqliteCacheDbPath}", _connectionString); 156 | conn.Close(); 157 | conn.Dispose(); 158 | } 159 | } 160 | 161 | #if NETCOREAPP3_1_OR_GREATER 162 | public async ValueTask DisposeAsync() 163 | { 164 | foreach (var pool in _commands) 165 | { 166 | while (pool.TryTake(out var cmd)) 167 | { 168 | await cmd.DisposeAsync().ConfigureAwait(false); 169 | } 170 | } 171 | 172 | foreach (var conn in _connections) 173 | { 174 | await conn.CloseAsync().ConfigureAwait(false); 175 | await conn.DisposeAsync().ConfigureAwait(false); 176 | } 177 | } 178 | #endif 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /SqliteCache/DbCommands.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace NeoSmart.Caching.Sqlite 4 | { 5 | enum Operation 6 | { 7 | Insert, 8 | Remove, 9 | RemoveExpired, 10 | Get, 11 | Refresh, 12 | BulkInsert, 13 | } 14 | 15 | static class DbCommands 16 | { 17 | public readonly static string[] Commands; 18 | public readonly static int Count = Enum.GetValues(typeof(Operation)).Length; 19 | 20 | // We have two expiry fields, that can be considered a union of these 21 | // two cases: (AbsoluteExpiry) and (NextExpiry, Ttl) 22 | const string NotExpiredClause = " (expiry IS NULL OR expiry >= @now) "; 23 | 24 | static DbCommands() 25 | { 26 | Commands = new string[Count]; 27 | 28 | Commands[(int)Operation.Insert] = 29 | "INSERT OR REPLACE INTO cache (key, value, expiry, renewal) " + 30 | "VALUES (@key, @value, @expiry, @renewal)"; 31 | 32 | Commands[(int)Operation.Refresh] = 33 | $"UPDATE cache " + 34 | $"SET expiry = (@now + renewal) " + 35 | $"WHERE " + 36 | $" key = @key " + 37 | $" AND expiry >= @now " + 38 | $" AND renewal IS NOT NULL;"; 39 | 40 | Commands[(int)Operation.Get] = 41 | // Get an unexpired item from the cache 42 | $"SELECT value FROM cache " + 43 | $" WHERE key = @key " + 44 | $" AND {NotExpiredClause};" + 45 | // And update the expiry if it is unexpired and has a renewal 46 | Commands[(int)Operation.Refresh]; 47 | 48 | Commands[(int)Operation.Remove] = 49 | "DELETE FROM cache " + 50 | " WHERE key = @key"; 51 | 52 | Commands[(int)Operation.RemoveExpired] = 53 | "DELETE FROM cache " + 54 | $" WHERE NOT {NotExpiredClause};" + 55 | $"SELECT CHANGES();"; 56 | 57 | Commands[(int)Operation.BulkInsert] = 58 | "INSERT OR REPLACE INTO cache (key, value, expiry, renewal) VALUES "; 59 | 60 | #if DEBUG 61 | for (int i = 0; i < Count; ++i) 62 | { 63 | if (string.IsNullOrEmpty(Commands[i])) 64 | { 65 | throw new Exception("Missing SQLite command for operation " + Enum.GetName(typeof(Operation), i)); 66 | } 67 | } 68 | #endif 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /SqliteCache/NeoSmart.Caching.Sqlite.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neosmart/SqliteCache/4a666222d40f5bf31f784b6971872e78b2720759/SqliteCache/NeoSmart.Caching.Sqlite.snk -------------------------------------------------------------------------------- /SqliteCache/NullLogger.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System; 3 | 4 | namespace NeoSmart.Caching.Sqlite 5 | { 6 | readonly struct NullLogger : ILogger 7 | { 8 | readonly struct NullDisposable : IDisposable 9 | { 10 | public void Dispose() 11 | { 12 | } 13 | } 14 | 15 | public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) 16 | { 17 | } 18 | 19 | public bool IsEnabled(LogLevel logLevel) 20 | { 21 | return false; 22 | } 23 | 24 | public IDisposable? BeginScope(TState state) where TState : notnull 25 | { 26 | return new NullDisposable(); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /SqliteCache/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace NeoSmart.Caching.Sqlite { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | internal class Resources { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal Resources() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | internal static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("NeoSmart.Caching.Sqlite.Resources", typeof(Resources).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | internal static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | 63 | /// 64 | /// Looks up a localized string similar to CREATE TABLE "cache" ( 65 | /// "key" varchar NOT NULL, 66 | /// "value" BLOB, 67 | /// "expiry" INTEGER, 68 | /// "renewal" INTEGER, 69 | /// PRIMARY KEY("key") 70 | ///) WITHOUT ROWID; 71 | /// 72 | ///CREATE TABLE "meta" ( 73 | /// "key" TEXT NOT NULL, 74 | /// "value" INTEGER, 75 | /// PRIMARY KEY("key") 76 | ///) WITHOUT ROWID; 77 | /// 78 | ///CREATE INDEX "cache_expiry" ON "cache" ( 79 | /// "expiry" 80 | ///). 81 | /// 82 | internal static string TableInitCommand { 83 | get { 84 | return ResourceManager.GetString("TableInitCommand", resourceCulture); 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /SqliteCache/Resources.resx: -------------------------------------------------------------------------------- 1 |  2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | CREATE TABLE "cache" ( 122 | "key" varchar NOT NULL, 123 | "value" BLOB, 124 | "expiry" INTEGER, 125 | "renewal" INTEGER, 126 | PRIMARY KEY("key") 127 | ) WITHOUT ROWID; 128 | 129 | CREATE TABLE "meta" ( 130 | "key" TEXT NOT NULL, 131 | "value" INTEGER, 132 | PRIMARY KEY("key") 133 | ) WITHOUT ROWID; 134 | 135 | CREATE INDEX "cache_expiry" ON "cache" ( 136 | "expiry" 137 | ) 138 | 139 | -------------------------------------------------------------------------------- /SqliteCache/SqliteCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.Caching.Distributed; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | 8 | using DbConnection = Microsoft.Data.Sqlite.SqliteConnection; 9 | using DbCommand = Microsoft.Data.Sqlite.SqliteCommand; 10 | using System.Collections.Generic; 11 | using System.Text; 12 | using System.Linq; 13 | 14 | namespace NeoSmart.Caching.Sqlite 15 | { 16 | public sealed class SqliteCache : IDistributedCache, IDisposable 17 | #if NETCOREAPP3_1_OR_GREATER 18 | , IAsyncDisposable 19 | #endif 20 | { 21 | public const int SchemaVersion = 1; 22 | 23 | private readonly SqliteCacheOptions _config; 24 | private readonly ILogger _logger; 25 | private readonly Timer? _cleanupTimer; 26 | private readonly DbConnection _db; 27 | 28 | private DbCommandPool Commands { get; } 29 | 30 | static SqliteCache() 31 | { 32 | // SQLitePCL.Batteries.Init(); 33 | } 34 | 35 | public SqliteCache(IOptions options, ILogger? logger = null) 36 | : this(options.Value, logger) 37 | { 38 | } 39 | 40 | public SqliteCache(SqliteCacheOptions options, ILogger? logger = null) 41 | { 42 | _config = options; 43 | _logger = logger ?? new NullLogger(); 44 | 45 | _db = Connect(_config, _logger); 46 | Commands = new DbCommandPool(_db, _logger); 47 | 48 | // This has to be after the call to Connect() 49 | if (_config.CleanupInterval.HasValue) 50 | { 51 | _cleanupTimer = new Timer(static state => 52 | { 53 | var @this = (SqliteCache)state!; 54 | 55 | @this._logger.LogTrace("Beginning background cleanup of expired SQLiteCache items"); 56 | @this.RemoveExpired(); 57 | }, this, TimeSpan.Zero, _config.CleanupInterval.Value); 58 | } 59 | } 60 | 61 | // Must keep this in sync with DisposeAsync() below! 62 | public void Dispose() 63 | { 64 | _logger.LogTrace("Disposing SQLite cache database at {SqliteCacheDbPath}", _config.CachePath); 65 | 66 | // Dispose the timer first, because it might still access other things until it's been disposed! 67 | if (_cleanupTimer is not null) 68 | { 69 | // Timer.Dispose(WaitHandle) ends up delegating to (the internal) 70 | // TimerQueueTimer.Dispose(WaitHandle), which calls (the private) 71 | // EventWaitHandle.Set(SafeWaitHandle) method, which is just a wrapper around Kernel32's 72 | // SetEvent() -- all of which is to say, we can't use a ManualResetEventSlim here. 73 | using var resetEvent = new ManualResetEvent(false); 74 | // If the timer has already been disposed, it'll return false immediately without setting the 75 | // event. Since we don't really protect against our own .Dispose() method being called twice 76 | // (maybe in a race), we should handle this eventuality. 77 | if (_cleanupTimer.Dispose(resetEvent)) 78 | { 79 | resetEvent.WaitOne(); 80 | } 81 | } 82 | Commands.Dispose(); 83 | 84 | _logger.LogTrace("Closing connection to SQLite database at {SqliteCacheDbPath}", _config.CachePath); 85 | _db.Close(); 86 | _db.Dispose(); 87 | } 88 | 89 | #if NETCOREAPP3_0_OR_GREATER 90 | // Must keep this in sync with Dispose() above! 91 | public async ValueTask DisposeAsync() 92 | { 93 | _logger.LogTrace("Disposing SQLite cache database at {SqliteCacheDbPath}", _config.CachePath); 94 | 95 | // Dispose the timer first, because it might still access other things until it's been disposed! 96 | if (_cleanupTimer is not null) 97 | { 98 | await _cleanupTimer.DisposeAsync().ConfigureAwait(false); 99 | } 100 | 101 | await Commands.DisposeAsync().ConfigureAwait(false); 102 | 103 | _logger.LogTrace("Closing connection to SQLite database at {SqliteCacheDbPath}", _config.CachePath); 104 | await _db.CloseAsync().ConfigureAwait(false); 105 | await _db.DisposeAsync().ConfigureAwait(false); 106 | } 107 | #endif 108 | 109 | #region Database Connection Initialization 110 | private static bool CheckExistingDb(DbConnection db, ILogger logger) 111 | { 112 | try 113 | { 114 | // Check for correct structure 115 | using (var cmd = new DbCommand(@"SELECT COUNT(*) from sqlite_master", db)) 116 | { 117 | var result = (long)cmd.ExecuteScalar()!; 118 | // We are expecting two tables and one additional index 119 | if (result != 3) 120 | { 121 | logger.LogWarning("Incorrect/incompatible existing cache db structure found!"); 122 | return false; 123 | } 124 | } 125 | 126 | // Check for correct version 127 | using (var cmd = new DbCommand("SELECT value FROM meta WHERE key = 'version'", db)) 128 | { 129 | var result = (long)cmd.ExecuteScalar()!; 130 | if (result != SchemaVersion) 131 | { 132 | logger.LogWarning("Existing cache db has unsupported schema version {SchemaVersion}", 133 | result); 134 | return false; 135 | } 136 | } 137 | } 138 | catch (Exception ex) 139 | { 140 | logger.LogError(ex, "Error while checking compatibility of existing cache db!"); 141 | return false; 142 | } 143 | 144 | return true; 145 | } 146 | 147 | private static DbConnection Connect(SqliteCacheOptions config, ILogger logger) 148 | { 149 | var connectionString = config.ConnectionString; 150 | logger.LogTrace("Opening connection to SQLite database: " + 151 | "{ConnectionString}", connectionString); 152 | 153 | DbConnection? db = null; 154 | 155 | // First try to open an existing database 156 | if (!config.MemoryOnly && System.IO.File.Exists(config.CachePath)) 157 | { 158 | logger.LogTrace("Found existing database at {CachePath}", config.CachePath); 159 | 160 | db = new DbConnection(config.ConnectionString); 161 | db.Open(); 162 | 163 | if (!CheckExistingDb(db, logger)) 164 | { 165 | logger.LogTrace("Closing connection to SQLite database at {SqliteCacheDbPath}", config.CachePath); 166 | db.Close(); 167 | db.Dispose(); 168 | db = null; 169 | 170 | logger.LogInformation("Deleting existing incompatible cache db file {CachePath}", config.CachePath); 171 | System.IO.File.Delete(config.CachePath); 172 | } 173 | } 174 | 175 | if (db is null) 176 | { 177 | db = new DbConnection(config.ConnectionString); 178 | db.Open(); 179 | Initialize(config, db, logger); 180 | } 181 | 182 | // Explicitly set default journal mode and fsync behavior 183 | using (var cmd = new DbCommand("PRAGMA journal_mode = WAL;", db)) 184 | { 185 | cmd.ExecuteNonQuery(); 186 | } 187 | using (var cmd = new DbCommand("PRAGMA synchronous = NORMAL;", db)) 188 | { 189 | cmd.ExecuteNonQuery(); 190 | } 191 | 192 | return db; 193 | } 194 | 195 | private static void Initialize(SqliteCacheOptions config, DbConnection db, ILogger logger) 196 | { 197 | logger.LogInformation("Initializing db cache: {ConnectionString}", 198 | config.ConnectionString); 199 | 200 | using (var transaction = db.BeginTransaction()) 201 | { 202 | using (var cmd = new DbCommand(Resources.TableInitCommand, db)) 203 | { 204 | cmd.Transaction = transaction; 205 | cmd.ExecuteNonQuery(); 206 | } 207 | using (var cmd = new DbCommand( 208 | $"INSERT INTO meta (key, value) " + 209 | $"VALUES " + 210 | $"('version', {SchemaVersion}), " + 211 | $"('created', {DateTimeOffset.UtcNow.Ticks})", db)) 212 | { 213 | cmd.Transaction = transaction; 214 | cmd.ExecuteNonQuery(); 215 | } 216 | transaction.Commit(); 217 | } 218 | } 219 | 220 | // Some day, Microsoft will deign it useful to add async service initializers and we can 221 | // bring this code back to the light of day. 222 | #if false 223 | private async Task CheckExistingDbAsync(DbConnection db, CancellationToken cancel) 224 | { 225 | try 226 | { 227 | // Check for correct structure 228 | using (var cmd = new DbCommand(@"SELECT COUNT(*) from sqlite_master", db)) 229 | { 230 | var result = (long)await cmd.ExecuteScalarAsync(cancel).ConfigureAwait(false); 231 | // We are expecting two tables and one additional index 232 | if (result != 3) 233 | { 234 | _logger.LogWarning("Incorrect/incompatible existing cache db structure found!"); 235 | return false; 236 | } 237 | } 238 | 239 | // Check for correct version 240 | using (var cmd = new DbCommand("SELECT value FROM meta WHERE key = 'version'", db)) 241 | { 242 | var result = (long)await cmd.ExecuteScalarAsync(cancel).ConfigureAwait(false); 243 | if (result != SchemaVersion) 244 | { 245 | _logger.LogWarning("Existing cache db has unsupported schema version {SchemaVersion}", 246 | result); 247 | return false; 248 | } 249 | } 250 | } 251 | catch (Exception ex) 252 | { 253 | _logger.LogError(ex, "Error while checking compatibilty of existing cache db!"); 254 | return false; 255 | } 256 | 257 | return true; 258 | } 259 | public async ValueTask ConnectAsync(CancellationToken cancel) 260 | { 261 | if (_db == null) 262 | { 263 | var connectionString = _config.ConnectionString; 264 | _logger.LogTrace("Opening connection to SQLite database: " + 265 | "{ConnectionString}", connectionString); 266 | 267 | // First try to open an existing database 268 | if (!_config.MemoryOnly && System.IO.File.Exists(_config.CachePath)) 269 | { 270 | _logger.LogTrace("Found existing database at {CachePath}", _config.CachePath); 271 | 272 | var db = new SqliteConnection(_config.ConnectionString); 273 | await db.OpenAsync().ConfigureAwait(false); 274 | if (await CheckExistingDbAsync(db, cancel).ConfigureAwait(false)) 275 | { 276 | // Everything checks out, we can use this as our cache db 277 | _db = db; 278 | } 279 | else 280 | { 281 | db?.Dispose(); 282 | db?.Close(); 283 | 284 | _logger.LogInformation("Deleting existing incompatible cache db file {CachePath}", _config.CachePath); 285 | System.IO.File.Delete(_config.CachePath); 286 | } 287 | } 288 | 289 | if (_db == null) 290 | { 291 | _db = new DbConnection(_config.ConnectionString); 292 | await _db.OpenAsync().ConfigureAwait(false); 293 | await InitializeAsync(cancel).ConfigureAwait(false); 294 | } 295 | 296 | Commands = new DbCommandPool(_db, _logger); 297 | } 298 | } 299 | 300 | private async Task InitializeAsync(CancellationToken cancel) 301 | { 302 | _logger.LogInformation("Initializing db cache: {ConnectionString}", 303 | _config.ConnectionString); 304 | 305 | using (var transaction = _db.BeginTransaction()) 306 | { 307 | using (var cmd = new DbCommand(Resources.TableInitCommand, _db)) 308 | { 309 | cmd.Transaction = transaction; 310 | await cmd.ExecuteNonQueryAsync(cancel).ConfigureAwait(false); 311 | } 312 | using (var cmd = new DbCommand( 313 | $"INSERT INTO meta (key, value) " + 314 | $"VALUES " + 315 | $"('version', {SchemaVersion}), " + 316 | $"('created', {DateTimeOffset.UtcNow.Ticks})" , _db)) 317 | { 318 | cmd.Transaction = transaction; 319 | await cmd.ExecuteNonQueryAsync(cancel).ConfigureAwait(false); 320 | } 321 | transaction.Commit(); 322 | } 323 | } 324 | #endif 325 | #endregion 326 | 327 | public byte[]? Get(string key) 328 | { 329 | return (byte[])Commands.Use(Operation.Get, cmd => 330 | { 331 | cmd.Parameters.AddWithValue("@key", key); 332 | cmd.Parameters.AddWithValue("@now", DateTimeOffset.UtcNow.Ticks); 333 | return cmd.ExecuteScalar(); 334 | })!; 335 | } 336 | 337 | public async Task GetAsync(string key, CancellationToken cancel = default) 338 | { 339 | return (byte[])(await Commands.UseAsync(Operation.Get, cmd => 340 | { 341 | cmd.Parameters.AddWithValue("@key", key); 342 | cmd.Parameters.AddWithValue("@now", DateTimeOffset.UtcNow.Ticks); 343 | return cmd.ExecuteScalarAsync(cancel); 344 | }).ConfigureAwait(false))!; 345 | } 346 | 347 | public void Refresh(string key) 348 | { 349 | Commands.Use(Operation.Refresh, cmd => 350 | { 351 | cmd.Parameters.AddWithValue("@key", key); 352 | cmd.Parameters.AddWithValue("@now", DateTimeOffset.UtcNow.Ticks); 353 | return cmd.ExecuteScalar(); 354 | }); 355 | } 356 | 357 | public Task RefreshAsync(string key, CancellationToken cancel = default) 358 | { 359 | return Commands.UseAsync(Operation.Refresh, cmd => 360 | { 361 | cmd.Parameters.AddWithValue("@key", key); 362 | cmd.Parameters.AddWithValue("@now", DateTimeOffset.UtcNow.Ticks); 363 | return cmd.ExecuteScalarAsync(cancel); 364 | }); 365 | } 366 | 367 | public void Remove(string key) 368 | { 369 | Commands.Use(Operation.Remove, cmd => 370 | { 371 | cmd.Parameters.AddWithValue("@key", key); 372 | cmd.ExecuteNonQuery(); 373 | }); 374 | } 375 | 376 | public Task RemoveAsync(string key, CancellationToken cancel = default) 377 | { 378 | return Commands.UseAsync(Operation.Remove, cmd => 379 | { 380 | cmd.Parameters.AddWithValue("@key", key); 381 | return cmd.ExecuteNonQueryAsync(cancel); 382 | }); 383 | } 384 | 385 | private void CreateForSet(DbCommand cmd, string key, byte[] value, DistributedCacheEntryOptions options) 386 | { 387 | cmd.Parameters.AddWithValue("@key", key); 388 | cmd.Parameters.AddWithValue("@value", value); 389 | 390 | AddExpirationParameters(cmd, options); 391 | } 392 | 393 | private void CreateBulkInsert(DbCommand cmd, IEnumerable> keyValues, DistributedCacheEntryOptions options) 394 | { 395 | StringBuilder sb = new StringBuilder(); 396 | sb.AppendLine(DbCommands.Commands[(int)Operation.BulkInsert]); 397 | int i = 0; 398 | foreach (var pair in keyValues) 399 | { 400 | sb.Append($"(@key{i}, @value{i}, @expiry, @renewal),"); 401 | cmd.Parameters.AddWithValue($"@key{i}", pair.Key); 402 | cmd.Parameters.AddWithValue($"@value{i}", pair.Value); 403 | i++; 404 | } 405 | sb.Remove(sb.Length - 1, 1); 406 | sb.Append(";"); 407 | 408 | AddExpirationParameters(cmd, options); 409 | 410 | cmd.CommandText = sb.ToString(); 411 | } 412 | 413 | public void Clear() 414 | { 415 | Commands.Use(conn => 416 | { 417 | using var cmd = new DbCommand("DELETE FROM cache WHERE 1=1;", conn); 418 | cmd.ExecuteNonQuery(); 419 | return true; 420 | }); 421 | } 422 | 423 | public Task ClearAsync(CancellationToken cancel = default) 424 | { 425 | return Commands.UseAsync(async conn => 426 | { 427 | using var cmd = new DbCommand("DELETE FROM cache WHERE 1=1;", conn); 428 | await cmd.ExecuteNonQueryAsync(cancel).ConfigureAwait(false); 429 | return true; 430 | }); 431 | } 432 | 433 | private void AddExpirationParameters(DbCommand cmd, DistributedCacheEntryOptions options) 434 | { 435 | DateTimeOffset? expiry = null; 436 | TimeSpan? renewal = null; 437 | 438 | if (options.AbsoluteExpiration.HasValue) 439 | { 440 | expiry = options.AbsoluteExpiration.Value.ToUniversalTime(); 441 | } 442 | else if (options.AbsoluteExpirationRelativeToNow.HasValue) 443 | { 444 | expiry = DateTimeOffset.UtcNow 445 | .Add(options.AbsoluteExpirationRelativeToNow.Value); 446 | } 447 | 448 | if (options.SlidingExpiration.HasValue) 449 | { 450 | renewal = options.SlidingExpiration.Value; 451 | expiry = (expiry ?? DateTimeOffset.UtcNow) + renewal; 452 | } 453 | 454 | cmd.Parameters.AddWithValue("@expiry", expiry?.Ticks ?? (object)DBNull.Value); 455 | cmd.Parameters.AddWithValue("@renewal", renewal?.Ticks ?? (object)DBNull.Value); 456 | } 457 | 458 | public void Set(string key, byte[] value, DistributedCacheEntryOptions options) 459 | { 460 | Commands.Use(Operation.Insert, cmd => 461 | { 462 | CreateForSet(cmd, key, value, options); 463 | cmd.ExecuteNonQuery(); 464 | }); 465 | } 466 | 467 | public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, 468 | CancellationToken cancel = default) 469 | { 470 | return Commands.UseAsync(Operation.Insert, cmd => 471 | { 472 | CreateForSet(cmd, key, value, options); 473 | return cmd.ExecuteNonQueryAsync(cancel); 474 | }); 475 | } 476 | 477 | public void SetBulk(IEnumerable> keyValues, DistributedCacheEntryOptions options) 478 | { 479 | if (keyValues is null || !keyValues.Any()) 480 | { 481 | return; 482 | } 483 | 484 | Commands.Use(Operation.BulkInsert, cmd => 485 | { 486 | CreateBulkInsert(cmd, keyValues, options); 487 | return cmd.ExecuteNonQuery(); 488 | }); 489 | } 490 | 491 | public Task SetBulkAsync(IEnumerable> keyValues, DistributedCacheEntryOptions options, 492 | CancellationToken cancel = default) 493 | { 494 | if (keyValues is null || !keyValues.Any()) 495 | { 496 | return Task.CompletedTask; 497 | } 498 | 499 | return Commands.UseAsync(Operation.BulkInsert, cmd => 500 | { 501 | CreateBulkInsert(cmd, keyValues, options); 502 | return cmd.ExecuteNonQueryAsync(cancel); 503 | }); 504 | } 505 | 506 | public void RemoveExpired() 507 | { 508 | var removed = (long)Commands.Use(Operation.RemoveExpired, cmd => 509 | { 510 | cmd.Parameters.AddWithValue("@now", DateTimeOffset.UtcNow.Ticks); 511 | return cmd.ExecuteScalar(); 512 | })!; 513 | 514 | if (removed > 0) 515 | { 516 | _logger.LogTrace("Evicted {DeletedCacheEntryCount} expired entries from cache", removed); 517 | } 518 | } 519 | 520 | public async Task RemoveExpiredAsync(CancellationToken cancel = default) 521 | { 522 | var removed = (long)(await Commands.UseAsync(Operation.RemoveExpired, cmd => 523 | { 524 | cmd.Parameters.AddWithValue("@now", DateTimeOffset.UtcNow.Ticks); 525 | return cmd.ExecuteScalarAsync(cancel); 526 | }).ConfigureAwait(false))!; 527 | 528 | if (removed > 0) 529 | { 530 | _logger.LogTrace("Evicted {DeletedCacheEntryCount} expired entries from cache", removed); 531 | } 532 | } 533 | } 534 | } 535 | -------------------------------------------------------------------------------- /SqliteCache/SqliteCache.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | netstandard2.0;net6.0;net8.0 6 | 9.0 7 | NeoSmart.Caching.Sqlite 8 | NeoSmart.Caching.Sqlite 9 | 9.0.1 10 | enable 11 | Mahmoud Al-Qudsi, neosmart, mqudsi 12 | NeoSmart Technologies 13 | SqliteCache 14 | An SQLite-backed IDistributedCache cache implementation for ASP.NET Core. Fast, transparent, persistent caching. 15 | NeoSmart Technologies 2019-2025 16 | MIT 17 | https://neosmart.net/blog/2019/sqlite-cache-for-asp-net-core 18 | https://github.com/neosmart/AspSqliteCache 19 | git 20 | idistributedcache, cache, sqlite, sqlitecache, distributed, aspnetcore, performance 21 | true 22 | NeoSmart.Caching.Sqlite.snk 23 | true 24 | true 25 | snupkg 26 | README.md 27 | true 28 | 29 | 30 | 31 | 32 | Version 7.0: 33 | - SqliteCache no longer depends on SQLitePCLRaw.bundle_green. 34 | 35 | ASP.NET users should install companion package NeoSmart.Caching.Sqlite.AspNetCore. 36 | Other .NET Core users will need to install either SQLitePCLRaw.bundle_green and call 37 | `SQLitePCL.Batteries.Init()` before instantiating SqliteCache, or else install the 38 | correct SQLitePCLRaw.provider.xxx version that matches the target platform and call 39 | `SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_whatever())` before using 40 | SqliteCache. 41 | 42 | Version 6.1: 43 | - Add methods to clear the cache (SqliteCache.Clear() and SqliteCache.ClearAsync()) 44 | - SQLitePCLRaw upgraded to 2.1.4 45 | - Compatible w/ SQLite builds compiled with SQLITE_DSQ=0 46 | 47 | Version 6.0: 48 | - Updates all Entity Framework and Microsoft Extension dependencies to v6.x 49 | 50 | Version 5.0: 51 | - WAL mode and normal synchronization pragmas are set at startup, dramatically improving performance 52 | - ILogger constructor parameter is now optional 53 | - A separate SqliteConnection is used for each SqlCommand instance, fixing threading issues under UWP 54 | - Makes it possible to inject SqliteCache directly (rather than only as IDistributedCache) 55 | - Adds IAsyncDisposable implementation for .NET Core 3.1 and above 56 | - Adds a bulk insert option to insert many key-value pairs quickly (credit to first-time contributor Elias Baumgartner aka @Rap22tor) 57 | - Fixes an issue w/ incorrect handling of user-specified non-UTC expiration dates (credit to first-time contributor Ravindu Liyanapathirana, aka @ravindUwU) 58 | 59 | Version 3.1: 60 | - Added .netcoreapp3.1 target 61 | - Switched to SQLitePCLRaw.bundle_e_sqlite3 (same as .NET Core 3.1 web projects) 62 | - SqliteCache is now a sealed class to prevent dispose problems if derived 63 | - Version number aligns with .NET Core semantic versioning 64 | 65 | 66 | 67 | 68 | 69 | True 70 | \ 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | True 85 | True 86 | Resources.resx 87 | 88 | 89 | 90 | 91 | 92 | ResXFileCodeGenerator 93 | Resources.Designer.cs 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /SqliteCache/SqliteCacheOptions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Data.Sqlite; 2 | using Microsoft.Extensions.Options; 3 | using System; 4 | 5 | namespace NeoSmart.Caching.Sqlite 6 | { 7 | public record SqliteCacheOptions : IOptions 8 | { 9 | SqliteCacheOptions IOptions.Value => this; 10 | 11 | /// 12 | /// Configures SQLite to use a temporary (non-persistent) memory-backed database. Defaults to false. 13 | ///
14 | /// Takes precedence over 15 | ///
16 | public bool MemoryOnly { get; set; } = false; 17 | 18 | private string _cachePath = "SqliteCache.db"; 19 | /// 20 | /// The path where the SQLite database should be persisted. Must have read/write permissions; does not need to already exist. 21 | ///
22 | /// Used only if is false. 23 | ///
24 | public string CachePath 25 | { 26 | get => _cachePath; 27 | set 28 | { 29 | // User might have passed a connection string instead of a data source 30 | if (value.StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase)) 31 | { 32 | value = value.Replace("Data Source=", ""); 33 | } 34 | if (value.Contains("=") || value.Contains("\"")) 35 | { 36 | throw new ArgumentException("CachePath must be a path and not a connection string!"); 37 | } 38 | _cachePath = value.Trim(); 39 | } 40 | } 41 | 42 | /// 43 | /// Specifies how often expired items are removed in the background. 44 | /// Background eviction is disabled if set to null. 45 | /// 46 | public TimeSpan? CleanupInterval { get; set; } = TimeSpan.FromMinutes(30); 47 | 48 | internal string ConnectionString 49 | { 50 | get 51 | { 52 | var sb = new SqliteConnectionStringBuilder 53 | { 54 | DataSource = MemoryOnly ? ":memory:" : CachePath, 55 | Mode = MemoryOnly ? SqliteOpenMode.Memory : SqliteOpenMode.ReadWriteCreate, 56 | Cache = SqliteCacheMode.Shared 57 | }; 58 | 59 | return sb.ConnectionString; 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /SqliteCache/SqliteCacheServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Caching.Distributed; 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace NeoSmart.Caching.Sqlite 6 | { 7 | public static class SqliteCacheServiceCollectionExtensions 8 | { 9 | /// 10 | /// Registers SqliteCache as a dependency-injected singleton, available 11 | /// both as IDistributedCache and SqliteCache. 12 | ///

13 | /// If you're using ASP.NET Core, install NeoSmart.Caching.Sqlite.AspNetCore 14 | /// and add using namespace NeoSmart.Caching.Sqlite.AspNetCore to get a version 15 | /// of this method that does not require the sqlite3Provider parameter. 16 | ///
17 | /// 18 | public static IServiceCollection AddSqliteCache(this IServiceCollection services, 19 | Action setupAction, SQLitePCL.ISQLite3Provider sqlite3Provider) 20 | { 21 | if (services is null) 22 | { 23 | throw new ArgumentNullException(nameof(services)); 24 | } 25 | else if (setupAction is null) 26 | { 27 | throw new ArgumentNullException(nameof(setupAction)); 28 | } 29 | 30 | services.AddOptions(); 31 | services.AddSingleton(); 32 | services.AddSingleton(services => services.GetRequiredService()); 33 | services.Configure(setupAction); 34 | return services; 35 | } 36 | 37 | /// 38 | /// Registers SqliteCache as a dependency-injected singleton, available 39 | /// both as IDistributedCache and SqliteCache. 40 | ///

41 | /// If you're using ASP.NET Core, install NeoSmart.Caching.Sqlite.AspNetCore 42 | /// and add using namespace NeoSmart.Caching.Sqlite.AspNetCore to get a version 43 | /// of this method that does not require the sqlite3Provider parameter. 44 | ///
45 | /// 46 | /// The path where the SQLite database should be stored. It 47 | /// is created if it does not exist. (The path should be a file path, not a 48 | /// directory. Make sure the application has RW access at runtime.) 49 | /// 50 | public static IServiceCollection AddSqliteCache(this IServiceCollection services, 51 | string path, SQLitePCL.ISQLite3Provider sqlite3Provider) 52 | { 53 | return AddSqliteCache(services, options => options.CachePath = path, sqlite3Provider); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /publish.fish: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env fish 2 | 3 | function csproj_field 4 | set csproj $argv[1] 5 | set field $argv[2] 6 | set optional $argv[3] 7 | set value (string replace -rf "\\s*<$field>(.*)" '$1' < $csproj | string trim)[1] 8 | 9 | if ! string match -qr -- '.' $value 10 | echo "Could not extract value of $field from $csproj" 1>&2 11 | if ! string match -q optional $optional 12 | exit 1 13 | else 14 | return 1 15 | end 16 | end 17 | 18 | echo $value 19 | end 20 | 21 | function publish_csproj 22 | set csproj $argv[1] 23 | if ! test -f $csproj 24 | echo "Could not find project file $csproj!" 1>&2 25 | exit 1 26 | end 27 | 28 | set -l pkgname 29 | if ! set pkgname (csproj_field $csproj "PackageId" optional) 30 | set pkgname (csproj_field $csproj "AssemblyName") 31 | echo "Using AssemblyName $pkgname instead of PackageId!" 1>&2 32 | end 33 | set -l pkgversion (csproj_field $csproj "Version") 34 | 35 | if ! dotnet build -c Release -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg $csproj 36 | exit 1 37 | end 38 | 39 | set nupkg (dirname $csproj)/bin/Release/$pkgname.$pkgversion.nupkg 40 | if ! test -f $nupkg 41 | echo "Could not find nuget package $nupkg!" 1>&2 42 | exit 1 43 | end 44 | 45 | set snupkg (dirname $csproj)/bin/Release/$pkgname.$pkgversion.snupkg 46 | if ! test -f $nupkg 47 | echo "Could not find nuget symbol package $snupkg!" 1>&2 48 | exit 1 49 | end 50 | 51 | if ! nuget push $nupkg #-source https://int.nugettest.org 52 | exit 1 53 | end 54 | 55 | if ! nuget push $snupkg #-source https://int.nugettest.org 56 | exit 1 57 | end 58 | 59 | end 60 | 61 | if string match -qr -- . $argv[1] 62 | set csproj $argv[1] 63 | publish_csproj $csproj 64 | else 65 | publish_csproj ./SqliteCache/*.csproj 66 | publish_csproj ./SqliteCache.AspNetCore/*.csproj 67 | end 68 | --------------------------------------------------------------------------------