aboutsummaryrefslogtreecommitdiff
path: root/VNLib.Plugins.Extensions.Data/Storage/LWStorageManager.cs
blob: 63d41af6562aa2eeb841606b5b6c6e3cd10c3645 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
/*
* Copyright (c) 2022 Vaughn Nugent
* 
* Library: VNLib
* Package: VNLib.Plugins.Extensions.Data
* File: LWStorageManager.cs 
*
* LWStorageManager.cs is part of VNLib.Plugins.Extensions.Data which is part of the larger 
* VNLib collection of libraries and utilities.
*
* VNLib.Plugins.Extensions.Data is free software: you can redistribute it and/or modify 
* it under the terms of the GNU General Public License as published
* by the Free Software Foundation, either version 2 of the License,
* or (at your option) any later version.
*
* VNLib.Plugins.Extensions.Data is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License 
* along with VNLib.Plugins.Extensions.Data. If not, see http://www.gnu.org/licenses/.
*/

using System;
using System.IO;
using System.Data;
using System.Threading;
using System.Data.Common;
using System.Threading.Tasks;

using VNLib.Utils;

using VNLib.Plugins.Extensions.Data.SQL;

namespace VNLib.Plugins.Extensions.Data.Storage
{

    /// <summary>
    /// Provides single table database object storage services
    /// </summary>
    public sealed class LWStorageManager : EnumerableTable<LWStorageDescriptor>
    {
        const int DTO_SIZE = 7;
        const int MAX_DATA_SIZE = 8000;

        //Mssql statments
        private const string GET_DESCRIPTOR_STATMENT_ID_MSQQL = "SELECT TOP 1\r\n[Id],\r\n[UserID],\r\n[Data],\r\n[Created],\r\n[LastModified]\r\nFROM @table\r\nWHERE Id=@Id;";
        private const string GET_DESCRIPTOR_STATMENT_UID_MSQL = "SELECT TOP 1\r\n[Id],\r\n[UserID],\r\n[Data],\r\n[Created],\r\n[LastModified]\r\nFROM @table\r\nWHERE UserID=@UserID;";

        private const string GET_DESCRIPTOR_STATMENT_ID = "SELECT\r\n[Id],\r\n[UserID],\r\n[Data],\r\n[Created],\r\n[LastModified]\r\nFROM @table\r\nWHERE Id=@Id\r\nLIMIT 1;";
        private const string GET_DESCRIPTOR_STATMENT_UID = "SELECT\r\n[Id],\r\n[UserID],\r\n[Data],\r\n[Created],\r\n[LastModified]\r\nFROM @table\r\nWHERE UserID=@UserID\r\nLIMIT 1;";

        private const string CREATE_DESCRIPTOR_STATMENT = "INSERT INTO @table\r\n(UserID,Id,Created,LastModified)\r\nVALUES (@UserID,@Id,@Created,@LastModified);";

        private const string UPDATE_DESCRIPTOR_STATMENT = "UPDATE @table\r\nSET [Data]=@Data\r\n,[LastModified]=@LastModified\r\nWHERE Id=@Id;";
        private const string REMOVE_DESCRIPTOR_STATMENT = "DELETE FROM @table\r\nWHERE Id=@Id";
        private const string CLEANUP_STATEMENT = "DELETE FROM @table\r\nWHERE [created]<@timeout;";
        private const string ENUMERATION_STATMENT = "SELECT [Id],\r\n[UserID],\r\n[Data],\r\n[LastModified],\r\n[Created]\r\nFROM @table;";

        private readonly string GetFromUD;
        private readonly string Cleanup;
        private readonly int keySize;

        /// <summary>
        /// The generator function that is invoked when a new <see cref="LWStorageDescriptor"/> is to 
        /// be created without an explicit id
        /// </summary>
        public Func<string> NewDescriptorIdGenerator { get; init; } = static () => Guid.NewGuid().ToString("N");

