├── .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 | [](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