55
66namespace DevExcelerateApi . Data ;
77
8+ /// <summary>
9+ /// SQL Database storage context that creates entity-specific tables for each IStorageEntity type.
10+ /// Each entity type gets its own table with a consistent schema pattern.
11+ /// </summary>
12+ /// <typeparam name="T">The entity type that implements IStorageEntity</typeparam>
813public class SqlDbStorageContext < T > : IStorageContext < T > , IDisposable where T : IStorageEntity
914{
1015 private readonly SqlDbOptions _sqlDbOptions ;
16+ private readonly string _tableName ;
17+ private bool _disposed = false ;
1118
1219 public SqlDbStorageContext ( SqlDbOptions sqlDbOptions )
1320 {
14- _sqlDbOptions = sqlDbOptions ?? throw new ArgumentNullException ( "SqlDb" ) ;
21+ _sqlDbOptions = sqlDbOptions ?? throw new ArgumentNullException ( nameof ( sqlDbOptions ) ) ;
22+ _tableName = SqlTableHelper . GetTableName < T > ( ) ;
23+
24+ if ( ! SqlTableHelper . IsValidTableName ( _tableName ) )
25+ {
26+ throw new ArgumentException ( $ "Invalid table name generated for entity type '{ typeof ( T ) . Name } ': '{ _tableName } '") ;
27+ }
1528 }
1629
17- public Task < IEnumerable < T > > QueryEntitiesAsync ( Func < T , bool > predicate )
30+ /// <summary>
31+ /// Creates a new SQL connection and ensures the table exists.
32+ /// </summary>
33+ /// <returns>A configured SQL connection</returns>
34+ private async Task < SqlConnection > CreateConnectionAsync ( )
1835 {
19- // could use extensions for dapper at https://github.com/tmsmith/Dapper-Extensions using the GetList Operation (with Predicates). To be tested
20- throw new NotImplementedException ( ) ;
36+ var connection = new SqlConnection ( _sqlDbOptions . ConnectionString ) ;
37+ await connection . OpenAsync ( ) ;
38+ await SqlTableHelper . EnsureTableExistsAsync ( connection , _tableName ) ;
39+ return connection ;
40+ }
41+
42+ public async Task < IEnumerable < T > > QueryEntitiesAsync ( Func < T , bool > predicate )
43+ {
44+ using var connection = await CreateConnectionAsync ( ) ;
45+
46+ var sql = SqlTableHelper . GetQueryAllEntitiesSql ( _tableName ) ;
47+ var jsonDataList = await connection . QueryAsync < string > ( sql ) ;
48+
49+ var entities = jsonDataList
50+ . Select ( jsonData => JsonSerializer . Deserialize < T > ( jsonData ) ! )
51+ . Where ( predicate ) ;
52+
53+ return entities ;
2154 }
2255
2356 public async Task < T > ReadAsync ( string entityId , string partitionKey )
2457 {
25- using ( var connection = new SqlConnection ( _sqlDbOptions . ConnectionString ) )
58+ if ( string . IsNullOrWhiteSpace ( entityId ) )
59+ {
60+ throw new ArgumentOutOfRangeException ( nameof ( entityId ) , "Entity Id cannot be null or empty." ) ;
61+ }
62+
63+ using var connection = await CreateConnectionAsync ( ) ;
64+
65+ var sql = SqlTableHelper . GetReadEntitySql ( _tableName ) ;
66+ var jsonData = await connection . QueryFirstOrDefaultAsync < string > ( sql , new { EntityId = entityId , PartitionKey = partitionKey } ) ;
67+
68+ if ( jsonData == null )
2669 {
27- var jsonData = await connection . QueryFirstAsync < string > (
28- "SELECT EventData FROM [WebhookEvents] WHERE EventId = @Id AND EventType = @EventType" ,
29- new { Id = entityId , EventType = typeof ( T ) . Name } ) ;
30- return JsonSerializer . Deserialize < T > ( jsonData ) ! ;
70+ throw new KeyNotFoundException ( $ "Entity with id '{ entityId } ' and partition '{ partitionKey } ' not found in table '{ _tableName } '.") ;
3171 }
72+
73+ return JsonSerializer . Deserialize < T > ( jsonData ) ! ;
3274 }
3375
3476 public async Task CreateAsync ( T entity )
3577 {
36- using ( var connection = new SqlConnection ( _sqlDbOptions . ConnectionString ) )
78+ if ( string . IsNullOrWhiteSpace ( entity . Id ) )
79+ {
80+ throw new ArgumentOutOfRangeException ( nameof ( entity ) , "Entity Id cannot be null or empty." ) ;
81+ }
82+
83+ using var connection = await CreateConnectionAsync ( ) ;
84+
85+ var sql = SqlTableHelper . GetInsertEntitySql ( _tableName ) ;
86+
87+ try
88+ {
89+ await connection . ExecuteAsync ( sql , new
90+ {
91+ EntityId = entity . Id ,
92+ PartitionKey = entity . Partition ,
93+ EntityData = JsonSerializer . Serialize ( entity )
94+ } ) ;
95+ }
96+ catch ( SqlException ex ) when ( ex . Number == 2627 ) // Unique constraint violation
3797 {
38- await connection . ExecuteAsync (
39- "INSERT INTO [WebhookEvents] ([EventId], [EventType],[EventData]) VALUES (@EventId, @EventType, @EventData)" ,
40- new { EventId = entity . Id , EventType = typeof ( T ) . Name , EventData = JsonSerializer . Serialize ( entity ) } ) ;
98+ throw new InvalidOperationException ( $ "Entity with id '{ entity . Id } ' and partition '{ entity . Partition } ' already exists in table '{ _tableName } '.", ex ) ;
4199 }
42100 }
43101
44102 public async Task UpsertAsync ( T entity )
45103 {
46- using ( var connection = new SqlConnection ( _sqlDbOptions . ConnectionString ) )
104+ if ( string . IsNullOrWhiteSpace ( entity . Id ) )
47105 {
48- var exists = await connection . ExecuteScalarAsync < bool > (
49- "SELECT COUNT(1) FROM [WebhookEvents] WHERE EventId = @Id AND EventType = @EventType" ,
50- new { Id = entity . Id , EventType = typeof ( T ) . Name } ) ;
51- if ( exists )
52- {
53- await connection . ExecuteAsync (
54- "UPDATE [WebhookEvents] SET EventData = @EventData WHERE EventId = @Id AND EventType = @EventType" ,
55- new { Id = entity . Id , EventType = typeof ( T ) . Name , EventData = JsonSerializer . Serialize ( entity ) } ) ;
56- }
57- else
106+ throw new ArgumentOutOfRangeException ( nameof ( entity ) , "Entity Id cannot be null or empty." ) ;
107+ }
108+
109+ using var connection = await CreateConnectionAsync ( ) ;
110+
111+ var checkSql = SqlTableHelper . GetEntityExistsSql ( _tableName ) ;
112+ var exists = await connection . ExecuteScalarAsync < bool > ( checkSql , new { EntityId = entity . Id , PartitionKey = entity . Partition } ) ;
113+
114+ if ( exists )
115+ {
116+ var updateSql = SqlTableHelper . GetUpdateEntitySql ( _tableName ) ;
117+
118+ await connection . ExecuteAsync ( updateSql , new
58119 {
59- await CreateAsync ( entity ) ;
60- }
120+ EntityId = entity . Id ,
121+ PartitionKey = entity . Partition ,
122+ EntityData = JsonSerializer . Serialize ( entity )
123+ } ) ;
124+ }
125+ else
126+ {
127+ await CreateAsync ( entity ) ;
61128 }
62129 }
63130
64131 public async Task DeleteAsync ( T entity )
65132 {
66- using ( var connection = new SqlConnection ( _sqlDbOptions . ConnectionString ) )
133+ if ( string . IsNullOrWhiteSpace ( entity . Id ) )
134+ {
135+ throw new ArgumentOutOfRangeException ( nameof ( entity ) , "Entity Id cannot be null or empty." ) ;
136+ }
137+
138+ using var connection = await CreateConnectionAsync ( ) ;
139+
140+ var sql = SqlTableHelper . GetDeleteEntitySql ( _tableName ) ;
141+ var rowsAffected = await connection . ExecuteAsync ( sql , new { EntityId = entity . Id , PartitionKey = entity . Partition } ) ;
142+
143+ if ( rowsAffected == 0 )
67144 {
68- await connection . ExecuteAsync ( "DELETE FROM [WebhookEvents] WHERE EventId = @Id AND EventType = @EventType" ,
69- new { Id = entity . Id , EventType = typeof ( T ) . Name } ) ;
145+ throw new KeyNotFoundException ( $ "Entity with id '{ entity . Id } ' and partition '{ entity . Partition } ' not found in table '{ _tableName } '.") ;
70146 }
71147 }
72148
73149 public void Dispose ( )
74150 {
151+ Dispose ( true ) ;
152+ GC . SuppressFinalize ( this ) ;
153+ }
154+
155+ protected virtual void Dispose ( bool disposing )
156+ {
157+ if ( ! _disposed && disposing )
158+ {
159+ // No specific resources to dispose in this implementation
160+ // Connection disposal is handled in the using statements
161+ }
162+ _disposed = true ;
75163 }
76164}
0 commit comments