        /// <summary>
        /// Creates a new <see cref="LWStorageManager"/> with 
        /// </summary>
        /// <param name="factory">A <see cref="DbConnection"/> factory function that will generate and open connections to a database</param>
        /// <param name="tableName">The name of the table to operate on</param>
        /// <param name="pkCharSize">The maximum number of characters of the DescriptorID and </param>
        /// <exception cref="ArgumentException"></exception>
        /// <exception cref="ArgumentNullException"></exception>
        public LWStorageManager(Func<DbConnection> factory, string tableName, int pkCharSize) : base(factory, tableName)
        {
            //Compile statments with specified tableid
            Insert = CREATE_DESCRIPTOR_STATMENT.Replace("@table", tableName);

            //Test connector type to compile MSSQL statments vs Sqlite/Mysql
            using (DbConnection testConnection = GetConnection())
            {
                //Determine if MSSql connections are being used
                bool isMsSql = testConnection.GetType().FullName!.Contains("SqlConnection", StringComparison.OrdinalIgnoreCase);

                if (isMsSql)
                {
                    GetFromUD = GET_DESCRIPTOR_STATMENT_UID_MSQL.Replace("@table", tableName);
                    Select = GET_DESCRIPTOR_STATMENT_ID_MSQQL.Replace("@table", tableName);
                }
                else
                {
                    Select = GET_DESCRIPTOR_STATMENT_ID.Replace("@table", tableName);
                    GetFromUD = GET_DESCRIPTOR_STATMENT_UID.Replace("@table", tableName);
                }
            }

            Update = UPDATE_DESCRIPTOR_STATMENT.Replace("@table", tableName);
            Delete = REMOVE_DESCRIPTOR_STATMENT.Replace("@table", tableName);
            Cleanup = CLEANUP_STATEMENT.Replace("@table", tableName);
            //Set key size
            keySize = pkCharSize;
            //Set default generator
            Enumerate = ENUMERATION_STATMENT.Replace("@table", tableName);
        }

