├── .github └── workflows │ └── build-deploy.yml ├── .gitignore ├── LICENSE ├── README.md └── src ├── ReactorData.EFCore ├── Implementation │ └── Storage.cs ├── ReactorData.EFCore.csproj └── ServiceCollectionExtensions.cs ├── ReactorData.Maui ├── DirectoryInfoExtensions.cs ├── Dispatcher.cs ├── Platforms │ ├── Android │ │ └── PathProvider.cs │ ├── MacCatalyst │ │ └── PathProvider.cs │ ├── Tizen │ │ └── PathProvider.cs │ ├── Windows │ │ └── PathProvider.cs │ └── iOS │ │ └── PathProvider.cs ├── ReactorData.Maui.csproj └── ServiceCollectionExtensions.cs ├── ReactorData.SourceGenerators ├── ModelPartialClassSourceGenerator.cs ├── ReactorData.SourceGenerators.csproj └── ValidateExtensions.cs ├── ReactorData.Sqlite ├── Implementation │ └── Storage.cs ├── ReactorData.Sqlite.csproj ├── ServiceCollectionExtensions.cs ├── StorageConfiguration.cs └── ValidateExtensions.cs ├── ReactorData.Tests ├── BasicEfCoreStorageTests.cs ├── BasicSqliteStorageTests.cs ├── BasicTests.cs ├── GlobalUsings.cs ├── LoadingEfCoreStorageTests.cs ├── LoadingSqliteStorageTests.cs ├── Models │ ├── Blog.cs │ ├── Director.cs │ ├── Migrations │ │ ├── 20240108180422_Initial.Designer.cs │ │ ├── 20240108180422_Initial.cs │ │ ├── 20240127182915_Movies.Designer.cs │ │ ├── 20240127182915_Movies.cs │ │ └── TestDbContextModelSnapshot.cs │ ├── Movie.cs │ ├── TestDbContext.cs │ └── Todo.cs ├── QueryEfCoreStorageTests.cs ├── QuerySqliteStorageTests.cs ├── QueryTests.cs └── ReactorData.Tests.csproj ├── ReactorData.sln └── ReactorData ├── AsyncAutoResetEvent.cs ├── IDispatcher.cs ├── IEntity.cs ├── IModelContext.cs ├── IPathProvider.cs ├── IQuery.cs ├── IStorage.cs ├── Implementation ├── ModelContext.Loading.cs ├── ModelContext.Operation.cs ├── ModelContext.cs ├── ObservableRangeCollection.cs └── Query.cs ├── ListExtensions.cs ├── ModelAttribute.cs ├── ModelContextOptions.cs ├── Properties └── launchSettings.json ├── ReactorData.csproj ├── ServiceCollectionExtensions.cs └── ValidateExtensions.cs /.github/workflows/build-deploy.yml: -------------------------------------------------------------------------------- 1 | 2 | name: ReactorData 3 | 4 | on: 5 | push: 6 | branches: [ "main" ] 7 | 8 | jobs: 9 | 10 | build: 11 | 12 | runs-on: windows-latest # For a list of available runner types, refer to 13 | # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idruns-on 14 | 15 | env: 16 | Solution_Name: ./src/ReactorData.sln 17 | Version: 1.0.29 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 0 24 | 25 | # Install the .NET Core workload 26 | - name: Install .NET Core 27 | uses: actions/setup-dotnet@v3 28 | with: 29 | dotnet-version: 8.0.100 30 | 31 | - name: Install MAUI workload 32 | run: dotnet workload install maui 33 | 34 | - name: Clean output directory 35 | run: dotnet clean $env:Solution_Name -c Release 36 | 37 | - name: Build the packages 38 | run: dotnet build $env:Solution_Name -c Release /p:Version=$env:Version 39 | 40 | - name: Test the solution 41 | run: dotnet test $env:Solution_Name -c Release 42 | 43 | - name: Push Package to NuGet.org 44 | run: dotnet nuget push **/*.nupkg -k ${{ secrets.NUGETAPIKEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate 45 | 46 | 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml 399 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 adospace 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReactorData 2 | 3 | [![Nuget](https://img.shields.io/nuget/v/ReactorData)](https://www.nuget.org/packages/ReactorData) 4 | 5 | ReactorData is a fast, easy-to-use, low ceremony data container for your next .NET app. 6 | 7 | Designed after SwiftData, it helps developers to persist data in UI applications abstracting the process of reading/writing to and from the storage. 8 | 9 | 1) Perfectly suited for declarative UI programming (MauiReactor, C# Markup, Comet etc) but can also be integrated in MVVM-base apps 10 | 2) Currently, it works with EFCore (Sqlite, SqlServer etc) or directly with Sqlite. 11 | 3) Easily expandable, you can create plugins for other storage libraries like LiteDB. You can even use a json file to store your models. 12 | 4) Non-intrusive, use your models 13 | 14 | ReactorData can be used in any application .NET8+ 15 | 16 | ## How it works: 17 | 18 | Install ReactorData 19 | 20 | ``` 21 | 22 | ``` 23 | 24 | Add it to your DI container 25 | 26 | ```csharp 27 | services.AddReactorData(); 28 | ``` 29 | 30 | Define your models using the `[Model]` attribute: 31 | 32 | ```csharp 33 | [Model] 34 | partial class Todo 35 | { 36 | public int Id { get; set; } 37 | public string Title { get; set; } 38 | public bool Done { get; set; } 39 | } 40 | ``` 41 | 42 | Get the model context from the container 43 | 44 | ```csharp 45 | var modelContext = serviceProvider.GetService(); 46 | ``` 47 | 48 | Create a Query for the model: 49 | 50 | ```csharp 51 | var query = modelContext.Query(); 52 | ``` 53 | 54 | A query is an object implementing INotifyCollectionChanged that notifies subscribers of any change to the list of models contained in the context. 55 | You can freely create a query with custom linq, ordering results as you prefer: 56 | 57 | ```csharp 58 | var query = modelContext.Query(_=>_.Where(x => x.Done).OrderBy(x => x.Title)); 59 | ``` 60 | Notifications are raised only for those models that pass the filter and in the order specified. 61 | 62 | Now just add an entity to your context and the query will receive the notification. 63 | 64 | ```csharp 65 | modelContext.Add(new Todo { Title = "Task 1" }); 66 | modelContext.Save(); 67 | ``` 68 | 69 | Note that the `Save()` function is synchronous, it just signals to the context that you modified it. Entities are sent to the storage in a separate background thread. 70 | 71 | ## Sqlite storage 72 | 73 | Without storage, ReactorData is more or less a state manager, keeping all the entities in memory. 74 | 75 | Things are more interesting when configuring a storage plugin, such as Sqlite. 76 | 77 | ``` 78 | 79 | ``` 80 | Configure the plugin by passing a connection string and the models you want it to manage: 81 | 82 | ```csharp 83 | using ReactorData.Sqlite; 84 | services.AddReactor( 85 | connectionString: $"Data Source=todo.db", 86 | configure: _ => _.Model() 87 | ); 88 | ``` 89 | 90 | With these changes, entities are automatically saved as they are added/modified or deleted. 91 | 92 | The last thing to do is to load Todo models from the storage when the app starts. 93 | You can decide when and which models to load, in this case, we want to load all the todo models at context startup. 94 | 95 | ```csharp 96 | using ReactorData.Sqlite; 97 | services.AddReactorData( 98 | connectionString: $"Data Source={_dbPath}", 99 | configure: _ => _.Model(), 100 | modelContextConfigure: options => 101 | { 102 | options.ConfigureContext = context => context.Load(); 103 | }); 104 | ``` 105 | 106 | IModelContext.Load() accepts a linq query that lets you specify which records to load. 107 | 108 | 109 | ## EFCore storage 110 | 111 | The EFCore plugin allows you to use whatever supported data store you like (Sqlite, SQLServer, etc). Moreover, you can efficiently manage related entities. 112 | Note that ReactorData works on top of EFCore without interfering with your existing code, which you may already use. 113 | 114 | This is how to configure it: 115 | ``` 116 | 117 | ``` 118 | 119 | ```csharp 120 | using ReactorData.EFCore; 121 | services.AddReactorData(, 122 | modelContextConfigure: options => 123 | { 124 | options.ConfigureContext = context => context.Load(); 125 | }); 126 | ``` 127 | 128 | TodoDbContext is a normal DbContext like this: 129 | 130 | ```csharp 131 | class TodoDbContext: DbContext 132 | { 133 | public DbSet Blogs => Set(); 134 | 135 | public TestDbContext(DbContextOptions options) : base(options) 136 | { } 137 | } 138 | ``` 139 | 140 | ## MauiReactor 141 | 142 | ReactorData perfectly integrates with MauiReactor (https://github.com/adospace/reactorui-maui) 143 | 144 | This is a sample todo application featuring ReactorData: 145 | https://github.com/adospace/mauireactor-samples/tree/main/TodoApp 146 | 147 | 148 | 149 | https://github.com/adospace/reactor-data/assets/10573253/58dc1262-50f8-429e-ac69-6de46de5115f 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | -------------------------------------------------------------------------------- /src/ReactorData.EFCore/Implementation/Storage.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.ChangeTracking; 3 | using Microsoft.EntityFrameworkCore.Metadata.Internal; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Logging; 6 | using SQLitePCL; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.Linq; 10 | using System.Linq.Expressions; 11 | using System.Runtime.CompilerServices; 12 | using System.Text; 13 | using System.Threading.Tasks; 14 | 15 | namespace ReactorData.EFCore.Implementation; 16 | 17 | 18 | class Storage : IStorage where T : DbContext 19 | { 20 | private readonly IServiceProvider _serviceProvider; 21 | private readonly ILogger>? _logger; 22 | private readonly SemaphoreSlim _semaphore = new(1); 23 | private bool _initialized; 24 | 25 | public Storage(IServiceProvider serviceProvider) 26 | { 27 | _serviceProvider = serviceProvider; 28 | _logger = serviceProvider.GetService>>(); 29 | } 30 | 31 | private async ValueTask Initialize(T dbContext) 32 | { 33 | if (_initialized) 34 | { 35 | return; 36 | } 37 | 38 | try 39 | { 40 | _semaphore.Wait(); 41 | 42 | if (_initialized) 43 | { 44 | return; 45 | } 46 | 47 | _logger?.LogTrace("Migrating context {DbContext}...", typeof(T)); 48 | 49 | await dbContext.Database.MigrateAsync(); 50 | 51 | _logger?.LogTrace("Context {DbContext} migrated", typeof(T)); 52 | 53 | _initialized = true; 54 | } 55 | catch (Exception ex) 56 | { 57 | _logger?.LogError(ex, "Exception raised when initializing the DbContext using migrations"); 58 | } 59 | finally 60 | { 61 | _semaphore.Release(); 62 | } 63 | } 64 | 65 | public async Task> Load(Func, IQueryable>? queryFunction = null) where TEntity : class, IEntity 66 | { 67 | using var serviceScope = _serviceProvider.CreateScope(); 68 | using var dbContext = serviceScope.ServiceProvider.GetRequiredService(); 69 | dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; 70 | dbContext.ChangeTracker.AutoDetectChangesEnabled = false; 71 | dbContext.ChangeTracker.LazyLoadingEnabled = false; 72 | 73 | await Initialize(dbContext); 74 | 75 | try 76 | { 77 | IQueryable query = dbContext.Set().AsNoTracking(); 78 | 79 | if (queryFunction != null) 80 | { 81 | query = queryFunction(query); 82 | } 83 | 84 | return await query.ToListAsync(); 85 | } 86 | catch (Exception ex) 87 | { 88 | _logger?.LogError(ex, "Unable to load entities of type {EntityType}", typeof(TEntity)); 89 | return []; 90 | } 91 | } 92 | 93 | public async Task Save(IEnumerable operations) 94 | { 95 | 96 | try 97 | { 98 | using var serviceScope = _serviceProvider.CreateScope(); 99 | using var dbContext = serviceScope.ServiceProvider.GetRequiredService(); 100 | dbContext.ChangeTracker.Clear(); 101 | dbContext.ChangeTracker.AutoDetectChangesEnabled = false; 102 | dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; 103 | dbContext.ChangeTracker.LazyLoadingEnabled = false; 104 | 105 | await Initialize(dbContext); 106 | 107 | _logger?.LogTrace("Apply changes for {Entities} entities", operations.Count()); 108 | 109 | foreach (var operation in operations) 110 | { 111 | foreach (var entity in operation.Entities) 112 | { 113 | var entry = dbContext.Entry(entity); 114 | 115 | entry.State = operation switch 116 | { 117 | StorageAdd => EntityState.Added, 118 | StorageUpdate => EntityState.Modified, 119 | StorageDelete => EntityState.Deleted, 120 | _ => EntityState.Unchanged 121 | }; 122 | 123 | // Mark navigation properties as unchanged to prevent tracking 124 | foreach (var navigation in entry.Navigations.OfType()) 125 | { 126 | navigation.IsModified = false; 127 | } 128 | 129 | //dbContext.Attach(entity); 130 | 131 | //switch (operation) 132 | //{ 133 | // case StorageAdd storageInsert: 134 | // dbContext.Entry(entity).State = EntityState.Added; 135 | // _logger?.LogTrace("Insert entity {EntityId} ({EntityType})", ((IEntity)entity).GetKey(), entity.GetType()); 136 | // break; 137 | // case StorageUpdate storageUpdate: 138 | // dbContext.Entry(entity).State = EntityState.Modified; 139 | // _logger?.LogTrace("Update entity {EntityId} ({EntityType})", ((IEntity)entity).GetKey(), entity.GetType()); 140 | // break; 141 | // case StorageDelete storageDelete: 142 | // dbContext.Entry(entity).State = EntityState.Deleted; 143 | // _logger?.LogTrace("Delete entity {EntityId} ({EntityType})", ((IEntity)entity).GetKey(), entity.GetType()); 144 | // break; 145 | //} 146 | } 147 | } 148 | 149 | await dbContext.SaveChangesAsync(); 150 | 151 | _logger?.LogTrace("Apply changes for {Entities} entities completed", operations.Count()); 152 | } 153 | catch (Exception ex) 154 | { 155 | _logger?.LogError(ex, "Saving changes to context resulted in an unhandled exception ({Operations})", System.Text.Json.JsonSerializer.Serialize(operations)); 156 | throw; 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/ReactorData.EFCore/ReactorData.EFCore.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 0.0.1 8 | adospace 9 | ReactorData.EFCore is the EFCore based plugin for ReactorData. 10 | Adolfo Marinucci 11 | https://github.com/adospace/reactor-data 12 | MIT 13 | https://github.com/adospace/reactor-data 14 | database storage persistance mvu UI 15 | true 16 | false 17 | ReactorData.EFCore 18 | true 19 | RS1036,NU1903,NU1902,NU1901 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/ReactorData.EFCore/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using ReactorData.Implementation; 4 | 5 | namespace ReactorData.EFCore; 6 | 7 | public static class ServiceCollectionExtensions 8 | { 9 | /// 10 | /// Add ReactorData services with EF Core storage 11 | /// 12 | /// DbContext-derived type that describe the Ef Core database context 13 | /// Service collection to modify 14 | /// Action called when the database context needs to be configured 15 | /// Action called when the needs to be configured 16 | public static void AddReactorData(this IServiceCollection services, 17 | Action? optionsAction = null, 18 | Action? modelContextConfigure = null) where T : DbContext 19 | { 20 | services.AddReactorData(modelContextConfigure); 21 | services.AddDbContext(optionsAction); 22 | services.AddSingleton>(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/ReactorData.Maui/DirectoryInfoExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | using System.Linq; 4 | 5 | //NOTE: part of this code is freely taken from https://github.com/reactiveui/Akavache/blob/main/src/Akavache.Core/Platforms/shared/Utility.cs 6 | 7 | namespace ReactorData.Maui; 8 | 9 | // All the code in this file is included in all platforms. 10 | internal static class DirectoryInfoExtensions 11 | 12 | { 13 | public static void CreateRecursive(this DirectoryInfo directoryInfo) => 14 | _ = directoryInfo.SplitFullPath().Aggregate((parent, dir) => 15 | { 16 | var path = Path.Combine(parent, dir); 17 | 18 | if (!Directory.Exists(path)) 19 | { 20 | Directory.CreateDirectory(path); 21 | } 22 | 23 | return path; 24 | }); 25 | 26 | public static IEnumerable SplitFullPath(this DirectoryInfo directoryInfo) 27 | { 28 | var root = Path.GetPathRoot(directoryInfo.FullName); 29 | var components = new List(); 30 | for (var path = directoryInfo.FullName; path != root && path is not null; path = Path.GetDirectoryName(path)) 31 | { 32 | var filename = Path.GetFileName(path); 33 | if (string.IsNullOrEmpty(filename)) 34 | { 35 | continue; 36 | } 37 | 38 | components.Add(filename); 39 | } 40 | 41 | if (root is not null) 42 | { 43 | components.Add(root); 44 | } 45 | 46 | components.Reverse(); 47 | return components; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ReactorData.Maui/Dispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace ReactorData.Maui; 8 | 9 | internal class Dispatcher(Action? exceptionCallBack) : IDispatcher 10 | { 11 | private readonly Action? _exceptionCallBack = exceptionCallBack; 12 | 13 | public void Dispatch(Action action) 14 | { 15 | if (Microsoft.Maui.Controls.Application.Current == null) 16 | { 17 | return; 18 | } 19 | 20 | if (Microsoft.Maui.Controls.Application.Current.Dispatcher.IsDispatchRequired == true) 21 | { 22 | Microsoft.Maui.Controls.Application.Current.Dispatcher.Dispatch(action); 23 | } 24 | else 25 | { 26 | action(); 27 | } 28 | } 29 | 30 | public void OnError(Exception exception) 31 | { 32 | _exceptionCallBack?.Invoke(exception); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ReactorData.Maui/Platforms/Android/PathProvider.cs: -------------------------------------------------------------------------------- 1 | using Android.App; 2 | using System.IO; 3 | 4 | //NOTE: part of this code is freely taken from https://github.com/reactiveui/Akavache/blob/main/src/Akavache.Core/Platforms/android/AndroidFilesystemProvider.cs 5 | 6 | namespace ReactorData.Maui.Platforms.Android; 7 | 8 | 9 | /// 10 | /// The file system provider that understands the android. 11 | /// 12 | public class PathProvider : IPathProvider 13 | { 14 | /// 15 | public string? GetDefaultLocalMachineCacheDirectory() => Application.Context.CacheDir?.AbsolutePath; 16 | 17 | /// 18 | public string? GetDefaultRoamingCacheDirectory() => Application.Context.FilesDir?.AbsolutePath; 19 | 20 | /// 21 | public string? GetDefaultSecretCacheDirectory() 22 | { 23 | var path = Application.Context.FilesDir?.AbsolutePath; 24 | 25 | if (path is null) 26 | { 27 | return null; 28 | } 29 | 30 | var di = new DirectoryInfo(Path.Combine(path, "Secret")); 31 | if (!di.Exists) 32 | { 33 | di.CreateRecursive(); 34 | } 35 | 36 | return di.FullName; 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /src/ReactorData.Maui/Platforms/MacCatalyst/PathProvider.cs: -------------------------------------------------------------------------------- 1 | using Foundation; 2 | using System.IO; 3 | using System; 4 | 5 | namespace ReactorData.Maui.Platforms.MacCatalyst; 6 | 7 | public class PathProvider : IPathProvider 8 | { 9 | /// 10 | public string GetDefaultLocalMachineCacheDirectory() => CreateAppDirectory(NSSearchPathDirectory.CachesDirectory); 11 | 12 | /// 13 | public string GetDefaultRoamingCacheDirectory() => CreateAppDirectory(NSSearchPathDirectory.ApplicationSupportDirectory); 14 | 15 | /// 16 | public string GetDefaultSecretCacheDirectory() => CreateAppDirectory(NSSearchPathDirectory.ApplicationSupportDirectory, "SecretCache"); 17 | 18 | private string CreateAppDirectory(NSSearchPathDirectory targetDir, string subDir = "BlobCache") 19 | { 20 | using var fm = new NSFileManager(); 21 | var url = fm.GetUrl(targetDir, NSSearchPathDomain.All, null, true, out _) ?? throw new DirectoryNotFoundException(); 22 | var rp = url.RelativePath ?? throw new DirectoryNotFoundException(); 23 | var ret = Path.Combine(rp, "ReactorData", subDir); 24 | if (!Directory.Exists(ret)) 25 | { 26 | Directory.CreateDirectory(ret); 27 | } 28 | 29 | return ret; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/ReactorData.Maui/Platforms/Tizen/PathProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace ReactorData.Maui.Platforms.Tizen; 5 | 6 | public class PathProvider : IPathProvider 7 | { 8 | /// 9 | public string GetDefaultLocalMachineCacheDirectory() => Application.Current.DirectoryInfo.Cache; 10 | 11 | /// 12 | public string GetDefaultRoamingCacheDirectory() => Application.Current.DirectoryInfo.ExternalCache; 13 | 14 | /// 15 | public string GetDefaultSecretCacheDirectory() 16 | { 17 | var path = Application.Current.DirectoryInfo.ExternalCache; 18 | var di = new System.IO.DirectoryInfo(Path.Combine(path, "Secret")); 19 | if (!di.Exists) 20 | { 21 | di.CreateRecursive(); 22 | } 23 | 24 | return di.FullName; 25 | } 26 | } -------------------------------------------------------------------------------- /src/ReactorData.Maui/Platforms/Windows/PathProvider.cs: -------------------------------------------------------------------------------- 1 |  2 | using System; 3 | using System.IO; 4 | 5 | namespace ReactorData.Maui.Platforms.Windows; 6 | 7 | // All the code in this file is only included on Windows. 8 | public class PathProvider : IPathProvider 9 | { 10 | /// 11 | public string GetDefaultRoamingCacheDirectory() => 12 | GetOrCreateDirectory(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ReactorData", "BlobCache")); 13 | 14 | /// 15 | public string GetDefaultSecretCacheDirectory() => 16 | GetOrCreateDirectory(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ReactorData", "SecretCache")); 17 | 18 | /// 19 | public string GetDefaultLocalMachineCacheDirectory() => 20 | GetOrCreateDirectory(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ReactorData", "BlobCache")); 21 | 22 | static string GetOrCreateDirectory(string directory) 23 | { 24 | if (!Directory.Exists(directory)) 25 | { 26 | Directory.CreateDirectory(directory); 27 | } 28 | return directory; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ReactorData.Maui/Platforms/iOS/PathProvider.cs: -------------------------------------------------------------------------------- 1 | using Foundation; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace ReactorData.Maui.Platforms.iOS; 11 | 12 | internal class PathProvider : IPathProvider 13 | { 14 | public string GetDefaultLocalMachineCacheDirectory() => CreateAppDirectory(NSSearchPathDirectory.CachesDirectory); 15 | 16 | /// 17 | public string GetDefaultRoamingCacheDirectory() => CreateAppDirectory(NSSearchPathDirectory.ApplicationSupportDirectory); 18 | 19 | /// 20 | public string GetDefaultSecretCacheDirectory() => CreateAppDirectory(NSSearchPathDirectory.ApplicationSupportDirectory, "SecretCache"); 21 | 22 | private string CreateAppDirectory(NSSearchPathDirectory targetDir, string subDir = "Cache") 23 | { 24 | using var fm = new NSFileManager(); 25 | var url = fm.GetUrl(targetDir, NSSearchPathDomain.All, null, true, out _) ?? throw new DirectoryNotFoundException(); 26 | var rp = url.RelativePath ?? throw new DirectoryNotFoundException(); 27 | var ret = Path.Combine(rp, "ReactorData", subDir); 28 | if (!Directory.Exists(ret)) 29 | { 30 | Directory.CreateDirectory(ret); 31 | } 32 | 33 | return ret; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ReactorData.Maui/ReactorData.Maui.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0;net8.0-android;net8.0-ios;net8.0-maccatalyst 5 | $(TargetFrameworks);net8.0-windows10.0.19041.0 6 | 7 | 8 | 9 | true 10 | true 11 | enable 12 | 0.0.1 13 | adospace 14 | ReactorData.Maui is the .NET MAUI integration for ReactorData. 15 | Adolfo Marinucci 16 | https://github.com/adospace/reactor-data 17 | MIT 18 | https://github.com/adospace/reactor-data 19 | database storage persistance mvu UI MAUI .NET 20 | true 21 | false 22 | ReactorData.Maui 23 | true 24 | RS1036,NU1903,NU1902,NU1901 25 | 26 | 11.0 27 | 13.1 28 | 21.0 29 | 10.0.17763.0 30 | 10.0.17763.0 31 | 6.5 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/ReactorData.Maui/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Maui.Hosting; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace ReactorData.Maui; 10 | 11 | public static class ServiceCollectionExtensions 12 | { 13 | public static MauiAppBuilder UseReactorData(this MauiAppBuilder appBuilder, Action? serviceBuilderAction = null, Action? onError = null) 14 | { 15 | var dispatcher = new Dispatcher(onError); 16 | appBuilder.Services.AddSingleton(dispatcher); 17 | 18 | #if ANDROID 19 | appBuilder.Services.AddSingleton(); 20 | #elif IOS 21 | appBuilder.Services.AddSingleton(); 22 | #elif MACCATALYST 23 | appBuilder.Services.AddSingleton(); 24 | #elif WINDOWS 25 | appBuilder.Services.AddSingleton(); 26 | #endif 27 | 28 | serviceBuilderAction?.Invoke(appBuilder.Services); 29 | 30 | return appBuilder; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ReactorData.SourceGenerators/ModelPartialClassSourceGenerator.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.CSharp; 3 | using Microsoft.CodeAnalysis.CSharp.Syntax; 4 | using Microsoft.CodeAnalysis.Text; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Diagnostics; 8 | using System.IO; 9 | using System.Linq; 10 | using System.Xml; 11 | 12 | 13 | namespace ReactorData; 14 | 15 | [Generator] 16 | public class ModelPartialClassSourceGenerator : ISourceGenerator 17 | { 18 | 19 | public void Initialize(GeneratorInitializationContext context) 20 | { 21 | context.RegisterForSyntaxNotifications(() => new ModelPartialClassSyntaxReceiver()); 22 | } 23 | 24 | public void Execute(GeneratorExecutionContext context) 25 | { 26 | SymbolDisplayFormat qualifiedFormat = new SymbolDisplayFormat( 27 | typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces 28 | ); 29 | 30 | var receiver = (ModelPartialClassSyntaxReceiver)context.SyntaxReceiver.EnsureNotNull(); 31 | 32 | bool HasAttribute(ISymbol symbol, string attributeName) 33 | { 34 | // This check assumes that the provided attributeName is either the full name (including namespace) 35 | // or the metadata name (without "Attribute" suffix), and that you're interested in exact matches only. 36 | return symbol.GetAttributes().Any(attr => 37 | attr.AttributeClass?.Name == attributeName 38 | || attr.AttributeClass?.ToDisplayString() == attributeName); 39 | } 40 | 41 | foreach (var modelToGenerate in receiver.ModelsToGenerate) 42 | { 43 | var semanticModel = context.Compilation.GetSemanticModel(modelToGenerate.SyntaxTree); 44 | 45 | // Get the class symbol from the semantic model 46 | var classTypeSymbol = semanticModel.GetDeclaredSymbol(modelToGenerate).EnsureNotNull(); 47 | 48 | var modelAttribute = classTypeSymbol.GetAttributes() 49 | .First(_ => _.AttributeClass.EnsureNotNull().Name == "ModelAttribute" || _.AttributeClass.EnsureNotNull().Name == "Model"); 50 | 51 | IPropertySymbol? idProperty = null; 52 | 53 | if (modelAttribute?.ConstructorArguments.Length > 0) 54 | { 55 | var indexPropertyName = (string?)modelAttribute.ConstructorArguments[0].Value; 56 | 57 | if (indexPropertyName != null) 58 | { 59 | idProperty = classTypeSymbol.GetMembers() 60 | .OfType() // Filter members to only include properties 61 | .FirstOrDefault(prop => prop.Name == indexPropertyName); 62 | } 63 | } 64 | 65 | string fullyQualifiedTypeName = classTypeSymbol.ToDisplayString(qualifiedFormat); 66 | string namespaceName = classTypeSymbol.ContainingNamespace.ToDisplayString(); 67 | string className = classTypeSymbol.Name; 68 | 69 | 70 | // Loop through all the properties of the class 71 | idProperty ??= classTypeSymbol.GetMembers() 72 | .OfType() // Filter members to only include properties 73 | .FirstOrDefault(prop => 74 | HasAttribute(prop, "KeyAttribute")); 75 | 76 | idProperty ??= classTypeSymbol.GetMembers() 77 | .OfType() // Filter members to only include properties 78 | .FirstOrDefault(prop => prop.Name == "Id"); 79 | 80 | idProperty ??= classTypeSymbol.GetMembers() 81 | .OfType() // Filter members to only include properties 82 | .FirstOrDefault(prop => prop.Name == "Key"); 83 | 84 | if (idProperty == null) 85 | { 86 | var diagnosticDescriptor = new DiagnosticDescriptor( 87 | id: "REACTOR_DATA_001", // Unique ID for your diagnostic 88 | title: $"Model '{fullyQualifiedTypeName}' without key property", 89 | messageFormat: "Unable to generate model entity: {0} (Looking for a property named 'Id', 'Key' or as specified with the 'keyPropertyName' constructor parameter of the Model attribute)", // {0} will be replaced with 'messageArgs' 90 | category: "ReactorData Model Attribute", 91 | defaultSeverity: DiagnosticSeverity.Warning, // Choose the appropriate severity 92 | isEnabledByDefault: true 93 | ); 94 | 95 | // You can now emit the diagnostic with this descriptor and message arguments for the message format. 96 | var diagnostic = Diagnostic.Create(diagnosticDescriptor, Location.None, fullyQualifiedTypeName); 97 | context.ReportDiagnostic(diagnostic); 98 | 99 | continue; 100 | } 101 | 102 | string idPropertyName = idProperty.Name; 103 | 104 | string generatedSource = $$""" 105 | using System; 106 | using ReactorData; 107 | 108 | #nullable enable 109 | 110 | namespace {{namespaceName}} 111 | { 112 | partial class {{className}} : IEntity 113 | { 114 | object? IEntity.GetKey() => {{idPropertyName}}; 115 | } 116 | } 117 | """; 118 | 119 | context.AddSource($"{fullyQualifiedTypeName}.g.cs", generatedSource); 120 | } 121 | } 122 | } 123 | 124 | class ModelPartialClassSyntaxReceiver : ISyntaxReceiver 125 | { 126 | public List ModelsToGenerate = new(); 127 | public List ModelKeysToGenerate = new(); 128 | 129 | public void OnVisitSyntaxNode(SyntaxNode syntaxNode) 130 | { 131 | if (syntaxNode is ClassDeclarationSyntax cds) 132 | { 133 | var scaffoldAttribute = cds.AttributeLists 134 | .Where(_ => _.Attributes.Any(attr => attr.Name is IdentifierNameSyntax nameSyntax && 135 | (nameSyntax.Identifier.Text == "Model" || nameSyntax.Identifier.Text == "ModelAttribute"))) 136 | .Select(_ => _.Attributes.First()) 137 | .FirstOrDefault(); 138 | 139 | if (scaffoldAttribute != null) 140 | { 141 | ModelsToGenerate.Add(cds); 142 | } 143 | } 144 | } 145 | } 146 | 147 | public class GeneratorClassItem 148 | { 149 | public GeneratorClassItem(string @namespace, string className) 150 | { 151 | Namespace = @namespace; 152 | ClassName = className; 153 | } 154 | 155 | public string Namespace { get; } 156 | public string ClassName { get; } 157 | 158 | public Dictionary FieldItems { get; } = new(); 159 | } 160 | 161 | public class GeneratorFieldItem 162 | { 163 | private readonly string? _propMethodName; 164 | 165 | public GeneratorFieldItem(string fieldName, string fieldTypeFullyQualifiedName, FieldAttributeType type, string? propMethodName) 166 | { 167 | FieldName = fieldName; 168 | FieldTypeFullyQualifiedName = fieldTypeFullyQualifiedName; 169 | Type = type; 170 | _propMethodName = propMethodName; 171 | } 172 | 173 | public string FieldName { get; } 174 | 175 | public string FieldTypeFullyQualifiedName { get; } 176 | 177 | public FieldAttributeType Type { get; } 178 | 179 | public string GetPropMethodName() 180 | { 181 | if (_propMethodName != null) 182 | { 183 | return _propMethodName; 184 | } 185 | 186 | var fieldName = FieldName.TrimStart('_'); 187 | fieldName = char.ToUpper(fieldName[0]) + fieldName.Substring(1); 188 | return fieldName; 189 | } 190 | } 191 | 192 | public enum FieldAttributeType 193 | { 194 | Inject, 195 | 196 | Prop 197 | } 198 | 199 | -------------------------------------------------------------------------------- /src/ReactorData.SourceGenerators/ReactorData.SourceGenerators.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0 5 | false 6 | enable 7 | true 8 | latest 9 | true 10 | true 11 | RS1036,NU1903,NU1902,NU1901 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/ReactorData.SourceGenerators/ValidateExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ReactorData; 6 | 7 | static class ValidateExtensions 8 | { 9 | public static T EnsureNotNull(this T? value) 10 | => value ?? throw new InvalidOperationException(); 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/ReactorData.Sqlite/Implementation/Storage.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Data.Sqlite; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | 5 | namespace ReactorData.Sqlite.Implementation; 6 | 7 | 8 | class Storage : IStorage 9 | { 10 | private readonly IServiceProvider _serviceProvider; 11 | private readonly string? _connectionString; 12 | private SqliteConnection? _connection; 13 | private readonly Action? _configureAction; 14 | private readonly SemaphoreSlim _semaphore = new(1); 15 | private bool _initialized; 16 | private StorageConfiguration? _configuration; 17 | 18 | class ConnectionHandler : IDisposable 19 | { 20 | readonly SqliteConnection? _currentConnection; 21 | private readonly Storage _storage; 22 | 23 | public ConnectionHandler(Storage storage) 24 | { 25 | if (storage._connection == null) 26 | { 27 | _currentConnection = new SqliteConnection(storage._connectionString.EnsureNotNull()); 28 | } 29 | _storage = storage; 30 | } 31 | 32 | public async Task GetConnection() 33 | { 34 | var connection = (_currentConnection ?? _storage._connection).EnsureNotNull(); 35 | 36 | if (connection.State == System.Data.ConnectionState.Closed) 37 | { 38 | await connection.OpenAsync(); 39 | } 40 | 41 | return connection; 42 | } 43 | 44 | public void Dispose() 45 | { 46 | _currentConnection?.Dispose(); 47 | } 48 | } 49 | 50 | public Storage(IServiceProvider serviceProvider, string connectionString, Action? configureAction = null) 51 | { 52 | _serviceProvider = serviceProvider; 53 | _connectionString = connectionString; 54 | _configureAction = configureAction; 55 | } 56 | 57 | public Storage(IServiceProvider serviceProvider, SqliteConnection connection, Action? configureAction = null) 58 | { 59 | _serviceProvider = serviceProvider; 60 | _connection = connection; 61 | _configureAction = configureAction; 62 | } 63 | 64 | private async ValueTask Initialize(SqliteConnection connection) 65 | { 66 | if (_initialized) 67 | { 68 | return; 69 | } 70 | 71 | try 72 | { 73 | _semaphore.Wait(); 74 | 75 | if (_initialized) 76 | { 77 | return; 78 | } 79 | 80 | _configuration = new StorageConfiguration(connection); 81 | 82 | _configureAction?.Invoke(_configuration); 83 | 84 | foreach (var modelConfiguration in _configuration.Models) 85 | { 86 | using var command = connection.CreateCommand(); 87 | 88 | var keyTypeName = GetSqliteTypeFor(modelConfiguration.Value.KeyPropertyType); 89 | 90 | command.CommandText = $$""" 91 | CREATE TABLE IF NOT EXISTS {{modelConfiguration.Value.TableName}} (ID {{keyTypeName}} PRIMARY KEY, MODEL TEXT) 92 | """; 93 | 94 | await command.ExecuteNonQueryAsync(); 95 | } 96 | 97 | _initialized = true; 98 | } 99 | finally 100 | { 101 | _semaphore.Release(); 102 | } 103 | } 104 | 105 | static string GetSqliteTypeFor(Type type) 106 | { 107 | //from https://learn.microsoft.com/en-us/dotnet/standard/data/sqlite/types 108 | if (type == typeof(int) || 109 | type == typeof(short) || 110 | type == typeof(long) || 111 | type == typeof(sbyte) || 112 | type == typeof(ushort) || 113 | type == typeof(uint) || 114 | type == typeof(bool) || 115 | type == typeof(byte)) 116 | { 117 | return "INTEGER"; 118 | } 119 | else if (type == typeof(string) || 120 | type == typeof(TimeOnly) || 121 | type == typeof(TimeSpan) || 122 | type == typeof(decimal) || 123 | type == typeof(char) || 124 | type == typeof(DateOnly) || 125 | type == typeof(DateTime) || 126 | type == typeof(DateTimeOffset) || 127 | type == typeof(Guid) 128 | ) 129 | { 130 | return "TEXT"; 131 | } 132 | else if (type == typeof(byte[])) 133 | { 134 | return "BLOB"; 135 | } 136 | 137 | throw new NotImplementedException($"Unable to get the Sqlite type for type: {type}"); 138 | } 139 | 140 | public async Task> Load(Func, IQueryable>? queryFunction = null) where TEntity : class, IEntity 141 | { 142 | using var connectionHandler = new ConnectionHandler(this); 143 | var connection = await connectionHandler.GetConnection(); 144 | 145 | await Initialize(connection); 146 | 147 | using var command = connection.EnsureNotNull().CreateCommand(); 148 | 149 | var entityType = typeof(TEntity); 150 | if (!_configuration.EnsureNotNull().Models.TryGetValue(entityType, out var modelConfiguration)) 151 | { 152 | throw new InvalidOperationException($"Missing model configuration for {entityType.Name}"); 153 | } 154 | 155 | command.CommandText = $$""" 156 | SELECT * FROM {{modelConfiguration.TableName}} 157 | """; 158 | 159 | var listOfLoadedEntities = new List(); 160 | 161 | using var reader = command.ExecuteReader(); 162 | 163 | while (await reader.ReadAsync()) 164 | { 165 | var json = reader.GetString(1); 166 | 167 | var loadedEntity = (TEntity)System.Text.Json.JsonSerializer.Deserialize(json, typeof(TEntity)).EnsureNotNull(); 168 | 169 | listOfLoadedEntities.Add(loadedEntity); 170 | } 171 | 172 | if (queryFunction != null) 173 | { 174 | return queryFunction(listOfLoadedEntities.AsQueryable()); 175 | } 176 | 177 | return listOfLoadedEntities; 178 | } 179 | 180 | public async Task Save(IEnumerable operations) 181 | { 182 | using var connectionHandler = new ConnectionHandler(this); 183 | var connection = await connectionHandler.GetConnection(); 184 | 185 | await Initialize(connection); 186 | 187 | using var command = connection.EnsureNotNull().CreateCommand(); 188 | 189 | foreach (var operation in operations) 190 | { 191 | switch (operation) 192 | { 193 | case StorageAdd storageInsert: 194 | { 195 | foreach (var entity in storageInsert.Entities) 196 | { 197 | await Insert(command, (IEntity)entity); 198 | } 199 | } 200 | break; 201 | case StorageUpdate storageUpdate: 202 | { 203 | foreach (var entity in storageUpdate.Entities) 204 | { 205 | await Update(command, (IEntity)entity); 206 | } 207 | } 208 | break; 209 | case StorageDelete storageDelete: 210 | { 211 | foreach (var entity in storageDelete.Entities) 212 | { 213 | await Delete(command, (IEntity)entity); 214 | } 215 | } 216 | break; 217 | } 218 | } 219 | } 220 | 221 | private async Task Delete(SqliteCommand command, IEntity entity) 222 | { 223 | var entityType = entity.GetType(); 224 | if (!_configuration.EnsureNotNull().Models.TryGetValue(entityType, out var modelConfiguration)) 225 | { 226 | throw new InvalidOperationException($"Missing model configuration for {entityType.Name}"); 227 | } 228 | 229 | command.CommandText = $$""" 230 | DELETE FROM {{modelConfiguration.TableName}} WHERE ID = $id 231 | """; 232 | command.Parameters.Clear(); 233 | command.Parameters.AddWithValue("$id", entity.GetKey().EnsureNotNull()); 234 | 235 | await command.ExecuteNonQueryAsync(); 236 | } 237 | 238 | private async Task Update(SqliteCommand command, IEntity entity) 239 | { 240 | var entityType = entity.GetType(); 241 | if (!_configuration.EnsureNotNull().Models.TryGetValue(entityType, out var modelConfiguration)) 242 | { 243 | throw new InvalidOperationException($"Missing model configuration for {entityType.Name}"); 244 | } 245 | 246 | var json = System.Text.Json.JsonSerializer.Serialize(entity, entityType); 247 | 248 | command.CommandText = $$""" 249 | UPDATE {{modelConfiguration.TableName}} SET MODEL = $json WHERE ID = $id 250 | """; 251 | command.Parameters.Clear(); 252 | command.Parameters.AddWithValue("$id", entity.GetKey().EnsureNotNull()); 253 | command.Parameters.AddWithValue("$json", json); 254 | 255 | await command.ExecuteNonQueryAsync(); 256 | } 257 | 258 | private async Task Insert(SqliteCommand command, IEntity entity) 259 | { 260 | var entityType = entity.GetType(); 261 | if (!_configuration.EnsureNotNull().Models.TryGetValue(entityType, out var modelConfiguration)) 262 | { 263 | throw new InvalidOperationException($"Missing model configuration for {entityType.Name}"); 264 | } 265 | 266 | var keyValue = entity.GetKey().EnsureNotNull(); 267 | if (modelConfiguration.KeyPropertyType == typeof(int) && 268 | (int)keyValue == 0) 269 | { 270 | command.CommandText = $$""" 271 | INSERT INTO {{modelConfiguration.TableName}} (MODEL) VALUES ($json) RETURNING ROWID 272 | """; 273 | command.Parameters.Clear(); 274 | command.Parameters.AddWithValue("$json", "{}"); 275 | 276 | var key = await command.ExecuteScalarAsync(); 277 | 278 | modelConfiguration.KeyPropertyInfo.SetValue(entity, Convert.ChangeType(key, modelConfiguration.KeyPropertyType)); 279 | 280 | var json = System.Text.Json.JsonSerializer.Serialize(entity, entityType); 281 | 282 | command.CommandText = $$""" 283 | UPDATE {{modelConfiguration.TableName}} SET MODEL = $json WHERE ID = $id 284 | """; 285 | command.Parameters.Clear(); 286 | command.Parameters.AddWithValue("$id", keyValue); 287 | command.Parameters.AddWithValue("$json", json); 288 | 289 | await command.ExecuteNonQueryAsync(); 290 | } 291 | else 292 | { 293 | var json = System.Text.Json.JsonSerializer.Serialize(entity, entityType); 294 | 295 | command.CommandText = $$""" 296 | INSERT INTO {{modelConfiguration.TableName}} (ID, MODEL) VALUES ($id, $json) 297 | """; 298 | command.Parameters.Clear(); 299 | command.Parameters.AddWithValue("$id", keyValue); 300 | command.Parameters.AddWithValue("$json", json); 301 | 302 | await command.ExecuteNonQueryAsync(); 303 | } 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /src/ReactorData.Sqlite/ReactorData.Sqlite.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 0.0.1 8 | adospace 9 | ReactorData.Sqlite is the Sqlite based plugin for ReactorData. 10 | Adolfo Marinucci 11 | https://github.com/adospace/reactor-data 12 | MIT 13 | https://github.com/adospace/reactor-data 14 | database storage persistance mvu UI 15 | true 16 | false 17 | ReactorData.Sqlite 18 | true 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/ReactorData.Sqlite/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Data.Sqlite; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace ReactorData.Sqlite; 5 | 6 | public static class ServiceCollectionExtensions 7 | { 8 | public static void AddReactorData(this IServiceCollection services, 9 | string connectionStringOrDatabaseName, 10 | Action? configure = null, 11 | Action? modelContextConfigure = null) 12 | { 13 | services.AddReactorData(modelContextConfigure); 14 | services.AddSingleton(sp => 15 | { 16 | if (!connectionStringOrDatabaseName.Trim() 17 | .StartsWith("Data Source",StringComparison.CurrentCultureIgnoreCase)) 18 | { 19 | var pathProvider = sp.GetService(); 20 | var connectionString = $"Data Source={Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), connectionStringOrDatabaseName)}"; 21 | if (pathProvider != null) 22 | { 23 | var roamingCacheDirectory = pathProvider.GetDefaultRoamingCacheDirectory(); 24 | if (roamingCacheDirectory != null) 25 | { 26 | connectionString = $"Data Source={Path.Combine(roamingCacheDirectory, connectionStringOrDatabaseName)}"; 27 | } 28 | } 29 | 30 | return new Implementation.Storage(sp, connectionString, configure); 31 | } 32 | 33 | return new Implementation.Storage(sp, connectionStringOrDatabaseName, configure); 34 | }); 35 | } 36 | 37 | public static void AddReactorData(this IServiceCollection services, 38 | SqliteConnection connection, 39 | Action? configure = null, 40 | Action? modelContextConfigure = null) 41 | { 42 | services.AddReactorData(modelContextConfigure); 43 | services.AddSingleton(sp => new Implementation.Storage(sp, connection, configure)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ReactorData.Sqlite/StorageConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Data.Sqlite; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.ComponentModel.DataAnnotations; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace ReactorData.Sqlite; 11 | 12 | public class StorageConfiguration 13 | { 14 | private readonly Dictionary _models = []; 15 | 16 | public SqliteConnection Connection { get; } 17 | 18 | internal StorageConfiguration(SqliteConnection connection) 19 | { 20 | Connection = connection; 21 | } 22 | 23 | internal IReadOnlyDictionary Models => _models; 24 | 25 | public StorageConfiguration Model(string? tableName = null, string? keyPropertyName = null) where T : class, IEntity 26 | { 27 | var typeOfT = typeof(T); 28 | 29 | var properties = typeOfT.GetProperties(); 30 | 31 | PropertyInfo? keyProperty = null; 32 | 33 | if (keyPropertyName != null) 34 | { 35 | keyProperty ??= properties 36 | .FirstOrDefault(p => p.Name == keyPropertyName); 37 | } 38 | else 39 | { 40 | 41 | 42 | keyProperty = properties 43 | .FirstOrDefault(p => p.GetCustomAttribute(typeof(KeyAttribute)) != null); 44 | 45 | keyProperty ??= properties 46 | .FirstOrDefault(p => p.Name == "Id"); 47 | 48 | keyProperty ??= properties 49 | .FirstOrDefault(p => p.Name == "Key"); 50 | } 51 | 52 | keyProperty = keyProperty ?? throw new InvalidOperationException($"Unable to find Key property on model {typeOfT.Name}"); 53 | 54 | keyPropertyName = keyProperty.Name; 55 | var keyPropertyType = keyProperty.PropertyType; 56 | 57 | _models[typeof(T)] = new StorageModelConfiguration(tableName ?? typeof(T).Name, keyPropertyName, keyPropertyType, keyProperty); 58 | return this; 59 | } 60 | } 61 | 62 | class StorageModelConfiguration(string tableName, string keyPropertyName, Type keyPropertyType, PropertyInfo keyPropertyInfo) 63 | { 64 | public string TableName { get; } = tableName; 65 | 66 | public string KeyPropertyName { get; } = keyPropertyName; 67 | 68 | public Type KeyPropertyType { get; } = keyPropertyType; 69 | 70 | public PropertyInfo KeyPropertyInfo { get; } = keyPropertyInfo; 71 | } -------------------------------------------------------------------------------- /src/ReactorData.Sqlite/ValidateExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ReactorData.Sqlite; 6 | 7 | static class ValidateExtensions 8 | { 9 | public static T EnsureNotNull(this T? value) 10 | => value ?? throw new InvalidOperationException(); 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/BasicEfCoreStorageTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using ReactorData.Tests.Models; 5 | using ReactorData.EFCore; 6 | using Microsoft.Data.Sqlite; 7 | using System.Reflection.Metadata; 8 | 9 | namespace ReactorData.Tests; 10 | 11 | class BasicEfCoreStorageTests 12 | { 13 | IServiceProvider _services; 14 | IModelContext _container; 15 | SqliteConnection _connection; 16 | 17 | [SetUp] 18 | public void Setup() 19 | { 20 | var serviceCollection = new ServiceCollection(); 21 | _connection = new SqliteConnection("Filename=:memory:"); 22 | _connection.Open(); 23 | serviceCollection.AddReactorData(options => options.UseSqlite(_connection)); 24 | 25 | _services = serviceCollection.BuildServiceProvider(); 26 | 27 | _container = _services.GetRequiredService(); 28 | } 29 | 30 | 31 | [TearDown] 32 | public void TearDown() 33 | { 34 | _connection.Dispose(); 35 | } 36 | 37 | [Test] 38 | public async Task BasicOperationsOnEntityUsingEfCoreStorage() 39 | { 40 | var blog = new Blog { Title = "My new blog" }; 41 | 42 | _container.GetEntityStatus(blog).Should().Be(EntityStatus.Detached); 43 | 44 | _container.Add(blog); 45 | 46 | await _container.Flush(); 47 | 48 | _container.GetEntityStatus(blog).Should().Be(EntityStatus.Added); 49 | 50 | //_container.Set().Single().Should().BeSameAs(blog); 51 | 52 | _container.Save(); 53 | 54 | await _container.Flush(); 55 | 56 | _container.GetEntityStatus(blog).Should().Be(EntityStatus.Attached); 57 | 58 | blog.Title = "My new blog modified"; 59 | 60 | var modifiedBlog = new Blog { Id = blog.Id, Title = "My new blog modified" }; 61 | _container.Replace(blog, modifiedBlog); 62 | 63 | await _container.Flush(); 64 | 65 | _container.GetEntityStatus(blog).Should().Be(EntityStatus.Detached); 66 | _container.GetEntityStatus(modifiedBlog).Should().Be(EntityStatus.Updated); 67 | 68 | _container.Save(); 69 | 70 | await _container.Flush(); 71 | 72 | _container.GetEntityStatus(modifiedBlog).Should().Be(EntityStatus.Attached); 73 | 74 | _container.Delete(modifiedBlog); 75 | 76 | await _container.Flush(); 77 | 78 | _container.GetEntityStatus(modifiedBlog).Should().Be(EntityStatus.Deleted); 79 | 80 | _container.Save(); 81 | 82 | await _container.Flush(); 83 | 84 | _container.GetEntityStatus(modifiedBlog).Should().Be(EntityStatus.Detached); 85 | } 86 | 87 | [Test] 88 | public async Task BasicOperationsOnEntityWithRelationshipUsingEfCoreStorage() 89 | { 90 | var director = new Director { Name = "Martin Scorsese" }; 91 | var movie = new Movie { Name = "The Irishman", Director = director }; 92 | 93 | _container.GetEntityStatus(movie).Should().Be(EntityStatus.Detached); 94 | 95 | _container.Add(movie); 96 | _container.Add(director); 97 | 98 | await _container.Flush(); 99 | 100 | _container.GetEntityStatus(movie).Should().Be(EntityStatus.Added); 101 | 102 | _container.Save(); 103 | 104 | await _container.Flush(); 105 | 106 | _container.GetEntityStatus(movie).Should().Be(EntityStatus.Attached); 107 | 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/BasicSqliteStorageTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using ReactorData.Tests.Models; 4 | using ReactorData.Sqlite; 5 | using Microsoft.Data.Sqlite; 6 | 7 | namespace ReactorData.Tests; 8 | 9 | class BasicSqliteStorageTests 10 | { 11 | IServiceProvider _services; 12 | IModelContext _container; 13 | private SqliteConnection _connection; 14 | 15 | [SetUp] 16 | public void Setup() 17 | { 18 | var serviceCollection = new ServiceCollection(); 19 | _connection = new SqliteConnection("Filename=:memory:"); 20 | _connection.Open(); 21 | 22 | serviceCollection.AddReactorData(_connection, 23 | configuration => configuration.Model()); 24 | 25 | _services = serviceCollection.BuildServiceProvider(); 26 | 27 | _container = _services.GetRequiredService(); 28 | } 29 | 30 | 31 | [TearDown] 32 | public void TearDown() 33 | { 34 | _connection.Dispose(); 35 | } 36 | 37 | [Test] 38 | public async Task BasicOperationsOnEntityUsingSqliteStorage() 39 | { 40 | var blog = new Blog { Title = "My new blog" }; 41 | 42 | _container.GetEntityStatus(blog).Should().Be(EntityStatus.Detached); 43 | 44 | _container.Add(blog); 45 | 46 | await _container.Flush(); 47 | 48 | _container.GetEntityStatus(blog).Should().Be(EntityStatus.Added); 49 | 50 | //_container.Set().Single().Should().BeSameAs(blog); 51 | 52 | _container.Save(); 53 | 54 | await _container.Flush(); 55 | 56 | _container.GetEntityStatus(blog).Should().Be(EntityStatus.Attached); 57 | 58 | var modifiedBlog = new Blog { Id = blog.Id, Title = "My new blog modified" }; 59 | _container.Replace(blog, modifiedBlog); 60 | 61 | await _container.Flush(); 62 | 63 | _container.GetEntityStatus(blog).Should().Be(EntityStatus.Detached); 64 | _container.GetEntityStatus(modifiedBlog).Should().Be(EntityStatus.Updated); 65 | 66 | _container.Save(); 67 | 68 | await _container.Flush(); 69 | 70 | _container.GetEntityStatus(modifiedBlog).Should().Be(EntityStatus.Attached); 71 | 72 | _container.Delete(modifiedBlog); 73 | 74 | await _container.Flush(); 75 | 76 | _container.GetEntityStatus(modifiedBlog).Should().Be(EntityStatus.Deleted); 77 | 78 | _container.Save(); 79 | 80 | await _container.Flush(); 81 | 82 | _container.GetEntityStatus(modifiedBlog).Should().Be(EntityStatus.Detached); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/BasicTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using ReactorData; 4 | using ReactorData.Tests.Models; 5 | 6 | namespace ReactorData.Tests; 7 | 8 | public class BasicTests 9 | { 10 | IServiceProvider _services; 11 | IModelContext _container; 12 | 13 | [SetUp] 14 | public void Setup() 15 | { 16 | var serviceCollection = new ServiceCollection(); 17 | serviceCollection.AddReactorData(); 18 | _services = serviceCollection.BuildServiceProvider(); 19 | 20 | _container = _services.GetRequiredService(); 21 | } 22 | 23 | 24 | [Test] 25 | public async Task BasicOperationsOnEntity() 26 | { 27 | var todo = new Todo 28 | { 29 | Title = "My new blog" 30 | }; 31 | 32 | _container.GetEntityStatus(todo).Should().Be(EntityStatus.Detached); 33 | 34 | _container.Add(todo); 35 | 36 | await _container.Flush(); 37 | 38 | _container.GetEntityStatus(todo).Should().Be(EntityStatus.Added); 39 | 40 | //_container.Set().Single().Should().BeSameAs(todo); 41 | 42 | _container.Save(); 43 | 44 | await _container.Flush(); 45 | 46 | _container.GetEntityStatus(todo).Should().Be(EntityStatus.Attached); 47 | 48 | var modifiedTodo = new Todo { Title = todo.Title, Done = true }; 49 | _container.Replace(todo, modifiedTodo); 50 | 51 | await _container.Flush(); 52 | 53 | _container.GetEntityStatus(modifiedTodo).Should().Be(EntityStatus.Updated); 54 | 55 | _container.Save(); 56 | 57 | await _container.Flush(); 58 | 59 | _container.GetEntityStatus(modifiedTodo).Should().Be(EntityStatus.Attached); 60 | 61 | _container.Delete(modifiedTodo); 62 | 63 | await _container.Flush(); 64 | 65 | _container.GetEntityStatus(modifiedTodo).Should().Be(EntityStatus.Deleted); 66 | 67 | _container.Save(); 68 | 69 | await _container.Flush(); 70 | 71 | _container.GetEntityStatus(modifiedTodo).Should().Be(EntityStatus.Detached); 72 | } 73 | } -------------------------------------------------------------------------------- /src/ReactorData.Tests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using NUnit.Framework; -------------------------------------------------------------------------------- /src/ReactorData.Tests/LoadingEfCoreStorageTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Data.Sqlite; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using ReactorData.Tests.Models; 4 | using ReactorData.EFCore; 5 | using Microsoft.EntityFrameworkCore; 6 | using FluentAssertions; 7 | using System.Collections.Specialized; 8 | 9 | namespace ReactorData.Tests; 10 | 11 | class LoadingEfCoreStorageTests 12 | { 13 | IServiceProvider _services; 14 | IModelContext _container; 15 | SqliteConnection _connection; 16 | 17 | [SetUp] 18 | public void Setup() 19 | { 20 | var serviceCollection = new ServiceCollection(); 21 | _connection = new SqliteConnection("Filename=:memory:"); 22 | _connection.Open(); 23 | serviceCollection.AddReactorData(options => options.UseSqlite(_connection)); 24 | 25 | _services = serviceCollection.BuildServiceProvider(); 26 | 27 | _container = _services.GetRequiredService(); 28 | } 29 | 30 | 31 | [TearDown] 32 | public void TearDown() 33 | { 34 | _connection.Dispose(); 35 | } 36 | 37 | [Test] 38 | public async Task TestContextLoadingUsingEfCoreStorage() 39 | { 40 | _container.Load(query => query.Where(_ => _.Title.StartsWith("Stored"))); 41 | 42 | await _container.Flush(); 43 | 44 | //_container.Set().Count.Should().Be(0); 45 | 46 | var firstBlog = new Blog { Title = "Stored Blog" }; 47 | 48 | { 49 | using var scope = _services.CreateScope(); 50 | using var dbContext = scope.ServiceProvider.GetRequiredService(); 51 | 52 | dbContext.Add(firstBlog); 53 | 54 | await dbContext.SaveChangesAsync(); 55 | 56 | firstBlog = dbContext.Blogs.First(); 57 | } 58 | 59 | var query = _container.Query(); 60 | 61 | bool addedEvent = false; 62 | void checkAddedEvent(object? sender, NotifyCollectionChangedEventArgs e) 63 | { 64 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 65 | e.NewItems.Should().NotBeNull(); 66 | e.NewItems![0].Should().BeEquivalentTo(firstBlog); 67 | e.NewStartingIndex.Should().Be(0); 68 | e.OldItems.Should().BeNull(); 69 | 70 | addedEvent = true; 71 | }; 72 | 73 | query.CollectionChanged += checkAddedEvent; 74 | 75 | _container.Load(query => query.Where(_ => _.Title.StartsWith("Stored"))); 76 | 77 | await _container.Flush(); 78 | 79 | addedEvent.Should().BeTrue(); 80 | 81 | query.Count.Should().Be(1); 82 | 83 | query.CollectionChanged -= checkAddedEvent; 84 | 85 | var modifiedBlog = new Blog { Id = firstBlog.Id, Title = "Stored Blog edited in db context" }; 86 | 87 | { 88 | using var scope = _services.CreateScope(); 89 | using var dbContext = scope.ServiceProvider.GetRequiredService(); 90 | 91 | dbContext.Update(modifiedBlog); 92 | 93 | await dbContext.SaveChangesAsync(); 94 | } 95 | 96 | { 97 | bool notCalledEvent = true; 98 | void checkNotCalledEvent(object? sender, NotifyCollectionChangedEventArgs e) 99 | { 100 | notCalledEvent = false; 101 | }; 102 | 103 | query.CollectionChanged += checkNotCalledEvent; 104 | 105 | _container.Load(query => query.Where(_ => _.Title.StartsWith("Stored"))); 106 | 107 | await _container.Flush(); 108 | 109 | notCalledEvent.Should().BeTrue(); 110 | 111 | query.Count.Should().Be(1); 112 | 113 | query.CollectionChanged -= checkNotCalledEvent; 114 | } 115 | 116 | { 117 | bool udpatedEvent = false; 118 | void checkUpdatedEvent(object? sender, NotifyCollectionChangedEventArgs e) 119 | { 120 | e.Action.Should().Be(NotifyCollectionChangedAction.Replace); 121 | e.NewItems.Should().NotBeNull(); 122 | e.NewItems![0].Should().BeEquivalentTo(modifiedBlog); 123 | e.NewStartingIndex.Should().Be(0); 124 | e.OldItems.Should().NotBeNull(); 125 | e.OldItems![0].Should().BeEquivalentTo(firstBlog); 126 | e.OldStartingIndex.Should().Be(0); 127 | 128 | udpatedEvent = true; 129 | }; 130 | 131 | query.CollectionChanged += checkUpdatedEvent; 132 | 133 | _container.Load(query => query.Where(_ => _.Title.StartsWith("Stored")), compareFunc: (b1, b2) => false); 134 | 135 | await _container.Flush(); 136 | 137 | udpatedEvent.Should().BeTrue(); 138 | 139 | query.Count.Should().Be(1); 140 | 141 | query.CollectionChanged -= checkUpdatedEvent; 142 | } 143 | 144 | _container.FindByKey(1).Should().NotBeNull(); 145 | } 146 | 147 | 148 | [Test] 149 | public async Task TestContextWithRealatedEntitiesUsingEfCoreStorage() 150 | { 151 | _container.Load(); 152 | 153 | await _container.Flush(); 154 | 155 | //_container.Set().Count.Should().Be(0); 156 | 157 | var director = new Director { Name = "Martin Scorsese" }; 158 | var movie = new Movie { Name = "The Irishman", Director = director }; 159 | 160 | 161 | { 162 | using var scope = _services.CreateScope(); 163 | using var dbContext = scope.ServiceProvider.GetRequiredService(); 164 | 165 | dbContext.Add(movie); 166 | 167 | await dbContext.SaveChangesAsync(); 168 | 169 | movie = dbContext.Movies.First(); 170 | } 171 | 172 | var query = _container.Query(); 173 | 174 | bool addedEvent = false; 175 | void checkAddedEvent(object? sender, NotifyCollectionChangedEventArgs e) 176 | { 177 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 178 | e.NewItems.Should().NotBeNull(); 179 | movie.IsEquivalentTo((Movie)e.NewItems![0]!).Should().BeTrue(); 180 | e.NewStartingIndex.Should().Be(0); 181 | e.OldItems.Should().BeNull(); 182 | 183 | addedEvent = true; 184 | }; 185 | 186 | query.CollectionChanged += checkAddedEvent; 187 | 188 | _container.Load(x => x.Include(_ => _.Director)); 189 | 190 | await _container.Flush(); 191 | 192 | addedEvent.Should().BeTrue(); 193 | 194 | query.Count.Should().Be(1); 195 | 196 | query.CollectionChanged -= checkAddedEvent; 197 | 198 | var anotherMovie = new Movie { Name = "The Wolf of Wall Street", Director = director }; 199 | 200 | bool addedAnotherMovieEvent = false; 201 | void checkAnotherMovieAddedEvent(object? sender, NotifyCollectionChangedEventArgs e) 202 | { 203 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 204 | e.NewItems.Should().NotBeNull(); 205 | anotherMovie.IsEquivalentTo((Movie)e.NewItems![0]!).Should().BeTrue(); 206 | e.NewStartingIndex.Should().Be(1); 207 | e.OldItems.Should().BeNull(); 208 | 209 | addedAnotherMovieEvent = true; 210 | }; 211 | 212 | query.CollectionChanged += checkAnotherMovieAddedEvent; 213 | 214 | _container.Add(anotherMovie); 215 | 216 | await _container.Flush(); 217 | 218 | addedAnotherMovieEvent.Should().BeTrue(); 219 | 220 | query.Count.Should().Be(2); 221 | 222 | query.CollectionChanged -= checkAnotherMovieAddedEvent; 223 | 224 | _container.Save(); 225 | 226 | await _container.Flush(); 227 | 228 | 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/LoadingSqliteStorageTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Data.Sqlite; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using ReactorData.Tests.Models; 4 | using FluentAssertions; 5 | using System.Collections.Specialized; 6 | using ReactorData.Sqlite; 7 | using System.Collections.Generic; 8 | 9 | namespace ReactorData.Tests; 10 | 11 | class LoadingSqliteStorageTests 12 | { 13 | IServiceProvider _services; 14 | IModelContext _container; 15 | private SqliteConnection _connection; 16 | 17 | [SetUp] 18 | public void Setup() 19 | { 20 | var serviceCollection = new ServiceCollection(); 21 | _connection = new SqliteConnection("Filename=:memory:"); 22 | _connection.Open(); 23 | 24 | serviceCollection.AddReactorData(_connection, 25 | configuration => configuration.Model()); 26 | 27 | _services = serviceCollection.BuildServiceProvider(); 28 | 29 | _container = _services.GetRequiredService(); 30 | } 31 | 32 | 33 | [TearDown] 34 | public void TearDown() 35 | { 36 | _connection.Dispose(); 37 | } 38 | 39 | [Test] 40 | public async Task TestContextLoadingUsingSqliteStorage() 41 | { 42 | _container.Load(query => query.Where(_ => _.Title.StartsWith("Stored"))); 43 | 44 | await _container.Flush(); 45 | 46 | //_container.Set().Count().Should().Be(0); 47 | 48 | var firstBlog = new Blog { Title = "Stored Blog" }; 49 | 50 | { 51 | using var command = _connection.CreateCommand(); 52 | 53 | command.CommandText = $$""" 54 | INSERT INTO Blog (MODEL) VALUES ($json) RETURNING ROWID 55 | """; 56 | command.Parameters.AddWithValue("$json", "{}"); 57 | 58 | var key = await command.ExecuteScalarAsync(); 59 | 60 | firstBlog.Id = (int)Convert.ChangeType(key!, typeof(int)); 61 | 62 | var json = System.Text.Json.JsonSerializer.Serialize(firstBlog); 63 | command.CommandText = $$""" 64 | UPDATE Blog SET MODEL = $json WHERE ID = $id 65 | """; 66 | command.Parameters.Clear(); 67 | command.Parameters.AddWithValue("$id", firstBlog.Id); 68 | command.Parameters.AddWithValue("$json", json); 69 | 70 | await command.ExecuteNonQueryAsync(); 71 | } 72 | 73 | var query = _container.Query(); 74 | 75 | bool addedEvent = false; 76 | void checkAddedEvent(object? sender, NotifyCollectionChangedEventArgs e) 77 | { 78 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 79 | e.NewItems.Should().NotBeNull(); 80 | e.NewItems![0].Should().BeEquivalentTo(firstBlog); 81 | e.NewStartingIndex.Should().Be(0); 82 | e.OldItems.Should().BeNull(); 83 | 84 | addedEvent = true; 85 | }; 86 | 87 | query.CollectionChanged += checkAddedEvent; 88 | 89 | _container.Load(query => query.Where(_ => _.Title.StartsWith("Stored"))); 90 | 91 | await _container.Flush(); 92 | 93 | addedEvent.Should().BeTrue(); 94 | 95 | query.Count.Should().Be(1); 96 | 97 | query.CollectionChanged -= checkAddedEvent; 98 | 99 | var modifiedBlog = new Blog { Id = firstBlog.Id, Title = "Stored Blog edited in db context" }; 100 | 101 | { 102 | using var command = _connection.CreateCommand(); 103 | 104 | var json = System.Text.Json.JsonSerializer.Serialize(modifiedBlog); 105 | command.CommandText = $$""" 106 | UPDATE Blog SET MODEL = $json WHERE ID = $id 107 | """; 108 | command.Parameters.Clear(); 109 | command.Parameters.AddWithValue("$id", modifiedBlog.Id); 110 | command.Parameters.AddWithValue("$json", json); 111 | 112 | await command.ExecuteNonQueryAsync(); 113 | } 114 | 115 | { 116 | bool notCalledEvent = true; 117 | void checkNotCalledEvent(object? sender, NotifyCollectionChangedEventArgs e) 118 | { 119 | notCalledEvent = false; 120 | }; 121 | 122 | query.CollectionChanged += checkNotCalledEvent; 123 | 124 | _container.Load(query => query.Where(_ => _.Title.StartsWith("Stored"))); 125 | 126 | await _container.Flush(); 127 | 128 | notCalledEvent.Should().BeTrue(); 129 | 130 | query.Count.Should().Be(1); 131 | 132 | query.CollectionChanged -= checkNotCalledEvent; 133 | } 134 | 135 | { 136 | bool udpatedEvent = false; 137 | void checkUpdatedEvent(object? sender, NotifyCollectionChangedEventArgs e) 138 | { 139 | e.Action.Should().Be(NotifyCollectionChangedAction.Replace); 140 | e.NewItems.Should().NotBeNull(); 141 | e.NewItems![0].Should().BeEquivalentTo(modifiedBlog); 142 | e.NewStartingIndex.Should().Be(0); 143 | e.OldItems.Should().NotBeNull(); 144 | e.OldItems![0].Should().BeEquivalentTo(firstBlog); 145 | e.OldStartingIndex.Should().Be(0); 146 | 147 | udpatedEvent = true; 148 | }; 149 | 150 | query.CollectionChanged += checkUpdatedEvent; 151 | 152 | _container.Load(query => query.Where(_ => _.Title.StartsWith("Stored")), compareFunc: (b1, b2) => false); 153 | 154 | await _container.Flush(); 155 | 156 | udpatedEvent.Should().BeTrue(); 157 | 158 | query.Count.Should().Be(1); 159 | 160 | query.CollectionChanged -= checkUpdatedEvent; 161 | } 162 | 163 | _container.FindByKey(1).Should().NotBeNull(); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/Models/Blog.cs: -------------------------------------------------------------------------------- 1 | namespace ReactorData.Tests.Models; 2 | 3 | [Model] 4 | partial class Blog 5 | { 6 | public int Id { get; set; } 7 | 8 | public required string Title { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/Models/Director.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace ReactorData.Tests.Models; 3 | 4 | [Model] 5 | partial class Director 6 | { 7 | public int Id { set; get; } 8 | 9 | public required string Name { get; set; } 10 | 11 | public ICollection? Movies { get; set; } 12 | 13 | public bool IsEquivalentTo(Director other) 14 | { 15 | return Id == other.Id && Name == other.Name; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/Models/Migrations/20240108180422_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | using Microsoft.EntityFrameworkCore.Migrations; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using ReactorData.Tests.Models; 7 | 8 | #nullable disable 9 | 10 | namespace ReactorData.Tests.Models.Migrations 11 | { 12 | [DbContext(typeof(TestDbContext))] 13 | [Migration("20240108180422_Initial")] 14 | partial class Initial 15 | { 16 | /// 17 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 18 | { 19 | #pragma warning disable 612, 618 20 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 21 | 22 | modelBuilder.Entity("ReactorData.Tests.Models.Blog", b => 23 | { 24 | b.Property("Id") 25 | .ValueGeneratedOnAdd() 26 | .HasColumnType("INTEGER"); 27 | 28 | b.Property("Title") 29 | .IsRequired() 30 | .HasColumnType("TEXT"); 31 | 32 | b.HasKey("Id"); 33 | 34 | b.ToTable("Blogs"); 35 | }); 36 | #pragma warning restore 612, 618 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/Models/Migrations/20240108180422_Initial.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace ReactorData.Tests.Models.Migrations 6 | { 7 | /// 8 | public partial class Initial : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.CreateTable( 14 | name: "Blogs", 15 | columns: table => new 16 | { 17 | Id = table.Column(type: "INTEGER", nullable: false) 18 | .Annotation("Sqlite:Autoincrement", true), 19 | Title = table.Column(type: "TEXT", nullable: false) 20 | }, 21 | constraints: table => 22 | { 23 | table.PrimaryKey("PK_Blogs", x => x.Id); 24 | }); 25 | } 26 | 27 | /// 28 | protected override void Down(MigrationBuilder migrationBuilder) 29 | { 30 | migrationBuilder.DropTable( 31 | name: "Blogs"); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/Models/Migrations/20240127182915_Movies.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | using Microsoft.EntityFrameworkCore.Migrations; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using ReactorData.Tests.Models; 7 | 8 | #nullable disable 9 | 10 | namespace ReactorData.Tests.Models.Migrations 11 | { 12 | [DbContext(typeof(TestDbContext))] 13 | [Migration("20240127182915_Movies")] 14 | partial class Movies 15 | { 16 | /// 17 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 18 | { 19 | #pragma warning disable 612, 618 20 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 21 | 22 | modelBuilder.Entity("ReactorData.Tests.Models.Blog", b => 23 | { 24 | b.Property("Id") 25 | .ValueGeneratedOnAdd() 26 | .HasColumnType("INTEGER"); 27 | 28 | b.Property("Title") 29 | .IsRequired() 30 | .HasColumnType("TEXT"); 31 | 32 | b.HasKey("Id"); 33 | 34 | b.ToTable("Blogs"); 35 | }); 36 | 37 | modelBuilder.Entity("ReactorData.Tests.Models.Director", b => 38 | { 39 | b.Property("Id") 40 | .ValueGeneratedOnAdd() 41 | .HasColumnType("INTEGER"); 42 | 43 | b.Property("Name") 44 | .IsRequired() 45 | .HasColumnType("TEXT"); 46 | 47 | b.HasKey("Id"); 48 | 49 | b.ToTable("Directors"); 50 | }); 51 | 52 | modelBuilder.Entity("ReactorData.Tests.Models.Movie", b => 53 | { 54 | b.Property("Id") 55 | .ValueGeneratedOnAdd() 56 | .HasColumnType("INTEGER"); 57 | 58 | b.Property("Description") 59 | .HasColumnType("TEXT"); 60 | 61 | b.Property("DirectorId") 62 | .HasColumnType("INTEGER"); 63 | 64 | b.Property("Name") 65 | .IsRequired() 66 | .HasColumnType("TEXT"); 67 | 68 | b.HasKey("Id"); 69 | 70 | b.HasIndex("DirectorId"); 71 | 72 | b.ToTable("Movies"); 73 | }); 74 | 75 | modelBuilder.Entity("ReactorData.Tests.Models.Movie", b => 76 | { 77 | b.HasOne("ReactorData.Tests.Models.Director", "Director") 78 | .WithMany("Movies") 79 | .HasForeignKey("DirectorId") 80 | .OnDelete(DeleteBehavior.Cascade) 81 | .IsRequired(); 82 | 83 | b.Navigation("Director"); 84 | }); 85 | 86 | modelBuilder.Entity("ReactorData.Tests.Models.Director", b => 87 | { 88 | b.Navigation("Movies"); 89 | }); 90 | #pragma warning restore 612, 618 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/Models/Migrations/20240127182915_Movies.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace ReactorData.Tests.Models.Migrations 6 | { 7 | /// 8 | public partial class Movies : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.CreateTable( 14 | name: "Directors", 15 | columns: table => new 16 | { 17 | Id = table.Column(type: "INTEGER", nullable: false) 18 | .Annotation("Sqlite:Autoincrement", true), 19 | Name = table.Column(type: "TEXT", nullable: false) 20 | }, 21 | constraints: table => 22 | { 23 | table.PrimaryKey("PK_Directors", x => x.Id); 24 | }); 25 | 26 | migrationBuilder.CreateTable( 27 | name: "Movies", 28 | columns: table => new 29 | { 30 | Id = table.Column(type: "INTEGER", nullable: false) 31 | .Annotation("Sqlite:Autoincrement", true), 32 | Name = table.Column(type: "TEXT", nullable: false), 33 | Description = table.Column(type: "TEXT", nullable: true), 34 | DirectorId = table.Column(type: "INTEGER", nullable: false) 35 | }, 36 | constraints: table => 37 | { 38 | table.PrimaryKey("PK_Movies", x => x.Id); 39 | table.ForeignKey( 40 | name: "FK_Movies_Directors_DirectorId", 41 | column: x => x.DirectorId, 42 | principalTable: "Directors", 43 | principalColumn: "Id", 44 | onDelete: ReferentialAction.Cascade); 45 | }); 46 | 47 | migrationBuilder.CreateIndex( 48 | name: "IX_Movies_DirectorId", 49 | table: "Movies", 50 | column: "DirectorId"); 51 | } 52 | 53 | /// 54 | protected override void Down(MigrationBuilder migrationBuilder) 55 | { 56 | migrationBuilder.DropTable( 57 | name: "Movies"); 58 | 59 | migrationBuilder.DropTable( 60 | name: "Directors"); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/Models/Migrations/TestDbContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 5 | using ReactorData.Tests.Models; 6 | 7 | #nullable disable 8 | 9 | namespace ReactorData.Tests.Models.Migrations 10 | { 11 | [DbContext(typeof(TestDbContext))] 12 | partial class TestDbContextModelSnapshot : ModelSnapshot 13 | { 14 | protected override void BuildModel(ModelBuilder modelBuilder) 15 | { 16 | #pragma warning disable 612, 618 17 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 18 | 19 | modelBuilder.Entity("ReactorData.Tests.Models.Blog", b => 20 | { 21 | b.Property("Id") 22 | .ValueGeneratedOnAdd() 23 | .HasColumnType("INTEGER"); 24 | 25 | b.Property("Title") 26 | .IsRequired() 27 | .HasColumnType("TEXT"); 28 | 29 | b.HasKey("Id"); 30 | 31 | b.ToTable("Blogs"); 32 | }); 33 | 34 | modelBuilder.Entity("ReactorData.Tests.Models.Director", b => 35 | { 36 | b.Property("Id") 37 | .ValueGeneratedOnAdd() 38 | .HasColumnType("INTEGER"); 39 | 40 | b.Property("Name") 41 | .IsRequired() 42 | .HasColumnType("TEXT"); 43 | 44 | b.HasKey("Id"); 45 | 46 | b.ToTable("Directors"); 47 | }); 48 | 49 | modelBuilder.Entity("ReactorData.Tests.Models.Movie", b => 50 | { 51 | b.Property("Id") 52 | .ValueGeneratedOnAdd() 53 | .HasColumnType("INTEGER"); 54 | 55 | b.Property("Description") 56 | .HasColumnType("TEXT"); 57 | 58 | b.Property("DirectorId") 59 | .HasColumnType("INTEGER"); 60 | 61 | b.Property("Name") 62 | .IsRequired() 63 | .HasColumnType("TEXT"); 64 | 65 | b.HasKey("Id"); 66 | 67 | b.HasIndex("DirectorId"); 68 | 69 | b.ToTable("Movies"); 70 | }); 71 | 72 | modelBuilder.Entity("ReactorData.Tests.Models.Movie", b => 73 | { 74 | b.HasOne("ReactorData.Tests.Models.Director", "Director") 75 | .WithMany("Movies") 76 | .HasForeignKey("DirectorId") 77 | .OnDelete(DeleteBehavior.Cascade) 78 | .IsRequired(); 79 | 80 | b.Navigation("Director"); 81 | }); 82 | 83 | modelBuilder.Entity("ReactorData.Tests.Models.Director", b => 84 | { 85 | b.Navigation("Movies"); 86 | }); 87 | #pragma warning restore 612, 618 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/Models/Movie.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace ReactorData.Tests.Models; 8 | 9 | [Model] 10 | partial class Movie 11 | { 12 | public int Id { get; set; } 13 | 14 | public required string Name { get; set; } 15 | 16 | public string? Description { get; set; } 17 | 18 | public int DirectorId { get; set; } 19 | 20 | public Director? Director { get; set; } 21 | 22 | public bool IsEquivalentTo(Movie other) 23 | { 24 | return Id == other.Id && Name == other.Name && Description == other.Description 25 | && (Director == null && other.Director == null || (Director != null && other.Director != null && Director.IsEquivalentTo(other.Director))); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/Models/TestDbContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace ReactorData.Tests.Models; 9 | 10 | class TestDbContext : DbContext 11 | { 12 | public DbSet Blogs => Set(); 13 | 14 | public DbSet Movies => Set(); 15 | 16 | public DbSet Directors => Set(); 17 | 18 | public TestDbContext() { } 19 | 20 | public TestDbContext(DbContextOptions options) : base(options) 21 | { } 22 | 23 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 24 | { 25 | if (!optionsBuilder.IsConfigured) 26 | { 27 | optionsBuilder.UseSqlite("Data Source=Database.db"); 28 | } 29 | 30 | base.OnConfiguring(optionsBuilder); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/Models/Todo.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace ReactorData.Tests.Models; 4 | 5 | [Model] 6 | partial class Todo 7 | { 8 | [Key] 9 | public required string Title { get; set; } 10 | 11 | public bool Done { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/QueryEfCoreStorageTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Microsoft.Data.Sqlite; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using ReactorData.Tests.Models; 5 | using ReactorData.EFCore; 6 | using System.Collections.Specialized; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | namespace ReactorData.Tests; 10 | 11 | class QueryEfCoreStorageTests 12 | { 13 | IServiceProvider _services; 14 | IModelContext _container; 15 | SqliteConnection _connection; 16 | 17 | [SetUp] 18 | public void Setup() 19 | { 20 | var serviceCollection = new ServiceCollection(); 21 | 22 | _connection = new SqliteConnection("Filename=:memory:"); 23 | _connection.Open(); 24 | 25 | serviceCollection.AddReactorData(options => options.UseSqlite(_connection)); 26 | 27 | _services = serviceCollection.BuildServiceProvider(); 28 | _container = _services.GetRequiredService(); 29 | } 30 | 31 | [Test] 32 | public async Task TestQueryFunctionsUsingEfCoreStorage() 33 | { 34 | var firstBlog = new Blog { Title = "My new blog" }; 35 | 36 | _container.GetEntityStatus(firstBlog).Should().Be(EntityStatus.Detached); 37 | 38 | _container.Add(firstBlog); 39 | 40 | _container.Save(); 41 | 42 | await _container.Flush(); 43 | 44 | var queryFirst = _container.Query(query => query.Where(_ => _.Title.StartsWith("My"))); 45 | var querySecond = _container.Query(query => query.Where(_ => _.Title.Contains("second"))); 46 | 47 | queryFirst.Count.Should().Be(1); 48 | querySecond.Count.Should().Be(0); 49 | 50 | { 51 | bool removedEvent = false; 52 | void checkRemovedEvent(object? sender, NotifyCollectionChangedEventArgs e) 53 | { 54 | e.Action.Should().Be(NotifyCollectionChangedAction.Remove); 55 | e.OldItems.Should().NotBeNull(); 56 | e.OldItems![0].Should().BeSameAs(firstBlog); 57 | e.OldStartingIndex.Should().Be(0); 58 | e.NewItems.Should().BeNull(); 59 | 60 | removedEvent = true; 61 | }; 62 | 63 | queryFirst.CollectionChanged += checkRemovedEvent; 64 | 65 | bool notCalledEvent = true; 66 | void checkNotCalledEvent(object? sender, NotifyCollectionChangedEventArgs e) 67 | { 68 | notCalledEvent = false; 69 | }; 70 | 71 | querySecond.CollectionChanged += checkNotCalledEvent; 72 | 73 | _container.Replace(firstBlog, new Blog { Id = firstBlog.Id, Title = "(edited)" + firstBlog.Title }); 74 | 75 | _container.Save(); 76 | 77 | await _container.Flush(); 78 | 79 | queryFirst.Count.Should().Be(0); 80 | 81 | removedEvent.Should().BeTrue(); 82 | notCalledEvent.Should().BeTrue(); 83 | 84 | queryFirst.CollectionChanged -= checkRemovedEvent; 85 | querySecond.CollectionChanged -= checkNotCalledEvent; 86 | } 87 | 88 | { 89 | var secondBlog = new Blog { Title = "My second blog" }; 90 | 91 | bool addedFirstQueryEvent = false; 92 | void checkAddedFirstQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 93 | { 94 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 95 | e.NewItems.Should().NotBeNull(); 96 | e.NewItems![0].Should().BeSameAs(secondBlog); 97 | e.NewStartingIndex.Should().Be(0); 98 | e.OldItems.Should().BeNull(); 99 | 100 | addedFirstQueryEvent = true; 101 | }; 102 | 103 | queryFirst.CollectionChanged += checkAddedFirstQueryEvent; 104 | 105 | bool addedSecondQueryEvent = false; 106 | void checkAddedSecondQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 107 | { 108 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 109 | e.NewItems.Should().NotBeNull(); 110 | e.NewItems![0].Should().BeSameAs(secondBlog); 111 | e.NewStartingIndex.Should().Be(0); 112 | e.OldItems.Should().BeNull(); 113 | 114 | addedSecondQueryEvent = true; 115 | }; 116 | 117 | querySecond.CollectionChanged += checkAddedSecondQueryEvent; 118 | 119 | _container.Add(secondBlog); 120 | 121 | _container.Save(); 122 | 123 | await _container.Flush(); 124 | 125 | queryFirst.Count.Should().Be(1); 126 | querySecond.Count.Should().Be(1); 127 | 128 | addedFirstQueryEvent.Should().BeTrue(); 129 | addedSecondQueryEvent.Should().BeTrue(); 130 | 131 | queryFirst.CollectionChanged -= checkAddedFirstQueryEvent; 132 | querySecond.CollectionChanged -= checkAddedSecondQueryEvent; 133 | } 134 | 135 | { 136 | firstBlog.Title = "My new blog"; 137 | 138 | bool addedFirstQueryEvent = false; 139 | void checkAddedFirstQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 140 | { 141 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 142 | e.NewItems.Should().NotBeNull(); 143 | e.NewItems![0].Should().BeSameAs(firstBlog); 144 | e.NewStartingIndex.Should().Be(0); 145 | e.OldItems.Should().BeNull(); 146 | 147 | addedFirstQueryEvent = true; 148 | }; 149 | 150 | queryFirst.CollectionChanged += checkAddedFirstQueryEvent; 151 | 152 | bool notCalledSecondQueryEvent = true; 153 | void checkAddedSecondQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 154 | { 155 | notCalledSecondQueryEvent = false; 156 | }; 157 | 158 | querySecond.CollectionChanged += checkAddedSecondQueryEvent; 159 | 160 | _container.Replace(firstBlog, firstBlog); 161 | 162 | _container.Save(); 163 | 164 | await _container.Flush(); 165 | 166 | queryFirst.Count.Should().Be(2); 167 | querySecond.Count.Should().Be(1); 168 | 169 | addedFirstQueryEvent.Should().BeTrue(); 170 | notCalledSecondQueryEvent.Should().BeTrue(); 171 | 172 | queryFirst.CollectionChanged -= checkAddedFirstQueryEvent; 173 | querySecond.CollectionChanged -= checkAddedSecondQueryEvent; 174 | } 175 | 176 | { 177 | firstBlog.Title += " (updated)"; 178 | 179 | bool updatedFirstQueryEvent = false; 180 | void checkUpdatedFirstQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 181 | { 182 | e.Action.Should().Be(NotifyCollectionChangedAction.Replace); 183 | e.NewItems.Should().NotBeNull(); 184 | e.NewItems![0].Should().BeSameAs(firstBlog); 185 | e.NewStartingIndex.Should().Be(0); 186 | e.OldItems.Should().NotBeNull(); 187 | e.OldItems![0].Should().BeSameAs(firstBlog); 188 | e.OldStartingIndex.Should().Be(0); 189 | 190 | updatedFirstQueryEvent = true; 191 | }; 192 | 193 | queryFirst.CollectionChanged += checkUpdatedFirstQueryEvent; 194 | 195 | bool notCalledSecondQueryEvent = true; 196 | void checkNotCalledSecondQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 197 | { 198 | notCalledSecondQueryEvent = false; 199 | }; 200 | 201 | querySecond.CollectionChanged += checkNotCalledSecondQueryEvent; 202 | 203 | _container.Replace(firstBlog, firstBlog); 204 | 205 | _container.Save(); 206 | 207 | await _container.Flush(); 208 | 209 | queryFirst.Count.Should().Be(2); 210 | querySecond.Count.Should().Be(1); 211 | 212 | updatedFirstQueryEvent.Should().BeTrue(); 213 | notCalledSecondQueryEvent.Should().BeTrue(); 214 | 215 | queryFirst.CollectionChanged -= checkUpdatedFirstQueryEvent; 216 | querySecond.CollectionChanged -= checkNotCalledSecondQueryEvent; 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/QuerySqliteStorageTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Microsoft.Data.Sqlite; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using ReactorData.Tests.Models; 5 | using ReactorData.EFCore; 6 | using System.Collections.Specialized; 7 | using Microsoft.EntityFrameworkCore; 8 | using ReactorData.Sqlite; 9 | 10 | namespace ReactorData.Tests; 11 | 12 | class QuerySqliteStorageTests 13 | { 14 | IServiceProvider _services; 15 | IModelContext _container; 16 | private SqliteConnection _connection; 17 | 18 | [SetUp] 19 | public void Setup() 20 | { 21 | var serviceCollection = new ServiceCollection(); 22 | _connection = new SqliteConnection("Filename=:memory:"); 23 | _connection.Open(); 24 | 25 | serviceCollection.AddReactorData(_connection, 26 | configuration => configuration.Model()); 27 | 28 | _services = serviceCollection.BuildServiceProvider(); 29 | 30 | _container = _services.GetRequiredService(); 31 | } 32 | 33 | 34 | [TearDown] 35 | public void TearDown() 36 | { 37 | _connection.Dispose(); 38 | } 39 | 40 | [Test] 41 | public async Task TestQueryFunctionsUsingSqliteStorage() 42 | { 43 | var firstBlog = new Blog { Title = "My new blog" }; 44 | 45 | _container.GetEntityStatus(firstBlog).Should().Be(EntityStatus.Detached); 46 | 47 | _container.Add(firstBlog); 48 | 49 | _container.Save(); 50 | 51 | await _container.Flush(); 52 | 53 | var queryFirst = _container.Query(query => query.Where(_ => _.Title.StartsWith("My")).OrderBy(_=>_.Title)); 54 | var querySecond = _container.Query(query => query.Where(_ => _.Title.Contains("second")).OrderBy(_ => _.Title)); 55 | 56 | queryFirst.Count.Should().Be(1); 57 | querySecond.Count.Should().Be(0); 58 | 59 | { 60 | bool removedEvent = false; 61 | void checkRemovedEvent(object? sender, NotifyCollectionChangedEventArgs e) 62 | { 63 | e.Action.Should().Be(NotifyCollectionChangedAction.Remove); 64 | e.OldItems.Should().NotBeNull(); 65 | e.OldItems![0].Should().BeSameAs(firstBlog); 66 | e.OldStartingIndex.Should().Be(0); 67 | e.NewItems.Should().BeNull(); 68 | 69 | removedEvent = true; 70 | }; 71 | 72 | queryFirst.CollectionChanged += checkRemovedEvent; 73 | 74 | bool notCalledEvent = true; 75 | void checkNotCalledEvent(object? sender, NotifyCollectionChangedEventArgs e) 76 | { 77 | notCalledEvent = false; 78 | }; 79 | 80 | querySecond.CollectionChanged += checkNotCalledEvent; 81 | 82 | _container.Replace(firstBlog, new Blog { Id = firstBlog.Id, Title = "(edited)" + firstBlog.Title }); 83 | 84 | _container.Save(); 85 | 86 | await _container.Flush(); 87 | 88 | queryFirst.Count.Should().Be(0); 89 | 90 | removedEvent.Should().BeTrue(); 91 | notCalledEvent.Should().BeTrue(); 92 | 93 | queryFirst.CollectionChanged -= checkRemovedEvent; 94 | querySecond.CollectionChanged -= checkNotCalledEvent; 95 | } 96 | 97 | { 98 | var secondBlog = new Blog { Title = "My second blog" }; 99 | 100 | bool addedFirstQueryEvent = false; 101 | void checkAddedFirstQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 102 | { 103 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 104 | e.NewItems.Should().NotBeNull(); 105 | e.NewItems![0].Should().BeSameAs(secondBlog); 106 | e.NewStartingIndex.Should().Be(0); 107 | e.OldItems.Should().BeNull(); 108 | 109 | addedFirstQueryEvent = true; 110 | }; 111 | 112 | queryFirst.CollectionChanged += checkAddedFirstQueryEvent; 113 | 114 | bool addedSecondQueryEvent = false; 115 | void checkAddedSecondQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 116 | { 117 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 118 | e.NewItems.Should().NotBeNull(); 119 | e.NewItems![0].Should().BeSameAs(secondBlog); 120 | e.NewStartingIndex.Should().Be(0); 121 | e.OldItems.Should().BeNull(); 122 | 123 | addedSecondQueryEvent = true; 124 | }; 125 | 126 | querySecond.CollectionChanged += checkAddedSecondQueryEvent; 127 | 128 | _container.Add(secondBlog); 129 | 130 | _container.Save(); 131 | 132 | await _container.Flush(); 133 | 134 | queryFirst.Count.Should().Be(1); 135 | querySecond.Count.Should().Be(1); 136 | 137 | addedFirstQueryEvent.Should().BeTrue(); 138 | addedSecondQueryEvent.Should().BeTrue(); 139 | 140 | queryFirst.CollectionChanged -= checkAddedFirstQueryEvent; 141 | querySecond.CollectionChanged -= checkAddedSecondQueryEvent; 142 | } 143 | 144 | { 145 | firstBlog.Title = "My new blog"; 146 | 147 | bool addedFirstQueryEvent = false; 148 | void checkAddedFirstQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 149 | { 150 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 151 | e.NewItems.Should().NotBeNull(); 152 | e.NewItems![0].Should().BeSameAs(firstBlog); 153 | e.NewStartingIndex.Should().Be(0); 154 | e.OldItems.Should().BeNull(); 155 | 156 | addedFirstQueryEvent = true; 157 | }; 158 | 159 | queryFirst.CollectionChanged += checkAddedFirstQueryEvent; 160 | 161 | bool notCalledSecondQueryEvent = true; 162 | void checkAddedSecondQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 163 | { 164 | notCalledSecondQueryEvent = false; 165 | }; 166 | 167 | querySecond.CollectionChanged += checkAddedSecondQueryEvent; 168 | 169 | _container.Replace(firstBlog, firstBlog); 170 | 171 | _container.Save(); 172 | 173 | await _container.Flush(); 174 | 175 | queryFirst.Count.Should().Be(2); 176 | querySecond.Count.Should().Be(1); 177 | 178 | addedFirstQueryEvent.Should().BeTrue(); 179 | notCalledSecondQueryEvent.Should().BeTrue(); 180 | 181 | queryFirst.CollectionChanged -= checkAddedFirstQueryEvent; 182 | querySecond.CollectionChanged -= checkAddedSecondQueryEvent; 183 | } 184 | 185 | { 186 | firstBlog.Title += " (updated)"; 187 | 188 | bool updatedFirstQueryEvent = false; 189 | void checkUpdatedFirstQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 190 | { 191 | e.Action.Should().Be(NotifyCollectionChangedAction.Replace); 192 | e.NewItems.Should().NotBeNull(); 193 | e.NewItems![0].Should().BeSameAs(firstBlog); 194 | e.NewStartingIndex.Should().Be(0); 195 | e.OldItems.Should().NotBeNull(); 196 | e.OldItems![0].Should().BeSameAs(firstBlog); 197 | e.OldStartingIndex.Should().Be(0); 198 | 199 | updatedFirstQueryEvent = true; 200 | }; 201 | 202 | queryFirst.CollectionChanged += checkUpdatedFirstQueryEvent; 203 | 204 | bool notCalledSecondQueryEvent = true; 205 | void checkNotCalledSecondQueryEvent(object? sender, NotifyCollectionChangedEventArgs e) 206 | { 207 | notCalledSecondQueryEvent = false; 208 | }; 209 | 210 | querySecond.CollectionChanged += checkNotCalledSecondQueryEvent; 211 | 212 | _container.Replace(firstBlog, firstBlog); 213 | 214 | _container.Save(); 215 | 216 | await _container.Flush(); 217 | 218 | queryFirst.Count.Should().Be(2); 219 | querySecond.Count.Should().Be(1); 220 | 221 | updatedFirstQueryEvent.Should().BeTrue(); 222 | notCalledSecondQueryEvent.Should().BeTrue(); 223 | 224 | queryFirst.CollectionChanged -= checkUpdatedFirstQueryEvent; 225 | querySecond.CollectionChanged -= checkNotCalledSecondQueryEvent; 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/QueryTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using ReactorData.Tests.Models; 4 | using System.Collections.Specialized; 5 | 6 | namespace ReactorData.Tests; 7 | 8 | class QueryTests 9 | { 10 | IServiceProvider _services; 11 | IModelContext _container; 12 | 13 | [SetUp] 14 | public void Setup() 15 | { 16 | var serviceCollection = new ServiceCollection(); 17 | serviceCollection.AddReactorData(); 18 | _services = serviceCollection.BuildServiceProvider(); 19 | _container = _services.GetRequiredService(); 20 | } 21 | 22 | [Test] 23 | public async Task TestQueryFunctions() 24 | { 25 | var todo = new Todo 26 | { 27 | Title = "Learn C#" 28 | }; 29 | 30 | var query = _container.Query(query => query.Where(_ => _.Done).OrderBy(_=>_.Title)); 31 | 32 | _container.Add(todo); 33 | 34 | await _container.Flush(); 35 | 36 | query.Count.Should().Be(0); 37 | 38 | var todo2 = new Todo 39 | { 40 | Title = "Learn Python", 41 | Done = true 42 | }; 43 | 44 | bool addedEvent = false; 45 | void checkAddedEvent(object? sender, NotifyCollectionChangedEventArgs e) 46 | { 47 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 48 | e.NewItems.Should().NotBeNull(); 49 | e.NewItems![0].Should().BeSameAs(todo2); 50 | e.NewStartingIndex.Should().Be(0); 51 | e.OldItems.Should().BeNull(); 52 | 53 | addedEvent = true; 54 | }; 55 | 56 | query.CollectionChanged += checkAddedEvent; 57 | 58 | _container.Add(todo2); 59 | 60 | await _container.Flush(); 61 | 62 | query.Count.Should().Be(1); 63 | 64 | addedEvent.Should().BeTrue(); 65 | 66 | addedEvent = false; 67 | void checkAddedSecondEvent(object? sender, NotifyCollectionChangedEventArgs e) 68 | { 69 | e.Action.Should().Be(NotifyCollectionChangedAction.Add); 70 | e.NewItems.Should().NotBeNull(); 71 | e.NewItems![0].Should().BeEquivalentTo(new Todo { Title = todo.Title, Done = true }); 72 | e.NewStartingIndex.Should().Be(0); //by default query order by key so here we have 0 as "Learn C#" is less than "Learn Python" 73 | e.OldItems.Should().BeNull(); 74 | 75 | addedEvent = true; 76 | }; 77 | 78 | query.CollectionChanged -= checkAddedEvent; 79 | query.CollectionChanged += checkAddedSecondEvent; 80 | 81 | _container.Replace(todo, new Todo { Title = todo.Title, Done = true }); 82 | 83 | await _container.Flush(); 84 | 85 | query.Count.Should().Be(2); 86 | 87 | addedEvent.Should().BeTrue(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ReactorData.Tests/ReactorData.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | true 11 | RS1036,NU1903,NU1902,NU1901 12 | 13 | 14 | 15 | 16 | 17 | all 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/ReactorData.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.9.34310.174 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactorData", "ReactorData\ReactorData.csproj", "{3406E76E-01DA-4C71-AAAD-A4A17B7DF8B5}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactorData.Tests", "ReactorData.Tests\ReactorData.Tests.csproj", "{BF54BCFB-F026-4AE4-A4F3-2A6EC61E6200}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactorData.EFCore", "ReactorData.EFCore\ReactorData.EFCore.csproj", "{C88DEE50-90D6-479D-BA43-6C4C7845EA75}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactorData.Sqlite", "ReactorData.Sqlite\ReactorData.Sqlite.csproj", "{9E3E4008-BBFB-400F-B86D-FCE47D854B8A}" 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactorData.SourceGenerators", "ReactorData.SourceGenerators\ReactorData.SourceGenerators.csproj", "{E2F648A0-8E73-4CE8-99B7-2AF62424DD5F}" 15 | EndProject 16 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{2699D336-51FF-403E-B688-B3777E55DA21}" 17 | ProjectSection(SolutionItems) = preProject 18 | ..\.github\workflows\build-deploy.yml = ..\.github\workflows\build-deploy.yml 19 | EndProjectSection 20 | EndProject 21 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReactorData.Maui", "ReactorData.Maui\ReactorData.Maui.csproj", "{FFB3D7F9-AD6D-4CED-A8AB-CC931FCB55C1}" 22 | EndProject 23 | Global 24 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 25 | Debug|Any CPU = Debug|Any CPU 26 | Release|Any CPU = Release|Any CPU 27 | EndGlobalSection 28 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 29 | {3406E76E-01DA-4C71-AAAD-A4A17B7DF8B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {3406E76E-01DA-4C71-AAAD-A4A17B7DF8B5}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {3406E76E-01DA-4C71-AAAD-A4A17B7DF8B5}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {3406E76E-01DA-4C71-AAAD-A4A17B7DF8B5}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {BF54BCFB-F026-4AE4-A4F3-2A6EC61E6200}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {BF54BCFB-F026-4AE4-A4F3-2A6EC61E6200}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {BF54BCFB-F026-4AE4-A4F3-2A6EC61E6200}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {BF54BCFB-F026-4AE4-A4F3-2A6EC61E6200}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {C88DEE50-90D6-479D-BA43-6C4C7845EA75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {C88DEE50-90D6-479D-BA43-6C4C7845EA75}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {C88DEE50-90D6-479D-BA43-6C4C7845EA75}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {C88DEE50-90D6-479D-BA43-6C4C7845EA75}.Release|Any CPU.Build.0 = Release|Any CPU 41 | {9E3E4008-BBFB-400F-B86D-FCE47D854B8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {9E3E4008-BBFB-400F-B86D-FCE47D854B8A}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {9E3E4008-BBFB-400F-B86D-FCE47D854B8A}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {9E3E4008-BBFB-400F-B86D-FCE47D854B8A}.Release|Any CPU.Build.0 = Release|Any CPU 45 | {E2F648A0-8E73-4CE8-99B7-2AF62424DD5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 46 | {E2F648A0-8E73-4CE8-99B7-2AF62424DD5F}.Debug|Any CPU.Build.0 = Debug|Any CPU 47 | {E2F648A0-8E73-4CE8-99B7-2AF62424DD5F}.Release|Any CPU.ActiveCfg = Release|Any CPU 48 | {E2F648A0-8E73-4CE8-99B7-2AF62424DD5F}.Release|Any CPU.Build.0 = Release|Any CPU 49 | {FFB3D7F9-AD6D-4CED-A8AB-CC931FCB55C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 50 | {FFB3D7F9-AD6D-4CED-A8AB-CC931FCB55C1}.Debug|Any CPU.Build.0 = Debug|Any CPU 51 | {FFB3D7F9-AD6D-4CED-A8AB-CC931FCB55C1}.Release|Any CPU.ActiveCfg = Release|Any CPU 52 | {FFB3D7F9-AD6D-4CED-A8AB-CC931FCB55C1}.Release|Any CPU.Build.0 = Release|Any CPU 53 | EndGlobalSection 54 | GlobalSection(SolutionProperties) = preSolution 55 | HideSolutionNode = FALSE 56 | EndGlobalSection 57 | GlobalSection(ExtensibilityGlobals) = postSolution 58 | SolutionGuid = {0928D8AE-CF56-4E21-8A42-30B95B7E511A} 59 | EndGlobalSection 60 | EndGlobal 61 | -------------------------------------------------------------------------------- /src/ReactorData/AsyncAutoResetEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace ReactorData; 9 | 10 | internal sealed class AsyncAutoResetEvent 11 | { 12 | private static readonly Task _completed = Task.FromResult(true); 13 | private readonly Queue> _waits = new Queue>(); 14 | private bool _signaled; 15 | 16 | public Task WaitAsync() 17 | { 18 | lock (_waits) 19 | { 20 | if (_signaled) 21 | { 22 | _signaled = false; 23 | return _completed; 24 | } 25 | else 26 | { 27 | var tcs = new TaskCompletionSource(); 28 | _waits.Enqueue(tcs); 29 | return tcs.Task; 30 | } 31 | } 32 | } 33 | 34 | public void Set() 35 | { 36 | TaskCompletionSource? toRelease = null; 37 | 38 | lock (_waits) 39 | { 40 | if (_waits.Count > 0) 41 | { 42 | toRelease = _waits.Dequeue(); 43 | } 44 | else if (!_signaled) 45 | { 46 | _signaled = true; 47 | } 48 | } 49 | 50 | toRelease?.SetResult(true); 51 | } 52 | } -------------------------------------------------------------------------------- /src/ReactorData/IDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace ReactorData; 8 | 9 | public interface IDispatcher 10 | { 11 | void Dispatch(Action action); 12 | 13 | void OnError(Exception exception); 14 | } 15 | -------------------------------------------------------------------------------- /src/ReactorData/IEntity.cs: -------------------------------------------------------------------------------- 1 | namespace ReactorData; 2 | 3 | public interface IEntity 4 | { 5 | object? GetKey(); 6 | 7 | string? SharedTypeEntityKey() => null; 8 | } -------------------------------------------------------------------------------- /src/ReactorData/IModelContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Linq; 5 | using System.Linq.Expressions; 6 | using System.Threading.Tasks; 7 | 8 | namespace ReactorData; 9 | 10 | /// 11 | /// Reactive container of entities. An entity is an object of a type decorated with the attribute. 12 | /// When a storage is configured, entities are automatically persisted in background. 13 | /// 14 | public interface IModelContext 15 | { 16 | /// 17 | /// Adds one or more entities to the context 18 | /// 19 | /// Entities to add 20 | /// Entities are marked with . To persist any change you have to call 21 | void Add(params T[] entities) where T : class, IEntity; 22 | 23 | /// 24 | /// Replaces one entity in to the context 25 | /// 26 | /// Old entity to replace 27 | /// New entity to put in the container 28 | /// Old entity is marked as while new entity is put in the container with status . To persist any change you have to call 29 | void Replace(T oldEntity, T newEntity) where T : class, IEntity; 30 | 31 | /// 32 | /// Delete one or more entities 33 | /// 34 | /// Entities to delete 35 | /// Entities are marked with only when not already in the status. To persist any change you have to call 36 | void Delete(params T[] entities) where T : class, IEntity; 37 | 38 | /// 39 | /// Save any pending changes in a background thread. After the save is applied pending entities will have the status 40 | /// 41 | /// The number of operations saved 42 | int Save(); 43 | 44 | /// 45 | /// Find an entity by Key (usually is the Id property of the entity) 46 | /// 47 | /// Type of the entity to return 48 | /// Key to search 49 | /// The entity found or null 50 | T? FindByKey(object key) where T : class, IEntity; 51 | 52 | /// 53 | /// Discard any pending change and revert the context to the initial state 54 | /// 55 | int DiscardChanges(); 56 | 57 | /// 58 | /// Wait for any pending operation to the context to complete 59 | /// 60 | /// Task to wait to get the number of operations flushed 61 | Task Flush(); 62 | 63 | /// 64 | /// Get the af an entity 65 | /// 66 | /// Entity to get status 67 | /// The of the entity. If the entity is not yet known by the context the is returned 68 | EntityStatus GetEntityStatus(IEntity entity); 69 | 70 | /// 71 | /// Create a query () that is update everytime the context is modified. 72 | /// 73 | /// Type to listent 74 | /// Optional query predicate to apply to entities modified 75 | /// An object that implements . The object is notified with any change to entities that pass the filter 76 | IQuery Query(Expression, IQueryable>>? predicate = null) where T : class, IEntity; 77 | 78 | /// 79 | /// Load entities from the datastore. You can specify which kind of entity to load and a function used to filter entities to load. 80 | /// 81 | /// Types of entities to load 82 | /// Optional function used to filter out entities loaded from the storage 83 | /// Optional function used to compare entities. Returns true when the entities are equal. 84 | /// True if all previous loaded entities of type T must be discarded before loading 85 | /// Optional callback function that is called (using the configured dispatcher) when the load completes 86 | void Load( 87 | Expression, IQueryable>>? predicate = null, 88 | Func? compareFunc = null, 89 | bool forceReload = false, 90 | Action>? onLoad = null) where T : class, IEntity; 91 | 92 | /// 93 | /// Creates a scoped (child) context that is funcionally separated by this context but that uses the same storage 94 | /// 95 | /// The scoped context 96 | IModelContext CreateScope(); 97 | 98 | ///// 99 | ///// Run background task for the context 100 | ///// 101 | ///// Task to execute in background 102 | ///// During the execution of the task, all the pending operations are suspended 103 | //void RunBackgroundTask(Func task); 104 | 105 | /// 106 | /// Event raised on the UI thread when a property of the context changes (ie IsLoading or IsSaving) 107 | /// 108 | public event PropertyChangedEventHandler? PropertyChanged; 109 | 110 | /// 111 | /// True when the context is loading entities from the storage 112 | /// 113 | bool IsLoading { get; } 114 | 115 | /// 116 | /// True when the context is saving entities to the storage 117 | /// 118 | bool IsSaving { get; } 119 | 120 | /// 121 | /// Returns the number of pending operations in the context 122 | /// 123 | int PendingOperationsCount { get; } 124 | } 125 | 126 | /// 127 | /// Identify the status of an entity 128 | /// 129 | public enum EntityStatus 130 | { 131 | Detached, 132 | Attached, 133 | Added, 134 | Updated, 135 | Deleted 136 | } 137 | 138 | public static class ModelContextExtensions 139 | { 140 | /// 141 | /// Update one or more entities "in-place" 142 | /// 143 | /// Context the contains the entity to update 144 | /// Entities to update 145 | /// Differently from the keeps the same entities already added to the container. Be aware that if you attached a query on a UI list like the .NET MAUI CollectionView you have to use the Replace function instead to see the items updated. 146 | public static void Update(this IModelContext modelContext, params IEntity[] entities) 147 | { 148 | foreach (var entity in entities) 149 | { 150 | modelContext.Replace(entity, entity); 151 | } 152 | } 153 | 154 | /// 155 | /// True when either or are true 156 | /// 157 | /// 158 | /// 159 | public static bool IsBusy(this IModelContext modelContext) 160 | => modelContext.IsLoading || modelContext.IsSaving; 161 | 162 | 163 | public static async Task> Fetch(this IModelContext modelContext, 164 | Expression, 165 | IQueryable>>? predicate = null, 166 | Func? compareFunc = null, 167 | bool forceReload = false) where T : class, IEntity 168 | { 169 | modelContext.Load(predicate, compareFunc, forceReload); 170 | 171 | await modelContext.Flush(); 172 | 173 | var query = modelContext.Query(predicate); 174 | 175 | return query; 176 | } 177 | 178 | public static bool HasPendingOperations(this IModelContext modelContext) 179 | => modelContext.PendingOperationsCount > 0; 180 | 181 | } 182 | -------------------------------------------------------------------------------- /src/ReactorData/IPathProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace ReactorData; 8 | 9 | public interface IPathProvider 10 | { 11 | /// 12 | /// Gets the default local machine cache directory (i.e. the one for temporary data). 13 | /// 14 | /// The default local machine cache directory. 15 | string? GetDefaultLocalMachineCacheDirectory(); 16 | 17 | /// 18 | /// Gets the default roaming cache directory (i.e. the one for user settings). 19 | /// 20 | /// The default roaming cache directory. 21 | string? GetDefaultRoamingCacheDirectory(); 22 | 23 | /// 24 | /// Gets the default roaming cache directory (i.e. the one for user secrets). 25 | /// 26 | /// The default roaming cache directory. 27 | string? GetDefaultSecretCacheDirectory(); 28 | } 29 | -------------------------------------------------------------------------------- /src/ReactorData/IQuery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Collections.ObjectModel; 5 | using System.Collections.Specialized; 6 | using System.ComponentModel; 7 | using System.Linq; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | 11 | namespace ReactorData; 12 | 13 | /// 14 | /// Reactive query of entities that supports the interface 15 | /// 16 | /// Type of the entities monitored by the query 17 | public interface IQuery : 18 | ICollection, 19 | IEnumerable, 20 | IEnumerable, 21 | IList, 22 | IReadOnlyCollection, 23 | IReadOnlyList, 24 | ICollection, 25 | IList, 26 | INotifyCollectionChanged, 27 | INotifyPropertyChanged where T : class, IEntity 28 | { 29 | new int Count => ((ICollection)this).Count; 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/ReactorData/IStorage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Linq.Expressions; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace ReactorData; 9 | 10 | /// 11 | /// Represents a storage in ReactorData. calls methods of this interface when it needs to persists entities. 12 | /// 13 | public interface IStorage 14 | { 15 | /// 16 | /// Called by the when it needs to persists entities. 17 | /// 18 | /// List of CRUD operations to execute 19 | /// Task that waits 20 | Task Save(IEnumerable operations); 21 | 22 | /// 23 | /// Called by when it needs to load entities of a specific type. 24 | /// 25 | /// Type of entities to load 26 | /// Query function to use when loading. For example, storages like EF core uses this query to filter records. 27 | /// Task that waits 28 | Task> Load(Func, IQueryable>? queryFunction = null) where T : class, IEntity; 29 | } 30 | 31 | /// 32 | /// Generic CRUD operation 33 | /// 34 | /// List of entities linked to the operation 35 | public abstract class StorageOperation(IEnumerable entities) 36 | { 37 | public IEnumerable Entities { get; } = entities; 38 | } 39 | 40 | /// 41 | /// Add operation 42 | /// 43 | /// List of entities to add to the storage 44 | public class StorageAdd(IEnumerable entities) : StorageOperation(entities); 45 | 46 | /// 47 | /// Update operation 48 | /// 49 | /// List of entities to update in the storage 50 | public class StorageUpdate(IEnumerable entities) : StorageOperation(entities); 51 | 52 | /// 53 | /// Delete operation 54 | /// 55 | /// List of entities to delete in the storage 56 | public class StorageDelete(IEnumerable entities) : StorageOperation(entities); 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/ReactorData/Implementation/ModelContext.Loading.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace ReactorData.Implementation; 9 | 10 | public partial class ModelContext 11 | { 12 | private bool _isLoading; 13 | private bool _isSaving; 14 | 15 | public event PropertyChangedEventHandler? PropertyChanged; 16 | 17 | public bool IsLoading 18 | { 19 | get => _isLoading; 20 | private set 21 | { 22 | if (_isLoading != value) 23 | { 24 | _isLoading = value; 25 | var propertyChanged = PropertyChanged; 26 | if (propertyChanged != null) 27 | { 28 | if (Dispatcher != null) 29 | { 30 | Dispatcher.Dispatch(() => propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsLoading)))); 31 | } 32 | else 33 | { 34 | propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsLoading))); 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | public bool IsSaving 42 | { 43 | get => _isSaving; 44 | private set 45 | { 46 | if (_isSaving != value) 47 | { 48 | _isSaving = value; 49 | var propertyChanged = PropertyChanged; 50 | if (propertyChanged != null) 51 | { 52 | if (Dispatcher != null) 53 | { 54 | Dispatcher.Dispatch(() => propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSaving)))); 55 | } 56 | else 57 | { 58 | propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSaving))); 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/ReactorData/Implementation/ModelContext.Operation.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System.Collections.Concurrent; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Runtime.CompilerServices; 5 | using System.Security.Cryptography; 6 | using System.Xml.Linq; 7 | 8 | namespace ReactorData.Implementation; 9 | 10 | partial class ModelContext 11 | { 12 | abstract record Operation() 13 | { 14 | internal abstract ValueTask Do(ModelContext context); 15 | }; 16 | 17 | abstract record OperationPending : Operation 18 | { 19 | 20 | } 21 | 22 | record OperationAdd(IEnumerable Entities) : OperationPending where T : class, IEntity 23 | { 24 | internal override ValueTask Do(ModelContext context) 25 | { 26 | foreach (var entity in Entities) 27 | { 28 | var entityStatus = context.GetEntityStatus(entity); 29 | 30 | if (entityStatus != EntityStatus.Detached) 31 | { 32 | continue; 33 | } 34 | 35 | context._entityStatus[entity] = EntityStatus.Added; 36 | 37 | context.NotifyChanges(entity.GetType()); 38 | 39 | context._operationQueue.Enqueue((entity, EntityStatus.Added)); 40 | } 41 | 42 | return ValueTask.CompletedTask; 43 | } 44 | } 45 | 46 | record OperationUpdate(T OldEntity, T NewEntity) : OperationPending where T : class, IEntity 47 | { 48 | internal override ValueTask Do(ModelContext context) 49 | { 50 | var oldEntityStatus = context.GetEntityStatus(OldEntity); 51 | 52 | if (oldEntityStatus == EntityStatus.Detached) 53 | { 54 | context._entityStatus[NewEntity] = EntityStatus.Updated; 55 | 56 | context._operationQueue.Enqueue((NewEntity, EntityStatus.Updated)); 57 | 58 | context.NotifyChanges(NewEntity.GetType(), [NewEntity]); 59 | } 60 | else if (oldEntityStatus == EntityStatus.Attached) 61 | { 62 | var entityToUpdateKey = OldEntity.GetKey().EnsureNotNull(); 63 | 64 | var entityType = OldEntity.GetType(); 65 | var set = context._sets.GetOrAdd(entityType, []); 66 | 67 | set[entityToUpdateKey] = NewEntity; 68 | 69 | context._entityStatus[NewEntity] = EntityStatus.Updated; 70 | 71 | context._operationQueue.Enqueue((NewEntity, EntityStatus.Updated)); 72 | 73 | context.NotifyChanges(NewEntity.GetType(), [NewEntity]); 74 | } 75 | else if (oldEntityStatus == EntityStatus.Added) 76 | { 77 | context._entityStatus.Remove(OldEntity, out var _); 78 | context._entityStatus[NewEntity] = EntityStatus.Added; 79 | 80 | #if DEBUG 81 | if (!context._operationQueue.Any(_ => Equals(((IEntity)_.Entity).GetKey(), NewEntity.GetKey()))) 82 | { 83 | System.Diagnostics.Debug.Assert(false); 84 | } 85 | #endif 86 | context._operationQueue.Enqueue((NewEntity, EntityStatus.Added)); 87 | 88 | context.NotifyChanges(NewEntity.GetType(), [NewEntity]); 89 | } 90 | else if (oldEntityStatus == EntityStatus.Deleted) 91 | { 92 | context._entityStatus.Remove(OldEntity, out var _); 93 | context._entityStatus[NewEntity] = EntityStatus.Updated; 94 | 95 | #if DEBUG 96 | if (!context._operationQueue.Any(_ => Equals(((IEntity)_.Entity).GetKey(), NewEntity.GetKey()))) 97 | { 98 | System.Diagnostics.Debug.Assert(false); 99 | } 100 | #endif 101 | context._operationQueue.Enqueue((NewEntity, EntityStatus.Updated)); 102 | 103 | context.NotifyChanges(NewEntity.GetType(), [NewEntity]); 104 | } 105 | 106 | return ValueTask.CompletedTask; 107 | } 108 | } 109 | 110 | record OperationDelete(IEnumerable Entities) : OperationPending where T : class, IEntity 111 | { 112 | internal override ValueTask Do(ModelContext context) 113 | { 114 | foreach (var entity in Entities) 115 | { 116 | var entityStatus = context.GetEntityStatus(entity); 117 | 118 | if (entityStatus == EntityStatus.Deleted) 119 | { 120 | continue; 121 | } 122 | 123 | if (entityStatus == EntityStatus.Added) 124 | { 125 | context._entityStatus.Remove(entity, out var _); 126 | 127 | context.NotifyChanges(entity.GetType()); 128 | } 129 | else 130 | { 131 | context._entityStatus[entity] = EntityStatus.Deleted; 132 | 133 | context.NotifyChanges(entity.GetType()); 134 | 135 | context._operationQueue.Enqueue((entity, EntityStatus.Deleted)); 136 | } 137 | } 138 | 139 | return ValueTask.CompletedTask; 140 | 141 | } 142 | } 143 | 144 | record OperationFetch( 145 | Type EntityTypeToLoad, 146 | Func>> LoadFunction, 147 | Func? CompareFunc = null, 148 | bool ForceReload = false, 149 | Action>? OnLoad = null) : Operation 150 | { 151 | public Func>> LoadFunction { get; } = LoadFunction; 152 | public Func? CompareFunc { get; } = CompareFunc; 153 | public Action>? OnLoad { get; } = OnLoad; 154 | 155 | internal override async ValueTask Do(ModelContext context) 156 | { 157 | var storage = /*context._owner?._storage ?? */context._storage; 158 | if (storage == null) 159 | { 160 | return; 161 | } 162 | 163 | try 164 | { 165 | context._logger?.LogDebug("OperationFetch.Begin() {Type}", EntityTypeToLoad); 166 | 167 | context.IsLoading = true; 168 | 169 | var entities = await LoadFunction(storage); 170 | HashSet queryTypesToNofity = []; 171 | ConcurrentDictionary> entitiesChanged = []; 172 | 173 | queryTypesToNofity.Add(EntityTypeToLoad); 174 | 175 | if (ForceReload) 176 | { 177 | var set = context._sets.GetOrAdd(EntityTypeToLoad, []); 178 | set.Clear(); 179 | } 180 | 181 | if (ForceReload) 182 | { 183 | foreach (var entity in entities) 184 | { 185 | var entityType = entity.GetType(); 186 | var set = context._sets.GetOrAdd(entityType, []); 187 | 188 | set.Clear(); 189 | } 190 | } 191 | 192 | foreach (var entity in entities) 193 | { 194 | var entityType = entity.GetType(); 195 | var set = context._sets.GetOrAdd(entityType, []); 196 | 197 | var entityKey = entity.GetKey().EnsureNotNull(); 198 | 199 | if (set.TryGetValue(entityKey, out var localEntity)) 200 | { 201 | if (CompareFunc?.Invoke(entity, localEntity) == false) 202 | { 203 | var entityChangesInSet = entitiesChanged.GetOrAdd(entityType, []); 204 | entityChangesInSet.Add(entity); 205 | } 206 | 207 | set[entityKey] = entity; 208 | } 209 | else 210 | { 211 | set.TryAdd(entityKey, entity); 212 | } 213 | 214 | queryTypesToNofity.Add(entityType); 215 | } 216 | 217 | foreach (var queryTypeToNofity in queryTypesToNofity) 218 | { 219 | if (ForceReload) 220 | { 221 | context.NotifyChanges(queryTypeToNofity, forceReload: true); 222 | } 223 | else 224 | { 225 | if (entitiesChanged.TryGetValue(queryTypeToNofity, out var entityChangesInSet)) 226 | { 227 | context.NotifyChanges(queryTypeToNofity, [.. entityChangesInSet]); 228 | } 229 | else 230 | { 231 | context.NotifyChanges(queryTypeToNofity); 232 | } 233 | } 234 | } 235 | 236 | if (OnLoad != null) 237 | { 238 | if (context.Dispatcher != null) 239 | { 240 | context.Dispatcher.Dispatch(() => OnLoad.Invoke(entities)); 241 | } 242 | else 243 | { 244 | OnLoad.Invoke(entities); 245 | } 246 | } 247 | } 248 | finally 249 | { 250 | context.IsLoading = false; 251 | context._logger?.LogDebug("OperationFetch.End() {Type}", EntityTypeToLoad); 252 | } 253 | } 254 | } 255 | 256 | record OperationDiscardChanges() : Operation 257 | { 258 | internal override ValueTask Do(ModelContext context) 259 | { 260 | HashSet queryTypesToNofity = []; 261 | HashSet changedEntities = []; 262 | 263 | foreach (var (Entity, _) in context._operationQueue) 264 | { 265 | changedEntities.Add((IEntity)Entity); 266 | 267 | var entityType = Entity.GetType(); 268 | queryTypesToNofity.Add(entityType); 269 | } 270 | 271 | context._operationQueue.Clear(); 272 | context._entityStatus.Clear(); 273 | 274 | foreach (var queryTypeToNofity in queryTypesToNofity) 275 | { 276 | context.NotifyChanges(queryTypeToNofity, [.. changedEntities]); 277 | } 278 | 279 | return ValueTask.CompletedTask; 280 | } 281 | } 282 | 283 | record OperationSave() : Operation 284 | { 285 | internal override async ValueTask Do(ModelContext context) 286 | { 287 | var storage = context._storage; 288 | 289 | try 290 | { 291 | context._logger?.LogDebug("OperationSave.Begin()"); 292 | 293 | context.IsSaving = storage != null && context._operationQueue.Count > 0; 294 | if (storage != null) 295 | { 296 | var listOfStorageOperation = new List(); 297 | var operationsAddedForEachType = new Dictionary>(); 298 | foreach (var (Entity, Status) in context._operationQueue) 299 | { 300 | var entity = (IEntity)Entity; 301 | var currentEntityStatus = context.GetEntityStatus(entity); 302 | 303 | if (currentEntityStatus != Status) 304 | { 305 | continue; 306 | } 307 | 308 | var key = entity.GetKey(); 309 | var entityType = Entity.GetType(); 310 | 311 | if (key != null) 312 | { 313 | if (!operationsAddedForEachType.TryGetValue(entityType, out var operationsAdded)) 314 | { 315 | operationsAddedForEachType[entityType] = operationsAdded = []; 316 | } 317 | 318 | if (operationsAdded.Contains(key)) 319 | { 320 | System.Diagnostics.Debug.WriteLine($"StorageOperation: {Status} (Key already added: {key})"); 321 | context._logger?.LogWarning("OperationSave: {Status} {Type} ({Key}) Key already added", Status, Entity.GetType().Name, key); 322 | continue; 323 | } 324 | 325 | operationsAdded.Add(key); 326 | } 327 | 328 | System.Diagnostics.Debug.WriteLine($"StorageOperation: {Status}"); 329 | context._logger?.LogDebug("OperationSave: {Status} {Type} ({Key})", Status, Entity.GetType().Name, key); 330 | 331 | switch (Status) 332 | { 333 | case EntityStatus.Added: 334 | listOfStorageOperation.Add(new StorageAdd([Entity])); 335 | break; 336 | case EntityStatus.Updated: 337 | listOfStorageOperation.Add(new StorageUpdate([Entity])); 338 | break; 339 | case EntityStatus.Deleted: 340 | listOfStorageOperation.Add(new StorageDelete([Entity])); 341 | break; 342 | } 343 | } 344 | 345 | await storage.Save(listOfStorageOperation); 346 | } 347 | 348 | HashSet queryTypesToNofity = []; 349 | foreach (var (Entity, Status) in context._operationQueue) 350 | { 351 | var entity = (IEntity)Entity; 352 | var currentEntityStatus = context.GetEntityStatus(entity); 353 | 354 | if (currentEntityStatus != Status) 355 | { 356 | continue; 357 | } 358 | 359 | switch (Status) 360 | { 361 | case EntityStatus.Added: 362 | { 363 | var entityType = Entity.GetType(); 364 | var set = context._sets.GetOrAdd(entityType, []); 365 | set[entity.GetKey().EnsureNotNull()] = entity; 366 | 367 | queryTypesToNofity.Add(entityType); 368 | } 369 | break; 370 | case EntityStatus.Updated: 371 | { 372 | var entityType = Entity.GetType(); 373 | var set = context._sets.GetOrAdd(entityType, []); 374 | set[entity.GetKey().EnsureNotNull()] = entity; 375 | 376 | queryTypesToNofity.Add(entityType); 377 | } 378 | break; 379 | case EntityStatus.Deleted: 380 | { 381 | var set = context._sets.GetOrAdd(Entity.GetType(), []); 382 | set.TryRemove(entity.GetKey().EnsureNotNull(), out var _); 383 | 384 | queryTypesToNofity.Add(Entity.GetType()); 385 | } 386 | break; 387 | } 388 | } 389 | 390 | context._operationQueue.Clear(); 391 | context._entityStatus.Clear(); 392 | 393 | foreach (var queryTypeToNofity in queryTypesToNofity) 394 | { 395 | context.NotifyChanges(queryTypeToNofity); 396 | } 397 | } 398 | finally 399 | { 400 | context.IsSaving = false; 401 | context._logger?.LogDebug("OperationSave.End()"); 402 | } 403 | } 404 | } 405 | 406 | record OperationFlush(AsyncAutoResetEvent Signal) : Operation 407 | { 408 | public AsyncAutoResetEvent Signal { get; } = Signal; 409 | 410 | internal override ValueTask Do(ModelContext context) 411 | { 412 | Signal.Set(); 413 | 414 | return ValueTask.CompletedTask; 415 | } 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /src/ReactorData/Implementation/ModelContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Linq.Expressions; 8 | using System.Runtime.Serialization; 9 | using System.Text; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using System.Threading.Tasks.Dataflow; 13 | 14 | namespace ReactorData.Implementation; 15 | 16 | partial class ModelContext : IModelContext 17 | { 18 | private readonly ConcurrentDictionary> _sets = []; 19 | 20 | private readonly Queue<(object Entity, EntityStatus Status)> _operationQueue = []; 21 | 22 | private readonly ConcurrentDictionary _entityStatus = []; 23 | 24 | private readonly ActionBlock _operationsBlock; 25 | 26 | private readonly IStorage? _storage; 27 | 28 | private readonly ConcurrentDictionary>> _queries = []; 29 | 30 | private readonly SemaphoreSlim _notificationSemaphore = new(1); 31 | 32 | private readonly ILogger? _logger; 33 | 34 | IServiceProvider _serviceProvider; 35 | 36 | public ModelContext(IServiceProvider serviceProvider, ModelContextOptions options) 37 | { 38 | _serviceProvider = serviceProvider; 39 | _operationsBlock = new ActionBlock(DoWork); 40 | _storage = serviceProvider.GetService(); 41 | _logger = serviceProvider.GetService()?.CreateLogger(); 42 | 43 | Dispatcher = serviceProvider.GetService(); 44 | 45 | Options = options; 46 | Options.ConfigureContext?.Invoke(this); 47 | } 48 | 49 | 50 | public ModelContextOptions Options { get; } 51 | 52 | public IDispatcher? Dispatcher { get; } 53 | 54 | public int PendingOperationsCount => _operationQueue.Count; 55 | 56 | public EntityStatus GetEntityStatus(IEntity entity) 57 | { 58 | if (_entityStatus.TryGetValue(entity, out var entityStatus)) 59 | { 60 | return entityStatus; 61 | } 62 | 63 | var key = entity.GetKey(); 64 | if (key != null) 65 | { 66 | var set = _sets.GetOrAdd(entity.GetType(), []); 67 | if (set.TryGetValue(key, out var attachedEntity)) 68 | { 69 | if (entity == attachedEntity) 70 | { 71 | return EntityStatus.Attached; 72 | } 73 | } 74 | } 75 | 76 | return EntityStatus.Detached; 77 | } 78 | 79 | private async Task DoWork(Operation operation) 80 | { 81 | try 82 | { 83 | await operation.Do(this); 84 | } 85 | catch (Exception ex) 86 | { 87 | try 88 | { 89 | System.Diagnostics.Debug.WriteLine($"Exception raised in ModelContext: {ex}"); 90 | Dispatcher?.OnError(ex); 91 | } 92 | catch { } 93 | } 94 | } 95 | 96 | public IModelContext CreateScope() 97 | { 98 | return new ModelContext(_serviceProvider, Options); 99 | } 100 | 101 | public void Add(params T[] entities) where T : class, IEntity 102 | { 103 | if (_logger != null) 104 | { 105 | foreach (var entity in entities) 106 | { 107 | _logger?.LogDebug("OperationAdd.Post() EntityType:{EntityType} Key:{Key}", entity.GetType().Name, entity.GetKey()); 108 | } 109 | } 110 | 111 | _operationsBlock.Post(new OperationAdd(entities)); 112 | } 113 | 114 | public void Replace(T oldEntity, T newEntity) where T : class, IEntity 115 | { 116 | if (_logger != null) 117 | { 118 | if (oldEntity != newEntity) 119 | { 120 | _logger?.LogDebug("OperationUpdate.Post() OldEntity:{TypeOld} OldKey:{OldKey} NewEntity:{TypeNew} NewKey:{NewKey})", oldEntity.GetType().Name, oldEntity.GetKey(), newEntity.GetType().Name, newEntity.GetKey()); 121 | } 122 | else 123 | { 124 | _logger?.LogDebug("OperationUpdate.Post() EntityType:{EntityType} Key:{Key}", newEntity.GetType().Name, newEntity.GetType().Name); 125 | } 126 | } 127 | 128 | _operationsBlock.Post(new OperationUpdate(oldEntity, newEntity)); 129 | } 130 | 131 | public void Delete(params T[] entities) where T : class, IEntity 132 | { 133 | if (_logger != null) 134 | { 135 | foreach (var entity in entities) 136 | { 137 | _logger?.LogDebug("OperationDelete.Post() EntityType:{EntityType} Key:{Key}", entity.GetType().Name, entity.GetType().Name); 138 | } 139 | } 140 | 141 | _operationsBlock.Post(new OperationDelete(entities)); 142 | } 143 | 144 | public void Load( 145 | Expression, IQueryable>>? predicate = null, 146 | Func? compareFunc = null, 147 | bool forceReload = false, 148 | Action>? onLoad = null 149 | ) where T : class, IEntity 150 | { 151 | _logger?.LogDebug("OperationFetch.Load() {Type} ({Query})", typeof(T).Name, predicate); 152 | 153 | _operationsBlock.Post( 154 | new OperationFetch( 155 | typeof(T), 156 | LoadFunction: storage => storage.Load(predicate?.Compile()), 157 | CompareFunc: compareFunc != null ? (storageEntity, localEntity) => compareFunc((T)storageEntity, (T)localEntity) : null, 158 | ForceReload: forceReload, 159 | OnLoad: onLoad != null ? items => onLoad?.Invoke(items.Cast()) : null)); 160 | } 161 | 162 | public int Save() 163 | { 164 | _logger?.LogDebug("OperationSave.Post()"); 165 | 166 | var pendingOperationsCount = _operationQueue.Count; 167 | 168 | _operationsBlock.Post(new OperationSave()); 169 | 170 | return pendingOperationsCount; 171 | } 172 | 173 | public IReadOnlyList Set() where T : class, IEntity 174 | { 175 | var typeofT = typeof(T); 176 | var set = _sets.GetOrAdd(typeofT, []); 177 | 178 | return set 179 | .Select(_=>_.Value) 180 | .Cast() 181 | .Concat(_entityStatus.Where(_ => _.Value == EntityStatus.Added).Select(_ => _.Key).OfType()) 182 | .Except(_entityStatus.Where(_ => _.Value == EntityStatus.Deleted).Select(_ => _.Key).OfType()) 183 | .ToList(); 184 | } 185 | public int DiscardChanges() 186 | { 187 | _logger?.LogDebug("OperationDiscardChanges.Post()"); 188 | 189 | var pendingOperationsCount = _operationQueue.Count; 190 | 191 | _operationsBlock.Post(new OperationDiscardChanges()); 192 | 193 | return pendingOperationsCount; 194 | } 195 | 196 | public async Task Flush() 197 | { 198 | _logger?.LogDebug("OperationFlush.Post()"); 199 | 200 | var pendingOperationsCount = _operationQueue.Count; 201 | 202 | var signalEvent = new AsyncAutoResetEvent(); 203 | _operationsBlock.Post(new OperationFlush(signalEvent)); 204 | await signalEvent.WaitAsync(); 205 | 206 | return pendingOperationsCount; 207 | } 208 | 209 | public IQuery Query(Expression, IQueryable>>? predicateExpression = null) where T : class, IEntity 210 | { 211 | var predicate = predicateExpression?.Compile(); 212 | 213 | var typeofT = typeof(T); 214 | var queries = _queries.GetOrAdd(typeofT, []); 215 | 216 | var observableQuery = new ObservableQuery(this, predicate); 217 | queries.Add(new WeakReference(observableQuery)); 218 | 219 | return observableQuery.Query; 220 | } 221 | 222 | public T? FindByKey(object key) where T : class, IEntity 223 | { 224 | var typeofT = typeof(T); 225 | var set = _sets.GetOrAdd(typeofT, []); 226 | 227 | if (set.TryGetValue(key, out var entity)) 228 | { 229 | return (T)entity; 230 | } 231 | 232 | return default; 233 | } 234 | 235 | private void NotifyChanges(Type typeOfEntity, IEntity[]? changedEntities = null, bool forceReload = false) 236 | { 237 | var queries = _queries.GetOrAdd(typeOfEntity, []); 238 | 239 | try 240 | { 241 | _notificationSemaphore.Wait(); 242 | List>? referencesToRemove = null; 243 | foreach (var queryReference in queries.ToArray()) 244 | { 245 | if (queryReference.TryGetTarget(out var query)) 246 | { 247 | query.NotifyChanges(changedEntities, forceReload); 248 | } 249 | else 250 | { 251 | referencesToRemove ??= []; 252 | referencesToRemove.Add(queryReference); 253 | } 254 | } 255 | 256 | if (referencesToRemove?.Count > 0) 257 | { 258 | foreach (var queryReference in referencesToRemove) 259 | { 260 | queries.Remove(queryReference); 261 | } 262 | } 263 | } 264 | finally 265 | { 266 | _notificationSemaphore.Release(); 267 | } 268 | 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/ReactorData/Implementation/ObservableRangeCollection.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Collections.Specialized; 5 | using System.ComponentModel; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace ReactorData.Implementation; 11 | 12 | //https://raw.githubusercontent.com/jamesmontemagno/mvvm-helpers/master/MvvmHelpers/ObservableRangeCollection.cs 13 | 14 | /// 15 | /// Represents a dynamic data collection that provides notifications when items get added, removed, or when the whole list is refreshed. 16 | /// 17 | /// 18 | class ObservableRangeCollection : ObservableCollection 19 | { 20 | 21 | /// 22 | /// Initializes a new instance of the System.Collections.ObjectModel.ObservableCollection(Of T) class. 23 | /// 24 | public ObservableRangeCollection() 25 | : base() 26 | { 27 | } 28 | 29 | /// 30 | /// Initializes a new instance of the System.Collections.ObjectModel.ObservableCollection(Of T) class that contains elements copied from the specified collection. 31 | /// 32 | /// collection: The collection from which the elements are copied. 33 | /// The collection parameter cannot be null. 34 | public ObservableRangeCollection(IEnumerable collection) 35 | : base(collection) 36 | { 37 | } 38 | 39 | //protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) 40 | //{ 41 | // System.Diagnostics.Debug.WriteLine($"Action:{e.Action} NewStartingIndex:{e.NewStartingIndex} OldStartingIndex:{e.OldStartingIndex}"); 42 | // base.OnCollectionChanged(e); 43 | //} 44 | 45 | /// 46 | /// Adds the elements of the specified collection to the end of the ObservableCollection(Of T). 47 | /// 48 | public void AddRange(IEnumerable collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Add) 49 | { 50 | if (notificationMode != NotifyCollectionChangedAction.Add && notificationMode != NotifyCollectionChangedAction.Reset) 51 | throw new ArgumentException("Mode must be either Add or Reset for AddRange.", nameof(notificationMode)); 52 | 53 | CheckReentrancy(); 54 | 55 | var startIndex = Count; 56 | 57 | var itemsAdded = AddArrangeCore(collection); 58 | 59 | if (!itemsAdded) 60 | return; 61 | 62 | if (notificationMode == NotifyCollectionChangedAction.Reset) 63 | { 64 | RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset); 65 | 66 | return; 67 | } 68 | 69 | var changedItems = collection is List ? (List)collection : new List(collection); 70 | 71 | RaiseChangeNotificationEvents( 72 | action: NotifyCollectionChangedAction.Add, 73 | changedItems: changedItems, 74 | startingIndex: startIndex); 75 | } 76 | 77 | /// 78 | /// Removes the first occurence of each item in the specified collection from ObservableCollection(Of T). NOTE: with notificationMode = Remove, removed items starting index is not set because items are not guaranteed to be consecutive. 79 | /// 80 | public void RemoveRange(IEnumerable collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Reset) 81 | { 82 | if (notificationMode != NotifyCollectionChangedAction.Remove && notificationMode != NotifyCollectionChangedAction.Reset) 83 | throw new ArgumentException("Mode must be either Remove or Reset for RemoveRange.", nameof(notificationMode)); 84 | 85 | CheckReentrancy(); 86 | 87 | if (notificationMode == NotifyCollectionChangedAction.Reset) 88 | { 89 | var raiseEvents = false; 90 | foreach (var item in collection) 91 | { 92 | Items.Remove(item); 93 | raiseEvents = true; 94 | } 95 | 96 | if (raiseEvents) 97 | RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset); 98 | 99 | return; 100 | } 101 | 102 | var changedItems = new List(collection); 103 | for (var i = 0; i < changedItems.Count; i++) 104 | { 105 | if (!Items.Remove(changedItems[i])) 106 | { 107 | changedItems.RemoveAt(i); //Can't use a foreach because changedItems is intended to be (carefully) modified 108 | i--; 109 | } 110 | } 111 | 112 | if (changedItems.Count == 0) 113 | return; 114 | 115 | RaiseChangeNotificationEvents( 116 | action: NotifyCollectionChangedAction.Remove, 117 | changedItems: changedItems); 118 | } 119 | 120 | /// 121 | /// Clears the current collection and replaces it with the specified item. 122 | /// 123 | public void Replace(T item) => ReplaceRange(new T[] { item }); 124 | 125 | /// 126 | /// Clears the current collection and replaces it with the specified collection. 127 | /// 128 | public void ReplaceRange(IEnumerable collection) 129 | { 130 | if (collection == null) 131 | throw new ArgumentNullException(nameof(collection)); 132 | 133 | CheckReentrancy(); 134 | 135 | var previouslyEmpty = Items.Count == 0; 136 | 137 | Items.Clear(); 138 | 139 | AddArrangeCore(collection); 140 | 141 | var currentlyEmpty = Items.Count == 0; 142 | 143 | if (previouslyEmpty && currentlyEmpty) 144 | return; 145 | 146 | RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset); 147 | } 148 | 149 | public void InsertRange(int startIndex, IEnumerable collection, NotifyCollectionChangedAction notificationMode = NotifyCollectionChangedAction.Add) 150 | { 151 | if (notificationMode != NotifyCollectionChangedAction.Add && notificationMode != NotifyCollectionChangedAction.Reset) 152 | throw new ArgumentException("Mode must be either Add or Reset for AddRange.", nameof(notificationMode)); 153 | if (collection == null) 154 | throw new ArgumentNullException(nameof(collection)); 155 | 156 | CheckReentrancy(); 157 | 158 | var itemsAdded = InsertArrangeCore(startIndex, collection); 159 | 160 | if (!itemsAdded) 161 | return; 162 | 163 | if (notificationMode == NotifyCollectionChangedAction.Reset) 164 | { 165 | RaiseChangeNotificationEvents(action: NotifyCollectionChangedAction.Reset); 166 | 167 | return; 168 | } 169 | 170 | var changedItems = collection is List list ? list : new List(collection); 171 | 172 | RaiseChangeNotificationEvents( 173 | action: NotifyCollectionChangedAction.Add, 174 | changedItems: changedItems, 175 | startingIndex: startIndex); 176 | } 177 | 178 | 179 | private bool AddArrangeCore(IEnumerable collection) 180 | { 181 | var itemAdded = false; 182 | foreach (var item in collection) 183 | { 184 | Items.Add(item); 185 | itemAdded = true; 186 | } 187 | return itemAdded; 188 | } 189 | private bool InsertArrangeCore(int startIndex, IEnumerable collection) 190 | { 191 | var itemAdded = false; 192 | var index = startIndex; 193 | foreach (var item in collection) 194 | { 195 | Items.Insert(index++, item); 196 | itemAdded = true; 197 | } 198 | return itemAdded; 199 | } 200 | 201 | private void RaiseChangeNotificationEvents(NotifyCollectionChangedAction action, List? changedItems = null, int startingIndex = -1) 202 | { 203 | OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); 204 | OnPropertyChanged(new PropertyChangedEventArgs("Item[]")); 205 | 206 | if (changedItems is null) 207 | OnCollectionChanged(new NotifyCollectionChangedEventArgs(action)); 208 | else 209 | OnCollectionChanged(new NotifyCollectionChangedEventArgs(action, changedItems: changedItems, startingIndex: startingIndex)); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/ReactorData/Implementation/Query.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualBasic; 2 | using System; 3 | using System.Collections; 4 | using System.Collections.Generic; 5 | using System.Collections.ObjectModel; 6 | using System.Collections.Specialized; 7 | using System.ComponentModel; 8 | using System.Linq; 9 | using System.Linq.Expressions; 10 | using System.Text; 11 | using System.Xml.Linq; 12 | 13 | namespace ReactorData.Implementation; 14 | 15 | interface IObservableQuery 16 | { 17 | public abstract void NotifyChanges(IEntity[]? changedEntities, bool forceReload = false); 18 | } 19 | 20 | class ObservableQuery : IObservableQuery where T : class, IEntity 21 | { 22 | class ObservableQueryCollection(ObservableQuery owner, ObservableRangeCollection observableCollection) 23 | : ReadOnlyObservableCollection(observableCollection), IQuery 24 | { 25 | //note: required to keep the owener alive 26 | private readonly ObservableQuery _owner = owner; 27 | } 28 | 29 | private readonly ModelContext _container; 30 | 31 | private readonly Func, IQueryable>? _predicate; 32 | 33 | private readonly ObservableRangeCollection _collection; 34 | 35 | public ObservableQuery(ModelContext container, Func, IQueryable>? predicate = null) 36 | { 37 | _container = container; 38 | _predicate = predicate; 39 | 40 | _collection = new ObservableRangeCollection(GetContainerList()); 41 | Query = new ObservableQueryCollection(this, _collection); 42 | } 43 | 44 | public IQuery Query { get; } 45 | 46 | public void NotifyChanges(IEntity[]? changedEntities = null, bool forceReload = false) 47 | { 48 | if (_container.Dispatcher != null) 49 | { 50 | _container.Dispatcher.Dispatch(() => InternalNotifyChanges(changedEntities, forceReload)); 51 | } 52 | else 53 | { 54 | InternalNotifyChanges(changedEntities, forceReload); 55 | } 56 | } 57 | 58 | private void InternalNotifyChanges(IEntity[]? changedEntities, bool forceReload) 59 | { 60 | var newItems = GetContainerList(); 61 | 62 | if (!forceReload) 63 | { 64 | var changedEntitiesMap = changedEntities != null ? new HashSet(changedEntities) : null; 65 | var changedEntitiesIdsMap = changedEntities != null ? new HashSet(changedEntities.Where(_=>_.GetKey() != null).Select(_=>_.GetKey()!)) : null; 66 | static bool areEqual(T newItem, T existingItem) 67 | { 68 | var newKey = newItem.GetKey(); 69 | var oldKey = existingItem.GetKey(); 70 | return newItem == existingItem || (newKey == null && oldKey == null) || (newKey != null && oldKey != null && newKey.Equals(existingItem.GetKey())); 71 | } 72 | 73 | SyncLists(_collection, newItems, areEqual, item => 74 | { 75 | var itemKey = item.GetKey(); 76 | if (itemKey != null) 77 | { 78 | if (changedEntitiesIdsMap?.Contains(itemKey) == true) 79 | { 80 | return true; 81 | } 82 | } 83 | 84 | return changedEntities?.Contains(item) == true; 85 | }); 86 | } 87 | else 88 | { 89 | _collection.ReplaceRange(newItems); 90 | } 91 | } 92 | 93 | public static void SyncLists( 94 | ObservableRangeCollection existingList, 95 | IList newList, 96 | Func areEqual, 97 | Func replaceItem) 98 | { 99 | int existingIndex = 0; 100 | var itemsToAdd = new List(); 101 | 102 | foreach (var newItem in newList) 103 | { 104 | // Check if we've exceeded the bounds of the existing list; if so, add remaining new items 105 | if (existingIndex >= existingList.Count) 106 | { 107 | itemsToAdd.Add(newItem); 108 | continue; 109 | } 110 | 111 | // If the items match based on the equality function, move to the next item 112 | if (areEqual(existingList[existingIndex], newItem)) 113 | { 114 | if (itemsToAdd.Count != 0) 115 | { 116 | existingList.InsertRange(existingIndex, itemsToAdd); 117 | existingIndex += itemsToAdd.Count; 118 | itemsToAdd.Clear(); 119 | } 120 | 121 | if (replaceItem(newItem)) 122 | { 123 | existingList[existingIndex] = newItem; 124 | } 125 | 126 | existingIndex++; 127 | continue; 128 | } 129 | 130 | // If the existing item doesn't match and the new item is not found ahead, 131 | // it means we need to remove the existing item 132 | if (!newList.Skip(existingIndex).Any(x => areEqual(existingList[existingIndex], x))) 133 | { 134 | existingList.RemoveAt(existingIndex); 135 | // Do not increment existingIndex as we removed the item at that index 136 | continue; 137 | } 138 | 139 | // Otherwise, the new item should be inserted before the current existing item 140 | itemsToAdd.Add(newItem); 141 | } 142 | 143 | // Add any items that are still pending to be added 144 | if (itemsToAdd.Count != 0) 145 | { 146 | existingList.AddRange(itemsToAdd); 147 | } 148 | 149 | // If there are any remaining elements in the existing list that are not in the new list, remove them 150 | var itemsToRemove = existingList.Skip(newList.Count).ToList(); 151 | if (itemsToRemove.Count != 0) 152 | { 153 | if (itemsToRemove.Count <= 10) 154 | { 155 | foreach (var itemToRemove in itemsToRemove) 156 | { 157 | existingList.Remove(itemToRemove); 158 | } 159 | } 160 | else 161 | { 162 | existingList.RemoveRange(itemsToRemove); 163 | } 164 | } 165 | } 166 | 167 | private T[] GetContainerList() 168 | { 169 | var newList = _container.Set().AsQueryable(); 170 | 171 | if (_predicate != null) 172 | { 173 | newList = _predicate(newList); 174 | } 175 | 176 | return [.. newList]; 177 | } 178 | } 179 | 180 | 181 | 182 | //public interface ISortableList 183 | //{ 184 | // ISortableList OrderBy(Expression> expression); 185 | //} -------------------------------------------------------------------------------- /src/ReactorData/ListExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace ReactorData; 8 | 9 | static class ListExtensions 10 | { 11 | public static void RemoveFirst(this IList values, Func valueCheckFunc) 12 | { 13 | for (int i = 0; i < values.Count; i++) 14 | { 15 | if (valueCheckFunc(values[i])) 16 | { 17 | values.RemoveAt(i); 18 | return; 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ReactorData/ModelAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace ReactorData; 4 | 5 | /// 6 | /// Indentify a class as an type that can be manupulated by a 7 | /// 8 | [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] 9 | public class ModelAttribute : Attribute 10 | { 11 | public string? KeyPropertyName { get; } 12 | public string? SharedTypeEntityKey { get; } 13 | 14 | public ModelAttribute(string? keyPropertyName = null, string? sharedTypeEntityKey = null) 15 | { 16 | KeyPropertyName = keyPropertyName; 17 | SharedTypeEntityKey = sharedTypeEntityKey; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ReactorData/ModelContextOptions.cs: -------------------------------------------------------------------------------- 1 | namespace ReactorData; 2 | 3 | /// 4 | /// This class allows to configure some options of the 5 | /// 6 | public class ModelContextOptions 7 | { 8 | ///// 9 | ///// calles this function when it needs to notify the UI of any change occurred to entities 10 | ///// 11 | ///// The call is potentially executed in a background thread. Implementors should handel the case appriopriately and use the UI framework utilities to tunnel the called to the UI thread when required. 12 | //public Action? Dispatcher { get; set; } 13 | 14 | /// 15 | /// This actions allows to confugure any public properties just after is created 16 | /// 17 | /// This callback can also be used to preload some entities in the context 18 | public Action? ConfigureContext { get; set; } 19 | } -------------------------------------------------------------------------------- /src/ReactorData/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "ReactorData": { 4 | "commandName": "Project" 5 | }, 6 | "Profile 1": { 7 | "commandName": "DebugRoslynComponent", 8 | "targetProject": "..\\ReactorData.Tests\\ReactorData.Tests.csproj" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/ReactorData/ReactorData.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 0.0.1 9 | adospace 10 | ReactorData is a data container adapter easy to use, low ceremony for .net UI application. 11 | Adolfo Marinucci 12 | https://github.com/adospace/reactor-data 13 | MIT 14 | https://github.com/adospace/reactor-data 15 | database storage persistance mvu UI 16 | true 17 | false 18 | ReactorData 19 | 20 | true 21 | RS1036,NU1903,NU1902,NU1901 22 | 23 | 24 | 25 | 1701;1702 26 | 27 | 28 | 29 | 1701;1702 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/ReactorData/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.DependencyInjection.Extensions; 3 | using ReactorData.Implementation; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace ReactorData; 11 | 12 | public static class ServiceCollectionExtensions 13 | { 14 | /// 15 | /// Add ReactorData services 16 | /// 17 | /// Service collection to modify 18 | /// Uses this function to modify any options related to the creation 19 | public static void AddReactorData(this IServiceCollection services, Action? configureAction = null) 20 | { 21 | services.TryAddSingleton(sp => 22 | { 23 | var options = new ModelContextOptions(); 24 | configureAction?.Invoke(options); 25 | return new ModelContext(sp, options); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ReactorData/ValidateExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ReactorData; 6 | 7 | static class ValidateExtensions 8 | { 9 | public static T EnsureNotNull(this T? value) 10 | => value ?? throw new InvalidOperationException(); 11 | 12 | } 13 | --------------------------------------------------------------------------------