Skip to content

Commit 77823c3

Browse files
committed
Centralize SQLite context execution
- add a synchronized SQLite database context executor - centralize connection, lock, transaction, commit, and rollback handling - simplify SQLiteDalBase - update sample DAL implementations - add integration tests for owned and borrowed contexts
1 parent 28b2191 commit 77823c3

13 files changed

Lines changed: 330 additions & 254 deletions

‎README.md‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,47 +8,49 @@
88
[![License](https://img.shields.io/github/license/nu-ext/NuExt.System.Data.SQLite?label=license)](https://github.com/nu-ext/NuExt.System.Data.SQLite/blob/main/LICENSE)
99
[![Downloads](https://img.shields.io/nuget/dt/NuExt.System.Data.SQLite.svg)](https://www.nuget.org/packages/NuExt.System.Data.SQLite)
1010

11-
### Features
11+
## Features
1212

1313
- **Synchronized Database Access**: Coordinates operations through reentrant process-local locks, including shared locks for multiple connections targeting the same database.
1414
- **Transaction Scope Management**: Provides explicit transaction state tracking, prevents nested transactions, and supports coordinated execution through reentrant connection locks.
1515
- **Database Context Management**: Manages connection state, transaction lifetime, commit, rollback, and deterministic resource cleanup.
1616
- **ADO.NET Utilities**: Provides helpers for commands, parameters, readers, data adapters, schema inspection, and common SQLite operations.
1717
- **Database Migrations**: Applies ordered schema and data migrations transactionally, records migration history with optional version metadata, and validates that the applied history matches the configured migration sequence.
1818

19-
### Important Considerations
19+
## Important Considerations
2020

2121
SQLite allows multiple readers but serializes writes according to the configured journal mode and transaction state. Long-running write transactions can delay other operations and increase the likelihood of lock timeouts.
2222

2323
The library's shared lock is intentionally process-local. Applications that access the same database from multiple processes must still rely on SQLite locking, timeouts, and appropriate journal configuration.
2424

25-
### Concurrency and Locking
25+
## Concurrency and Locking
2626

2727
`SQLiteDbConnection` uses a reentrant process-local lock to coordinate database operations. When shared locking is enabled, connections targeting the same database use the same lock. When shared locking is disabled, each wrapper connection uses its own lock.
2828

2929
This coordination helps reduce competing write operations and `"database is locked"` errors originating within the same process. It does not replace SQLite file locking and cannot coordinate access from other processes or external database tools.
3030

3131
Transactions can be used directly when the caller guarantees exclusive connection access. Concurrent or reentrant scenarios should execute the entire transaction lifetime within `AcquireLock` or use `ExecuteInTransaction`.
3232

33-
#### Transaction Example
33+
## Quick Examples
34+
35+
### Transaction Example
3436

3537
```csharp
3638
connection.ExecuteInTransaction(transaction =>
3739
{
3840
connection.ExecuteNonQuery(
39-
"UPDATE movies SET title = @title WHERE id = @id",
41+
"UPDATE Movies SET Title = @Title WHERE Id = @Id",
4042
CommandBehavior.Default,
4143
cancellationToken,
42-
DbType.String.CreateInputParam("@title", title),
43-
DbType.Int64.CreateInputParam("@id", id));
44+
DbType.String.CreateInputParam("@Title", title),
45+
DbType.Int64.CreateInputParam("@Id", id));
4446

4547
transaction.Commit();
4648
}, cancellationToken);
4749
```
4850

4951
The callback is responsible for committing the transaction or explicitly rolling it back when the operation completes normally. If the callback throws while the transaction is still active, the transaction is rolled back automatically without replacing the original exception with a rollback error.
5052

51-
#### Migration Example
53+
### Migration Example
5254

5355
```csharp
5456
internal sealed class CreateMoviesSchema_0001 : SQLiteDbMigration
@@ -75,18 +77,18 @@ SQLiteDbMigration[] migrations =
7577
migrations.Migrate(CreateConnection, "1.0.0", cancellationToken);
7678
```
7779

78-
Migrations are applied in the order in which they are registered. Applied migrations are recorded in the `__nuext_migrations` table together with their application time and optional version information. The complete migration batch is executed within a synchronized transaction and is rolled back if any migration fails.
80+
Migrations are applied in the order in which they are registered. Applied migrations are recorded in the `__nuext_migrations` table together with their application time and optional version information. When a connection factory is used, the complete migration batch is executed within a synchronized transaction and is rolled back if any migration fails. Migrations can also use an existing `SQLiteDbContext`, allowing the caller to include migrations and application operations in the same transaction and retain responsibility for committing or rolling it back.
7981

80-
### Commonly Used Types
82+
## Commonly Used Types
8183

82-
- **`System.Data.SQLite.SQLiteDbConnection`**: Wraps SQLite connections with synchronized process-local access, transaction coordination, and utilities for common database operations.
83-
- **`System.Data.SQLite.SQLiteDbTransaction`**: Wraps SQLite transactions with explicit state tracking, commit and rollback operations, and transaction lifetime diagnostics.
84+
- **`System.Data.SQLite.SQLiteDalBase`**: Base class for SQLite DAL implementations with synchronized provider-managed context execution and access to connection creation for custom scenarios.
85+
- **`System.Data.SQLite.SQLiteDbConnection`**: Wraps SQLite connections with process-local synchronization, transaction coordination, command execution, schema inspection, and common data access utilities.
8486
- **`System.Data.SQLite.SQLiteDbContext`**: Manages connection state and transaction lifetime, with optional validation that the connection lock remains held for the lifetime of the context.
85-
- **`System.Data.SQLite.SQLiteDalBase`**: Base class for SQLite-specific Data Access Layer (DAL) operations.
8687
- **`System.Data.SQLite.SQLiteDbMigration`**: Base class for defining ordered SQLite schema and data migrations.
8788
- **`System.Data.SQLite.SQLiteDbMigrationContext`**: Provides the execution context used by SQLite migrations.
89+
- **`System.Data.SQLite.SQLiteDbTransaction`**: Wraps SQLite transactions with explicit state tracking, commit and rollback operations, and transaction lifetime diagnostics.
8890

89-
### Installation
91+
## Installation
9092

9193
Via [NuGet](https://www.nuget.org/):
9294

@@ -100,7 +102,11 @@ Or via Visual Studio:
100102
2. Search for `NuExt.System.Data.SQLite`.
101103
3. Click "Install".
102104

103-
### Usage Examples
105+
### Compatibility
106+
107+
**.NET Standard 2.0+, .NET 8/9/10 and .NET Framework 4.6.2+**
108+
109+
## Usage Examples
104110

105111
For examples of how to use these classes, see the [samples](samples). These samples provide practical guidance on implementing the library's features in real-world scenarios.
106112

‎samples/MoviesAppSample/DataAccess/MovieDal.cs‎

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,15 @@ internal sealed class MovieDal(Func<SQLiteDbConnection> createConnection) : SQLi
1111

1212
private static readonly string[] s_updateColumns = ["Title", "Description", "DateReleased"];
1313

14-
#region Properties
15-
16-
protected override string[] Columns => s_columns;
17-
18-
protected override string TableName => "Movies";
19-
20-
#endregion
14+
private const string TableName = "Movies";
2115

2216
#region Methods
2317

2418
public ValueTask<bool> DeleteMovieAsync(SQLiteDbContext? context, long id, CancellationToken cancellationToken)
2519
{
2620
Debug.Assert(id > 0);
2721
cancellationToken.ThrowIfCancellationRequested();
28-
return TryExecuteInDbContextAsync(context, async ctx =>
22+
return ExecuteInDbContextAsync(context, async ctx =>
2923
{
3024
using var personDal = new PersonDal(CreateConnection);
3125
bool result = await personDal.DeleteMoviePersonsAsync(ctx, id, cancellationToken);
@@ -35,8 +29,8 @@ public ValueTask<bool> DeleteMovieAsync(SQLiteDbContext? context, long id, Cance
3529
await
3630
#endif
3731

38-
using var command = ctx.Connection.CreateCommandDelete(TableName, "WHERE Id=@Id",
39-
DbType.Int64.CreateInputParam("@Id", id));
32+
using var command = ctx.Connection.CreateCommandDelete(TableName, "WHERE Id=@Id",
33+
DbType.Int64.CreateInputParam("@Id", id));
4034
int affected = ctx.Connection.ExecuteNonQuery(command, cancellationToken: cancellationToken);
4135
return affected == 1;
4236
}, cancellationToken);
@@ -45,7 +39,7 @@ public ValueTask<bool> DeleteMovieAsync(SQLiteDbContext? context, long id, Cance
4539
public ValueTask<bool> DeleteMoviesAsync(SQLiteDbContext? context, IEnumerable<long> ids, CancellationToken cancellationToken)
4640
{
4741
cancellationToken.ThrowIfCancellationRequested();
48-
return TryExecuteInDbContextAsync(context, async ctx =>
42+
return ExecuteInDbContextAsync(context, async ctx =>
4943
{
5044
using var personDal = new PersonDal(CreateConnection);
5145

@@ -54,8 +48,8 @@ public ValueTask<bool> DeleteMoviesAsync(SQLiteDbContext? context, IEnumerable<l
5448
await
5549
#endif
5650

57-
using var command = ctx.Connection.CreateCommandDelete(TableName, "WHERE Id=@Id",
58-
DbType.Int64.CreateInputParam("@Id", 0));
51+
using var command = ctx.Connection.CreateCommandDelete(TableName, "WHERE Id=@Id",
52+
DbType.Int64.CreateInputParam("@Id", 0));
5953
int affected = 0;
6054
foreach (var id in ids)
6155
{
@@ -71,9 +65,9 @@ public ValueTask<bool> DeleteMoviesAsync(SQLiteDbContext? context, IEnumerable<l
7165
{
7266
Debug.Assert(id > 0);
7367
cancellationToken.ThrowIfCancellationRequested();
74-
return TryExecuteInDbContextAsync(context, ctx =>
68+
return ExecuteInDbContextAsync(context, ctx =>
7569
{
76-
using var command = ctx.Connection.CreateCommandSelect(TableName, Columns, "WHERE Id=@Id LIMIT 1",
70+
using var command = ctx.Connection.CreateCommandSelect(TableName, s_columns, "WHERE Id=@Id LIMIT 1",
7771
DbType.Int64.CreateInputParam("@Id", id));
7872
MovieDto? dto = null;
7973
int count = ctx.Connection.ExecuteReader(command, Read, cancellationToken: cancellationToken);
@@ -90,9 +84,9 @@ void Read(SQLiteDataReader reader)
9084
public ValueTask<List<MovieDto>> LoadMoviesAsync(SQLiteDbContext? context, CancellationToken cancellationToken)
9185
{
9286
cancellationToken.ThrowIfCancellationRequested();
93-
return TryExecuteInDbContextAsync(context, ctx =>
87+
return ExecuteInDbContextAsync(context, ctx =>
9488
{
95-
using var command = ctx.Connection.CreateCommandSelect(TableName, Columns, "ORDER BY DateReleased");
89+
using var command = ctx.Connection.CreateCommandSelect(TableName, s_columns, "ORDER BY DateReleased");
9690
var list = new List<MovieDto>();
9791
int count = ctx.Connection.ExecuteReader(command, Read, cancellationToken: cancellationToken);
9892
Debug.Assert(list.Count == count);
@@ -118,7 +112,7 @@ private static MovieDto ReadMovie(DbDataReader reader)
118112
public ValueTask<(bool result, MovieDto dto)> SaveMovieAsync(SQLiteDbContext? context, MovieDto dto, CancellationToken cancellationToken)
119113
{
120114
cancellationToken.ThrowIfCancellationRequested();
121-
return TryExecuteInDbContextAsync(context, ctx =>
115+
return ExecuteInDbContextAsync(context, ctx =>
122116
{
123117
SQLiteCommand command;
124118
if (dto.Id <= 0)

‎samples/MoviesAppSample/DataAccess/PersonDal.cs‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ namespace MoviesAppSample.DataAccess;
66

77
internal sealed class PersonDal(Func<SQLiteDbConnection> createConnection) : SQLiteDalBase(createConnection)
88
{
9-
private static readonly string[] s_columns = ["Id", "Name"];
10-
119
private static readonly string[] s_idColumns = ["Id"];
1210

1311
private static readonly string[] s_updateColumns = ["Name"];
@@ -16,20 +14,15 @@ internal sealed class PersonDal(Func<SQLiteDbConnection> createConnection) : SQL
1614

1715
private static readonly string[] s_relationshipTables = ["MovieCasts", "MovieDirectors", "MovieWriters"];
1816

19-
#region Properties
20-
21-
protected override string[] Columns => s_columns;
22-
protected override string TableName => "Persons";
23-
24-
#endregion
17+
private const string TableName = "Persons";
2518

2619
#region Methods
2720

2821
public ValueTask<bool> DeleteMoviePersonsAsync(SQLiteDbContext context, long movieId, CancellationToken cancellationToken)
2922
{
3023
Debug.Assert(movieId > 0);
3124
cancellationToken.ThrowIfCancellationRequested();
32-
return TryExecuteInDbContextAsync(context, ctx =>
25+
return ExecuteInDbContextAsync(context, ctx =>
3326
{
3427
int affected = 0;
3528
foreach (var tableName in s_relationshipTables)
@@ -63,7 +56,7 @@ public ValueTask<List<PersonDto>> LoadMovieWritersAsync(SQLiteDbContext context,
6356
private ValueTask<List<PersonDto>> LoadMoviePersonsAsync(SQLiteDbContext context, string tableName, long movieId, CancellationToken cancellationToken)
6457
{
6558
cancellationToken.ThrowIfCancellationRequested();
66-
return TryExecuteInDbContextAsync(context, ctx =>
59+
return ExecuteInDbContextAsync(context, ctx =>
6760
{
6861
using var command = ctx.Connection.CreateCommand($"""
6962
SELECT p.Id, p.Name
@@ -109,7 +102,7 @@ private ValueTask<bool> SaveMoviePersonsAsync(SQLiteDbContext context, string ta
109102
Debug.Assert(movieId > 0);
110103

111104
cancellationToken.ThrowIfCancellationRequested();
112-
return TryExecuteInDbContextAsync(context, ctx =>
105+
return ExecuteInDbContextAsync(context, ctx =>
113106
{
114107
var (adapter, table) = ctx.Connection.SelectForUpdate(tableName, s_relationshipColumns, "WHERE MovieId=@MovieId", true, cancellationToken,
115108
DbType.Int64.CreateInputParam("@MovieId", movieId));
@@ -151,7 +144,7 @@ public ValueTask<bool> SavePersonsAsync(SQLiteDbContext context, List<PersonDto>
151144
ArgumentNullException.ThrowIfNull(dtos);
152145

153146
cancellationToken.ThrowIfCancellationRequested();
154-
return TryExecuteInDbContextAsync(context, ctx =>
147+
return ExecuteInDbContextAsync(context, ctx =>
155148
{
156149
using var commandInsert = ctx.Connection.CreateCommandInsertOrIgnore(TableName, s_updateColumns,
157150
"RETURNING Id",

‎samples/MoviesAppSample/Services/MoviesService.cs‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
using MoviesAppSample.Migrations;
2-
using MoviesAppSample.DataAccess;
3-
using MoviesAppSample.Models;
4-
using System.Data;
1+
using System.Data;
52
using System.Data.SQLite;
63
using System.Reflection;
4+
using MoviesAppSample.DataAccess;
5+
using MoviesAppSample.Migrations;
6+
using MoviesAppSample.Models;
77

88
namespace MoviesAppSample.Services;
99

@@ -67,9 +67,9 @@ public async ValueTask<List<Movie>> GetAllMoviesAsync(CancellationToken cancella
6767
dbContext.Commit();
6868
return movies;
6969
}
70-
catch
70+
catch (Exception ex)
7171
{
72-
dbContext.Rollback();
72+
dbContext.TryRollback(ex);
7373
throw;
7474
}
7575
}, cancellationToken);
@@ -157,9 +157,9 @@ public async ValueTask<bool> SaveMovieAsync(Movie movie, CancellationToken cance
157157
dbContext.Commit();
158158
return true;
159159
}
160-
catch
160+
catch (Exception ex)
161161
{
162-
dbContext.Rollback();
162+
dbContext.TryRollback(ex);
163163
throw;
164164
}
165165
}, cancellationToken);

0 commit comments

Comments
 (0)