        /// <summary>
        /// Creates a new <see cref="LWStorageDescriptor"/> fror a given user
        /// </summary>
        /// <param name="userId">Id of user</param>
        /// <param name="descriptorIdOverride">An override to specify the new descriptor's id</param>
        /// <param name="cancellation">A token to cancel the operation</param>
        /// <returns>A new <see cref="LWStorageDescriptor"/> if successfully created, null otherwise</returns>
        /// <exception cref="ArgumentNullException"></exception>
        /// <exception cref="LWDescriptorCreationException"></exception>
        public async Task<LWStorageDescriptor> CreateDescriptorAsync(string userId, string? descriptorIdOverride = null, CancellationToken cancellation = default)
        {
            if (string.IsNullOrWhiteSpace(userId))
            {
                throw new ArgumentNullException(nameof(userId));
            }
            //If no override id was specified, generate a new one
            descriptorIdOverride ??= NewDescriptorIdGenerator();
            //Set created time
            DateTimeOffset now = DateTimeOffset.UtcNow;
            //Open a new sql client
            await using DbConnection Database = GetConnection();
            await Database.OpenAsync(cancellation);
            //Setup transaction with repeatable read iso level
            await using DbTransaction transaction = await Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellation);
            //Create command for text command 
            await using DbCommand cmd = Database.CreateTextCommand(Insert, transaction);
            //add parameters
            _ = cmd.AddParameter("@Id", descriptorIdOverride, DbType.String, keySize);
            _ = cmd.AddParameter("@UserID", userId, DbType.String, keySize);
            _ = cmd.AddParameter("@Created", now, DbType.DateTimeOffset, DTO_SIZE);
            _ = cmd.AddParameter("@LastModified", now, DbType.DateTimeOffset, DTO_SIZE);
            //Prepare operation
            await cmd.PrepareAsync(cancellation);
            //Exec and if successful will return > 0, so we can properly return a descriptor
            int result = await cmd.ExecuteNonQueryAsync(cancellation);
            //Commit transaction
            await transaction.CommitAsync(cancellation);
            if (result <= 0)
            {
                throw new LWDescriptorCreationException("Failed to create the new descriptor because the database retuned an invalid update row count");
            }
            //Rent new descriptor
            LWStorageDescriptor desciptor = new(this)
            {
                DescriptorID = descriptorIdOverride,
                UserID = userId,
                Created = now,
                LastModified = now
            };
            //Set data to null
            await desciptor.PrepareAsync(null);
            return desciptor;
        }
        /// <summary>
        /// Attempts to retrieve <see cref="LWStorageDescriptor"/> for a given user-id. The caller is responsible for 
        /// consitancy state of the descriptor
        /// </summary>
        /// <param name="userid">User's id</param>
        /// <param name="cancellation">A token to cancel the operation</param>
        /// <returns>The descriptor belonging to the user, or null if not found or error occurs</returns>
        /// <exception cref="ArgumentNullException"></exception>
        public async Task<LWStorageDescriptor?> GetDescriptorFromUIDAsync(string userid, CancellationToken cancellation = default)
        {
            if (string.IsNullOrWhiteSpace(userid))
            {
                throw new ArgumentNullException(nameof(userid));
            }
            //Open a new sql client
            await using DbConnection Database = GetConnection();
            await Database.OpenAsync(cancellation);
            //Setup transaction with repeatable read iso level
            await using DbTransaction transaction = await Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, cancellation);
            //Create a new command based on the command text
            await using DbCommand cmd = Database.CreateTextCommand(GetFromUD, transaction);
            //Add userid parameter
            _ = cmd.AddParameter("@UserID", userid, DbType.String, keySize);
            //Prepare operation
            await cmd.PrepareAsync(cancellation);
            //Get the reader
            DbDataReader reader = await cmd.ExecuteReaderAsync(CommandBehavior.SingleRow, cancellation);
            try
            {
                //Make sure the record was found
                if (!await reader.ReadAsync(cancellation))
                {
                    return null;
                }
                return await GetItemAsync(reader, CancellationToken.None);
            }
            finally
            {
                //Close the reader
                await reader.CloseAsync();
                //Commit the transaction
                await transaction.CommitAsync(cancellation);
            }
        }
        /// <summary>
        /// Attempts to retrieve the <see cref="LWStorageDescriptor"/> for the given descriptor id. The caller is responsible for 
        /// consitancy state of the descriptor
        /// </summary>
        /// <param name="descriptorId">Unique identifier for the descriptor</param>
        /// <returns>The descriptor belonging to the user, or null if not found or error occurs</returns>
        /// <exception cref="ArgumentNullException"></exception>
        public async Task<LWStorageDescriptor?> GetDescriptorFromIDAsync(string descriptorId, CancellationToken cancellation = default)
        {
            //Allow null/empty entrys to just return null
            if (string.IsNullOrWhiteSpace(descriptorId))
            {
                throw new ArgumentNullException(nameof(descriptorId));
            }
            //Open a new sql client
            await using DbConnection Database = GetConnection();
            await Database.OpenAsync(cancellation);
            //Setup transaction with repeatable read iso level
            await using DbTransaction transaction = await Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, cancellation);
            //We dont have the routine stored 
            await using DbCommand cmd = Database.CreateTextCommand(Select, transaction);
            //Set userid (unicode length)
            _ = cmd.AddParameter("@Id", descriptorId, DbType.String, keySize);
            //Prepare operation
            await cmd.PrepareAsync(cancellation);
            //Get the reader
            DbDataReader reader = await cmd.ExecuteReaderAsync(CommandBehavior.SingleRow, cancellation);
            try
            {
                if (!await reader.ReadAsync(cancellation))
                {
                    return null;
                }
                return await GetItemAsync(reader, CancellationToken.None);
            }
            finally
            {
                //Close the reader
                await reader.CloseAsync();
                //Commit the transaction
                await transaction.CommitAsync(cancellation);
            }
        }
        /// <summary>
        /// Cleanup entries before the specified <see cref="TimeSpan"/>. Entires are store in UTC time
        /// </summary>
        /// <param name="compareTime">Time before <see cref="DateTime.UtcNow"/> to compare against</param>
        /// <param name="cancellation">A token to cancel the operation</param>
        /// <returns>The number of entires cleaned</returns>S
        public Task<ERRNO> CleanupTableAsync(TimeSpan compareTime, CancellationToken cancellation = default) => CleanupTableAsync(DateTime.UtcNow.Subtract(compareTime), cancellation);
        /// <summary>
        /// Cleanup entries before the specified <see cref="DateTime"/>. Entires are store in UTC time
        /// </summary>
        /// <param name="compareTime">UTC time to compare entires against</param>
        /// <param name="cancellation">A token to cancel the operation</param>
        /// <returns>The number of entires cleaned</returns>
        public async Task<ERRNO> CleanupTableAsync(DateTime compareTime, CancellationToken cancellation = default)
        {
            //Open a new sql client
            await using DbConnection Database = GetConnection();
            await Database.OpenAsync(cancellation);
            //Begin a new transaction
            await using DbTransaction transaction = await Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellation);
            //Setup the cleanup command for the current database
            await using DbCommand cmd = Database.CreateTextCommand(Cleanup, transaction);
            //Setup timeout parameter as a datetime
            cmd.AddParameter("@timeout", compareTime, DbType.DateTime);
            await cmd.PrepareAsync(cancellation);
            //Exec and if successful will return > 0, so we can properly return a descriptor
            int result = await cmd.ExecuteNonQueryAsync(cancellation);
            //Commit transaction
            await transaction.CommitAsync(cancellation);
            return result;
        }

        /// <summary>
        /// Updates a descriptor's data field
        /// </summary>
        /// <param name="descriptorObj">Descriptor to update</param>
        /// <param name="data">Data string to store to descriptor record</param>
        /// <exception cref="LWStorageUpdateFailedException"></exception>
        internal async Task UpdateDescriptorAsync(object descriptorObj, Stream data)
        {
            LWStorageDescriptor descriptor = (descriptorObj as LWStorageDescriptor)!;
            int result = 0;
            try
            {
                //Open a new sql client
                await using DbConnection Database = GetConnection();
                await Database.OpenAsync();
                //Setup transaction with repeatable read iso level
                await using DbTransaction transaction = await Database.BeginTransactionAsync(IsolationLevel.Serializable);
                //Create command for stored procedure
                await using DbCommand cmd = Database.CreateTextCommand(Update, transaction);
                //Add parameters
                _ = cmd.AddParameter("@Id", descriptor.DescriptorID, DbType.String, keySize);
                _ = cmd.AddParameter("@Data", data, DbType.Binary, MAX_DATA_SIZE);
                _ = cmd.AddParameter("@LastModified", DateTime.UtcNow, DbType.DateTime2, DTO_SIZE);
                //Prepare operation
                await cmd.PrepareAsync();
                //exec and store result
                result = await cmd.ExecuteNonQueryAsync();
                //Commit 
                await transaction.CommitAsync();
            }
            catch (Exception ex)
            {
                throw new LWStorageUpdateFailedException("", ex);
            }
            //If the result is 0 then the update failed
            if (result <= 0)
            {
                throw new LWStorageUpdateFailedException($"Descriptor {descriptor.DescriptorID} failed to update", null);
            }
        }
        /// <summary>
        /// Function to remove the specified descriptor 
        /// </summary>
        /// <param name="descriptorObj">The active descriptor to remove from the database</param>
        /// <exception cref="LWStorageRemoveFailedException"></exception>
        internal async Task RemoveDescriptorAsync(object descriptorObj)
        {
            LWStorageDescriptor descriptor = (descriptorObj as LWStorageDescriptor)!;
            try
            {
                //Open a new sql client
                await using DbConnection Database = GetConnection();
                await Database.OpenAsync();
                //Setup transaction with repeatable read iso level
                await using DbTransaction transaction = await Database.BeginTransactionAsync(IsolationLevel.Serializable);
                //Create sql command
                await using DbCommand cmd = Database.CreateTextCommand(Delete, transaction);
                //set descriptor id
                _ = cmd.AddParameter("@Id", descriptor.DescriptorID, DbType.String, keySize);
                //Prepare operation
                await cmd.PrepareAsync();
                //Execute (the descriptor my already be removed, as long as the transaction doesnt fail we should be okay)
                _ = await cmd.ExecuteNonQueryAsync();
                //Commit 
                await transaction.CommitAsync();
            }
            catch (Exception ex)
            {
                throw new LWStorageRemoveFailedException("", ex);
            }
        }

        ///<inheritdoc/>
        protected async override Task<LWStorageDescriptor> GetItemAsync(DbDataReader reader, CancellationToken cancellationToken)
        {
            //Open binary stream for the data column
            await using Stream data = reader.GetStream("Data");
            //Create new descriptor
            LWStorageDescriptor desciptor = new(this)
            {
                //Set desctiptor data
                DescriptorID = reader.GetString("Id"),
                UserID = reader.GetString("UserID"),
                Created = reader.GetDateTime("Created"),
                LastModified = reader.GetDateTime("LastModified")
            };
            //Load the descriptor's data
            await desciptor.PrepareAsync(data);
            return desciptor;
        }
        ///<inheritdoc/>
        protected override ValueTask CleanupItemAsync(LWStorageDescriptor item, CancellationToken cancellationToken)
        {
            return item.ReleaseAsync();
        }
    }
}