diff --git a/Directory.Build.props b/Directory.Build.props index 7cf793c5..de217239 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,6 +14,7 @@ true true + 3.0.1 diff --git a/Makefile b/Makefile index 42cd34dd..622b9cde 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ PACKAGES_OUT=$(abspath PackagesOut) all: nuget -nuget: pclnuget basenuget sqlciphernuget staticnuget +nuget: pclnuget basenuget staticnuget pclnuget: nuget/SQLite-net-std/SQLite-net-std.csproj $(SRC) dotnet pack -c Release -o $(PACKAGES_OUT) $< @@ -13,9 +13,6 @@ pclnuget: nuget/SQLite-net-std/SQLite-net-std.csproj $(SRC) basenuget: nuget/SQLite-net-base/SQLite-net-base.csproj $(SRC) dotnet pack -c Release -o $(PACKAGES_OUT) $< -sqlciphernuget: nuget/SQLite-net-sqlcipher/SQLite-net-sqlcipher.csproj $(SRC) - dotnet pack -c Release -o $(PACKAGES_OUT) $< - staticnuget: nuget/SQLite-net-static/SQLite-net-static.csproj $(SRC) dotnet pack -c Release -o $(PACKAGES_OUT) $< diff --git a/README.md b/README.md index 04e9ad9f..9cb10898 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ Use one of these packages: | Version | Package | Description | | ------- | ------- | ----------- | | [![NuGet Package](https://img.shields.io/nuget/v/sqlite-net-pcl.svg)](https://www.nuget.org/packages/sqlite-net-pcl) | [sqlite-net-pcl](https://www.nuget.org/packages/sqlite-net-pcl) | .NET Standard Library | -| [![NuGet Package with Encryption](https://img.shields.io/nuget/v/sqlite-net-sqlcipher.svg)](https://www.nuget.org/packages/sqlite-net-sqlcipher) | [sqlite-net-sqlcipher](https://www.nuget.org/packages/sqlite-net-sqlcipher) | With Encryption Support | | [![NuGet Package using P/Invoke](https://img.shields.io/nuget/v/sqlite-net-static.svg)](https://www.nuget.org/packages/sqlite-net-static) | [sqlite-net-static](https://www.nuget.org/packages/sqlite-net-static) | Special version that uses P/Invokes to platform-provided sqlite3 | | [![NuGet Package without a SQLitePCLRaw bundle](https://img.shields.io/nuget/v/sqlite-net-base.svg)](https://www.nuget.org/packages/sqlite-net-base) | [sqlite-net-base](https://www.nuget.org/packages/sqlite-net-base) | without a SQLitePCLRaw bundle so you can choose your own provider | @@ -204,9 +203,9 @@ db.Execute ("insert into Stock(Symbol) values (?)", "MSFT"); var stocks = db.Query ("select * from Stock"); ``` -## Using SQLCipher +## Using encryption -You can use an encrypted database by using the [sqlite-net-sqlcipher NuGet package](https://www.nuget.org/packages/sqlite-net-sqlcipher). +If you are using a native SQLite instance which supports encryption: The database key is set in the `SqliteConnectionString` passed to the connection constructor: diff --git a/SQLite.sln b/SQLite.sln index a6329bc1..fa0b05d6 100644 --- a/SQLite.sln +++ b/SQLite.sln @@ -15,8 +15,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiDiff", "tests\ApiDiff\Ap EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SQLite-net-base", "nuget\SQLite-net-base\SQLite-net-base.csproj", "{53D1953C-3641-47D0-BE08-14DB853CC576}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SQLite-net-sqlcipher", "nuget\SQLite-net-sqlcipher\SQLite-net-sqlcipher.csproj", "{59DB03EF-E28D-431E-9058-74AF316800EE}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SQLite.Tests", "tests\SQLite.Tests\SQLite.Tests.csproj", "{80B66A43-B358-4438-BF06-6351B86B121A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SQLite-net-static", "nuget\SQLite-net-static\SQLite-net-static.csproj", "{7CD60DAE-D505-4C2E-80B3-296556CE711E}" @@ -67,18 +65,6 @@ Global {53D1953C-3641-47D0-BE08-14DB853CC576}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {53D1953C-3641-47D0-BE08-14DB853CC576}.Debug|iPhone.ActiveCfg = Debug|Any CPU {53D1953C-3641-47D0-BE08-14DB853CC576}.Debug|iPhone.Build.0 = Debug|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Release|Any CPU.Build.0 = Release|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Release|iPhone.ActiveCfg = Release|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Release|iPhone.Build.0 = Release|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {59DB03EF-E28D-431E-9058-74AF316800EE}.Debug|iPhone.Build.0 = Debug|Any CPU {80B66A43-B358-4438-BF06-6351B86B121A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {80B66A43-B358-4438-BF06-6351B86B121A}.Debug|Any CPU.Build.0 = Debug|Any CPU {80B66A43-B358-4438-BF06-6351B86B121A}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/nuget/SQLite-net-base/SQLite-net-base.csproj b/nuget/SQLite-net-base/SQLite-net-base.csproj index 8fd9ae2e..0a78763d 100644 --- a/nuget/SQLite-net-base/SQLite-net-base.csproj +++ b/nuget/SQLite-net-base/SQLite-net-base.csproj @@ -23,7 +23,7 @@ - + diff --git a/nuget/SQLite-net-sqlcipher/SQLite-net-sqlcipher.csproj b/nuget/SQLite-net-sqlcipher/SQLite-net-sqlcipher.csproj deleted file mode 100644 index 714af08a..00000000 --- a/nuget/SQLite-net-sqlcipher/SQLite-net-sqlcipher.csproj +++ /dev/null @@ -1,38 +0,0 @@ - - - - netstandard2.0;net8.0;net9.0 - SQLite-net - sqlite-net-sqlcipher - SQLite-net SQLCipher .NET Standard Library - - SQLite-net is an open source and light weight library providing easy SQLite database storage for .NET, Mono, and Xamarin applications. - This version uses SQLitePCLRaw to provide platform independent versions of SQLite with the SQLCipher extension. - This enables secure access to the database with password (key) access. - - sqlite-net;sqlite;database;orm;encryption;sqlcipher - true - - - - USE_SQLITEPCL_RAW;RELEASE - bin\Release\$(TargetFramework)\SQLite-net.xml - - - USE_SQLITEPCL_RAW;DEBUG - bin\Debug\$(TargetFramework)\SQLite-net.xml - - - - - - - - SQLite.cs - - - SQLiteAsync.cs - - - - diff --git a/nuget/SQLite-net-std/SQLite-net-std.csproj b/nuget/SQLite-net-std/SQLite-net-std.csproj index 4a1e6d1b..92e5d56c 100644 --- a/nuget/SQLite-net-std/SQLite-net-std.csproj +++ b/nuget/SQLite-net-std/SQLite-net-std.csproj @@ -1,7 +1,7 @@ - netstandard2.0;net8.0;net9.0 + netstandard2.0;net8.0;net9.0;net8.0-ios SQLite-net sqlite-net-pcl SQLite-net Official .NET Standard Library @@ -10,6 +10,7 @@ This version uses SQLitePCLRaw to provide platform independent versions of SQLite. true + $([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0-ios')) @@ -22,8 +23,35 @@ - + + + + + + + + + + + + + + + $(DefineConstants);PROVIDER_sqlite3 + + + + $(DefineConstants);PROVIDER_e_sqlite3 + + SQLite.cs diff --git a/nuget/SQLite-net-std/batteries_v2.cs b/nuget/SQLite-net-std/batteries_v2.cs new file mode 100644 index 00000000..efef85db --- /dev/null +++ b/nuget/SQLite-net-std/batteries_v2.cs @@ -0,0 +1,20 @@ + +using System; + +namespace SQLitePCL +{ + internal static class Batteries_V2 + { + public static void Init() + { +#if PROVIDER_sqlite3 + SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_sqlite3()); +#elif PROVIDER_e_sqlite3 + SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_e_sqlite3()); +#else +#error batteries_v2.cs built with nothing specified +#endif + } + } +} + diff --git a/src/SQLite.cs b/src/SQLite.cs index 72525c56..dec2f7ff 100644 --- a/src/SQLite.cs +++ b/src/SQLite.cs @@ -25,6 +25,7 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; #if NET8_0_OR_GREATER @@ -611,6 +612,9 @@ public SQLiteConnection (SQLiteConnectionString connectionString) throw new InvalidOperationException ("Encryption keys must be strings or byte arrays"); } connectionString.PostKeyAction?.Invoke (this); + + foreach (var handler in CustomTypeRegistry.Handlers) + handler.Initialize (this); } /// @@ -706,6 +710,28 @@ public void EnableLoadExtension (bool enabled) throw SQLiteException.New (r, msg); } } + + /// + /// Load extension. + /// + public void LoadExtension (string filename) + { + SQLite3.Result r = SQLite3.LoadExtension (Handle, filename, null, out var msg); + if (r != SQLite3.Result.OK) { + throw SQLiteException.New (r, msg); + } + } + + /// + /// Load extension. + /// + public void LoadExtension (string filename, string customInitFunctionName) + { + SQLite3.Result r = SQLite3.LoadExtension (Handle, filename, customInitFunctionName, out var msg); + if (r != SQLite3.Result.OK) { + throw SQLiteException.New (r, msg); + } + } #if !USE_SQLITEPCL_RAW static byte[] GetNullTerminatedUtf8 (string s) @@ -893,17 +919,52 @@ public CreateTableResult CreateTable ( var @virtual = fts ? "virtual " : string.Empty; var @using = fts3 ? "using fts3 " : fts4 ? "using fts4 " : string.Empty; - // Build query. - var query = "create " + @virtual + "table if not exists \"" + map.TableName + "\" " + @using + "(\n"; - var decls = map.Columns.Select (p => Orm.SqlDecl (p, StoreDateTimeAsTicks, StoreTimeSpanAsTicks)); - var decl = string.Join (",\n", decls.ToArray ()); - query += decl; - query += ")"; - if (map.WithoutRowId) { - query += " without rowid"; + // Build query - but exclude custom type columns from initial CREATE TABLE + var standardColumns = map.Columns.Where (c => !c.HasCustomTypeHandler).ToArray (); + + if (standardColumns.Length > 0) { + // Build query. + var query = "create " + @virtual + "table if not exists \"" + map.TableName + "\" " + @using + "(\n"; + var decls = standardColumns.Select (p => Orm.SqlDecl (p, StoreDateTimeAsTicks, StoreTimeSpanAsTicks)); + var decl = string.Join (",\n", decls.ToArray ()); + query += decl; + query += ")"; + if (map.WithoutRowId) { + query += " without rowid"; + } + + Execute (query);} + else { + // All columns are custom types, create empty table first + var query = "create " + @virtual + "table if not exists \"" + map.TableName + "\" " + @using + "(temp_col INTEGER)"; + Execute (query); } + // Add custom type columns using their specific handlers + foreach (var customCol in map.Columns.Where (c => c.HasCustomTypeHandler)) { + var handler = customCol.CustomTypeHandler; + var metadata = customCol.GetCustomTypeMetadata (); + var (addColSql, commandType) = handler.GetAddColumnSql (map.TableName, metadata); + + if (!string.IsNullOrEmpty (addColSql)) { + if(commandType == CommandType.ExecuteScalar) + ExecuteScalar(addColSql); + else + Execute (addColSql); + } + else { + // Fallback to standard ALTER TABLE + var addCol = "alter table \"" + map.TableName + "\" add column " + Orm.SqlDecl (customCol, StoreDateTimeAsTicks, StoreTimeSpanAsTicks); + Execute (addCol); + } - Execute (query); + // Call the OnTableCreated callback + handler.OnTableCreated ( this, map.TableName, metadata ); + } + + // Remove temp column if we had to create one + if (standardColumns.Length == 0) { + Execute ($"ALTER TABLE \"{map.TableName}\" DROP COLUMN temp_col"); + } } else { result = CreateTableResult.Migrated; @@ -938,7 +999,28 @@ public CreateTableResult CreateTable ( foreach (var indexName in indexes.Keys) { var index = indexes[indexName]; var columns = index.Columns.OrderBy (i => i.Order).Select (i => i.ColumnName).ToArray (); - CreateIndex (indexName, index.TableName, columns, index.Unique); + + // Check if any column in this index uses a custom type + var customColumn = map.Columns.FirstOrDefault (c => columns.Contains (c.Name) && c.HasCustomTypeHandler); + if (customColumn != null) { + var handler = customColumn.CustomTypeHandler; + var metadata = customColumn.GetCustomTypeMetadata (); + var (createIndexSql, commandType) = handler.GetCreateIndexSql (indexName, index.TableName, customColumn.Name, index.Unique, metadata ); + + if (!string.IsNullOrEmpty (createIndexSql)) { + if(commandType == CommandType.ExecuteScalar) + ExecuteScalar(createIndexSql); + else + Execute (createIndexSql); + } + else { + // Fallback to standard index creation + CreateIndex (indexName, index.TableName, columns, index.Unique); + } + } + else { + CreateIndex (indexName, index.TableName, columns, index.Unique); + } } return result; @@ -1241,8 +1323,30 @@ void MigrateTable (TableMapping map, List existingCols) } foreach (var p in toBeAdded) { - var addCol = "alter table \"" + map.TableName + "\" add column " + Orm.SqlDecl (p, StoreDateTimeAsTicks, StoreTimeSpanAsTicks); - Execute (addCol); + if (p.HasCustomTypeHandler) { + var handler = p.CustomTypeHandler; + var metadata = p.GetCustomTypeMetadata (); + var (addColSql, commandType) = handler.GetAddColumnSql (map.TableName, metadata ); + + if (!string.IsNullOrEmpty (addColSql)) { + if(commandType == CommandType.ExecuteScalar) + ExecuteScalar(addColSql); + else + Execute (addColSql); + } + else { + // Fallback to standard ALTER TABLE + var addCol = "alter table \"" + map.TableName + "\" add column " + Orm.SqlDecl (p, StoreDateTimeAsTicks, StoreTimeSpanAsTicks); + Execute (addCol); + } + + // Call the OnTableCreated callback + handler.OnTableCreated (this, map.TableName, metadata); + } + else { + var addCol = "alter table \"" + map.TableName + "\" add column " + Orm.SqlDecl (p, StoreDateTimeAsTicks, StoreTimeSpanAsTicks); + Execute (addCol); + } } } @@ -2227,7 +2331,7 @@ public int Insert ( // We lock here to protect the prepared statement returned via GetInsertCommand. // A SQLite prepared statement can be bound for only one operation at a time. try { - count = insertCmd.ExecuteNonQuery (vals); + count = insertCmd.ExecuteNonQuery (vals, map.Columns); } catch (SQLiteException ex) { if (SQLite3.ExtendedErrCode (this.Handle) == SQLite3.ExtendedResult.ConstraintNotNull) { @@ -2289,12 +2393,26 @@ PreparedSqlLiteInsertCommand CreateInsertCommand (TableMapping map, string extra cols = map.InsertOrReplaceColumns; } - insertSql = string.Format ("insert {3} into \"{0}\"({1}) values ({2})", map.TableName, - string.Join (",", (from c in cols - select "\"" + c.Name + "\"").ToArray ()), - string.Join (",", (from c in cols - select "?").ToArray ()), extra); + // Build column names + var columnNames = string.Join (",", cols.Select (c => "\"" + c.Name + "\"").ToArray ()); + + // Build value placeholders - use custom expressions for custom types + var valuePlaceholders = new List (); + for (int i = 0; i < cols.Length; i++) { + var col = cols[i]; + if (col.HasCustomTypeHandler) { + var handler = col.CustomTypeHandler; + var metadata = col.GetCustomTypeMetadata (); + var expression = (string)handler.GetInsertExpression ("?", metadata ); + valuePlaceholders.Add (expression); + } + else { + valuePlaceholders.Add ("?"); + } + } + insertSql = string.Format ("insert {3} into \"{0}\"({1}) values ({2})", + map.TableName, columnNames, string.Join (",", valuePlaceholders.ToArray ()), extra); } var insertCommand = new PreparedSqlLiteInsertCommand (this, insertSql); @@ -2576,6 +2694,27 @@ public void Backup (string destinationDatabasePath, string databaseName = "main" throw SQLiteException.New (r, msg); } } + + /// + /// Defines a custom type handler for use with SQLite operations + /// + /// The custom type + /// The handler that defines how to work with this type + public void DefineCustomType (CustomTypeHandler handler) + { + if (CustomTypeRegistry.RegisterHandler (handler)) + handler.Initialize (this); + } + + /// + /// Gets the custom type handler for a given type + /// + /// The custom type + /// The handler, or null if not registered + public CustomTypeHandler GetCustomTypeHandler () + { + return CustomTypeRegistry.GetHandler (); + } ~SQLiteConnection () { @@ -3200,6 +3339,30 @@ private static Type GetMemberType(MemberInfo m) default: throw new InvalidProgramException($"{nameof(TableMapping)} supports properties or fields only."); } } + + /// + /// Gets whether this column uses a custom type handler + /// + public bool HasCustomTypeHandler => CustomTypeRegistry.HasHandler (ColumnType); + + /// + /// Gets the custom type handler for this column, if any + /// + public ICustomTypeHandler CustomTypeHandler => CustomTypeRegistry.TryGetHandler (ColumnType, out var handler) ? handler : null; + + private CustomTypeMetadata _customTypeMetadata; + + /// + /// Gets the custom type metadata for this column, including access to custom attributes + /// + public CustomTypeMetadata GetCustomTypeMetadata () + { + if (_customTypeMetadata == null) { + _customTypeMetadata = new CustomTypeMetadata (this); + } + return _customTypeMetadata; + } + } internal enum MapMethod @@ -3339,6 +3502,11 @@ public static string SqlType (TableMapping.Column p, bool storeDateTimeAsTicks, else if (clrType == typeof (Guid)) { return "varchar(36)"; } + else if(CustomTypeRegistry.TryGetHandler(clrType, out var handler)) + { + var metadata = p.GetCustomTypeMetadata(); + return handler.GetSqlType(metadata); + } else { throw new NotSupportedException ("Don't know about " + clrType); } @@ -3575,7 +3743,7 @@ public IEnumerable ExecuteDeferredQuery (TableMapping map) for (int i = 0; i < cols.Length; i++) { var name = SQLite3.ColumnName16 (stmt, i); cols[i] = map.FindColumn (name); - if (cols[i] != null) + if (cols[i] != null && !cols[i].HasCustomTypeHandler) if (getSetter != null) { fastColumnSetters[i] = (Action)getSetter.Invoke(null, new object[]{ _conn, cols[i]}); } @@ -3596,7 +3764,8 @@ public IEnumerable ExecuteDeferredQuery (TableMapping map) } else { var colType = SQLite3.ColumnType (stmt, i); - var val = ReadCol (stmt, i, colType, cols[i].ColumnType); + var metadata = cols[i].HasCustomTypeHandler ? cols[i].GetCustomTypeMetadata () : null; + var val = ReadCol (stmt, i, colType, cols[i].ColumnType, metadata); cols[i].SetValue (obj, val); } } @@ -3721,7 +3890,7 @@ void BindAll (Sqlite3Statement stmt) static IntPtr NegativePointer = new IntPtr (-1); - internal static void BindParameter (Sqlite3Statement stmt, int index, object value, bool storeDateTimeAsTicks, string dateTimeStringFormat, bool storeTimeSpanAsTicks) + internal static void BindParameter (Sqlite3Statement stmt, int index, object value, bool storeDateTimeAsTicks, string dateTimeStringFormat, bool storeTimeSpanAsTicks, CustomTypeMetadata metadata = null) { if (value == null) { SQLite3.BindNull (stmt, index); @@ -3779,6 +3948,13 @@ internal static void BindParameter (Sqlite3Statement stmt, int index, object val else if (value is UriBuilder) { SQLite3.BindText (stmt, index, ((UriBuilder)value).ToString (), -1, NegativePointer); } + else if(CustomTypeRegistry.TryGetHandler (value.GetType(), out var handler)) { + var bindableValue =handler.ConvertToBindableValue (value, metadata); + + // Recursively bind the converted value + BindParameter (stmt, index, bindableValue, storeDateTimeAsTicks, dateTimeStringFormat, storeTimeSpanAsTicks, null); + return; + } else { // Now we could possibly get an enum, retrieve cached info var valueType = value.GetType (); @@ -3806,7 +3982,7 @@ class Binding public int Index { get; set; } } - object ReadCol (Sqlite3Statement stmt, int index, SQLite3.ColType type, Type clrType) + object ReadCol (Sqlite3Statement stmt, int index, SQLite3.ColType type, Type clrType, CustomTypeMetadata metadata = null) { if (type == SQLite3.ColType.Null) { return null; @@ -3913,6 +4089,33 @@ object ReadCol (Sqlite3Statement stmt, int index, SQLite3.ColType type, Type clr var text = SQLite3.ColumnString (stmt, index); return new UriBuilder (text); } + else if (CustomTypeRegistry.TryGetHandler (clrType, out var handler)) + { + // Get the raw value from SQLite + object rawValue = null; + switch (type) { + case SQLite3.ColType.Text: + rawValue = SQLite3.ColumnString (stmt, index); + break; + case SQLite3.ColType.Blob: + rawValue = SQLite3.ColumnByteArray (stmt, index); + break; + case SQLite3.ColType.Integer: + rawValue = SQLite3.ColumnInt64 (stmt, index); + break; + case SQLite3.ColType.Float: + rawValue = SQLite3.ColumnDouble (stmt, index); + break; + } + + // Use provided metadata or create temporary one + if (metadata == null) { + throw SQLiteException.New(SQLite3.Result.Error, + $"No metadata was provided for the custom type {clrType.FullName}"); + } + + return handler.ConvertFromDatabaseValueInternal(rawValue, metadata); + } else { throw new NotSupportedException ("Don't know how to read " + clrType); } @@ -4183,7 +4386,7 @@ public PreparedSqlLiteInsertCommand (SQLiteConnection conn, string commandText) CommandText = commandText; } - public int ExecuteNonQuery (object[] source) + public int ExecuteNonQuery (object[] source, TableMapping.Column[] columns) { if (Initialized && Statement == NullStatement) { throw new ObjectDisposedException (nameof (PreparedSqlLiteInsertCommand)); @@ -4203,7 +4406,8 @@ public int ExecuteNonQuery (object[] source) //bind the values. if (source != null) { for (int i = 0; i < source.Length; i++) { - SQLiteCommand.BindParameter (Statement, i + 1, source[i], Connection.StoreDateTimeAsTicks, Connection.DateTimeStringFormat, Connection.StoreTimeSpanAsTicks); + var metadata = i < columns.Length && columns[i].HasCustomTypeHandler ? columns[i].GetCustomTypeMetadata () : null; + SQLiteCommand.BindParameter (Statement, i + 1, source[i], Connection.StoreDateTimeAsTicks, Connection.DateTimeStringFormat, Connection.StoreTimeSpanAsTicks, metadata); } } r = SQLite3.Step (Statement); @@ -4534,7 +4738,25 @@ private SQLiteCommand GenerateCommand (string selectionList) throw new NotSupportedException ("Joins are not supported."); } else { + // Handle custom selection list for custom types + if (selectionList == "*") { + var selectItems = new List (); + foreach (var col in Table.Columns) { + if (col.HasCustomTypeHandler) { + var handler = col.CustomTypeHandler; + var metadata = col.GetCustomTypeMetadata (); + var expression = handler.GetSelectExpression ( col.Name, metadata ); + selectItems.Add ($"{expression} as \"{col.Name}\""); + } + else { + selectItems.Add ($"\"{col.Name}\""); + } + } + selectionList = string.Join (", ", selectItems); + } + var cmdText = "select " + selectionList + " from \"" + Table.TableName + "\""; + var args = new List (); if (_where != null) { var w = CompileExpr (_where, args); @@ -4715,11 +4937,18 @@ private CompileResult CompileExpr (Expression expr, List queryArgs) } if (paramExpr != null) { + var column = Table.FindColumnWithPropertyName (mem.Member.Name); + if (column != null && column.HasCustomTypeHandler) { + // Validate LINQ usage for custom types + var handler = column.CustomTypeHandler; + var metadata = column.GetCustomTypeMetadata (); + handler.ValidateLinqUsage(expr, metadata); + } // // This is a column of our table, output just the column name // Need to translate it if that column name is mapped // - var columnName = Table.FindColumnWithPropertyName (mem.Member.Name).Name; + var columnName = column.Name; return new CompileResult { CommandText = "\"" + columnName + "\"" }; } else { @@ -5032,13 +5261,13 @@ public enum ConfigOption : int const string LibraryPath = "sqlite3"; #if !USE_CSHARP_SQLITE && !USE_WP8_NATIVE_SQLITE && !USE_SQLITEPCL_RAW - [DllImport(LibraryPath, EntryPoint = "sqlite3_threadsafe", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_threadsafe", CallingConvention = CallingConvention.Cdecl)] public static extern int Threadsafe (); - [DllImport(LibraryPath, EntryPoint = "sqlite3_open", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_open", CallingConvention = CallingConvention.Cdecl)] public static extern Result Open ([MarshalAs(UnmanagedType.LPStr)] string filename, out IntPtr db); - [DllImport(LibraryPath, EntryPoint = "sqlite3_open_v2", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_open_v2", CallingConvention = CallingConvention.Cdecl)] public static extern Result Open ([MarshalAs(UnmanagedType.LPStr)] string filename, out IntPtr db, int flags, [MarshalAs (UnmanagedType.LPStr)] string zvfs); [DllImport(LibraryPath, EntryPoint = "sqlite3_open_v2", CallingConvention = CallingConvention.Cdecl)] @@ -5047,34 +5276,39 @@ public enum ConfigOption : int [DllImport(LibraryPath, EntryPoint = "sqlite3_open16", CallingConvention = CallingConvention.Cdecl)] public static extern Result Open16([MarshalAs(UnmanagedType.LPWStr)] string filename, out IntPtr db); - [DllImport(LibraryPath, EntryPoint = "sqlite3_enable_load_extension", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_enable_load_extension", CallingConvention = +CallingConvention.Cdecl)] public static extern Result EnableLoadExtension (IntPtr db, int onoff); - [DllImport(LibraryPath, EntryPoint = "sqlite3_close", CallingConvention=CallingConvention.Cdecl)] + [DllImport (LibraryPath, EntryPoint = "sqlite3_load_extension", CallingConvention = CallingConvention.Cdecl)] + public static extern Result LoadExtension (IntPtr db, [MarshalAs (UnmanagedType.LPStr)] string filename, [MarshalAs (UnmanagedType.LPStr)] string initFunctionName, [MarshalAs(UnmanagedType.LPStr)] out string errorMsg); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_close", CallingConvention = CallingConvention.Cdecl)] public static extern Result Close (IntPtr db); [DllImport(LibraryPath, EntryPoint = "sqlite3_close_v2", CallingConvention = CallingConvention.Cdecl)] public static extern Result Close2(IntPtr db); - [DllImport(LibraryPath, EntryPoint = "sqlite3_initialize", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_initialize", CallingConvention = CallingConvention.Cdecl)] public static extern Result Initialize(); - [DllImport(LibraryPath, EntryPoint = "sqlite3_shutdown", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_shutdown", CallingConvention = CallingConvention.Cdecl)] public static extern Result Shutdown(); - [DllImport(LibraryPath, EntryPoint = "sqlite3_config", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_config", CallingConvention = CallingConvention.Cdecl)] public static extern Result Config (ConfigOption option); - [DllImport(LibraryPath, EntryPoint = "sqlite3_win32_set_directory", CallingConvention=CallingConvention.Cdecl, CharSet=CharSet.Unicode)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_win32_set_directory", CallingConvention = +CallingConvention.Cdecl, CharSet = CharSet.Unicode)] public static extern int SetDirectory (uint directoryType, string directoryPath); - [DllImport(LibraryPath, EntryPoint = "sqlite3_busy_timeout", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_busy_timeout", CallingConvention = CallingConvention.Cdecl)] public static extern Result BusyTimeout (IntPtr db, int milliseconds); - [DllImport(LibraryPath, EntryPoint = "sqlite3_changes", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_changes", CallingConvention = CallingConvention.Cdecl)] public static extern int Changes (IntPtr db); - [DllImport(LibraryPath, EntryPoint = "sqlite3_prepare_v2", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_prepare_v2", CallingConvention = CallingConvention.Cdecl)] public static extern Result Prepare2 (IntPtr db, [MarshalAs(UnmanagedType.LPStr)] string sql, int numBytes, out IntPtr stmt, IntPtr pzTail); #if NETFX_CORE @@ -5097,19 +5331,19 @@ public static IntPtr Prepare2 (IntPtr db, string query) return stmt; } - [DllImport(LibraryPath, EntryPoint = "sqlite3_step", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_step", CallingConvention = CallingConvention.Cdecl)] public static extern Result Step (IntPtr stmt); - [DllImport(LibraryPath, EntryPoint = "sqlite3_reset", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_reset", CallingConvention = CallingConvention.Cdecl)] public static extern Result Reset (IntPtr stmt); - [DllImport(LibraryPath, EntryPoint = "sqlite3_finalize", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_finalize", CallingConvention = CallingConvention.Cdecl)] public static extern Result Finalize (IntPtr stmt); - [DllImport(LibraryPath, EntryPoint = "sqlite3_last_insert_rowid", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_last_insert_rowid", CallingConvention = CallingConvention.Cdecl)] public static extern long LastInsertRowid (IntPtr db); - [DllImport(LibraryPath, EntryPoint = "sqlite3_errmsg16", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_errmsg16", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr Errmsg (IntPtr db); public static string GetErrmsg (IntPtr db) @@ -5117,62 +5351,64 @@ public static string GetErrmsg (IntPtr db) return Marshal.PtrToStringUni (Errmsg (db)); } - [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_parameter_index", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_parameter_index", CallingConvention = +CallingConvention.Cdecl)] public static extern int BindParameterIndex (IntPtr stmt, [MarshalAs(UnmanagedType.LPStr)] string name); - [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_null", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_null", CallingConvention = CallingConvention.Cdecl)] public static extern int BindNull (IntPtr stmt, int index); - [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_int", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_int", CallingConvention = CallingConvention.Cdecl)] public static extern int BindInt (IntPtr stmt, int index, int val); - [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_int64", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_int64", CallingConvention = CallingConvention.Cdecl)] public static extern int BindInt64 (IntPtr stmt, int index, long val); - [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_double", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_double", CallingConvention = CallingConvention.Cdecl)] public static extern int BindDouble (IntPtr stmt, int index, double val); - [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_text16", CallingConvention=CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_text16", CallingConvention = CallingConvention.Cdecl, CharSet + = CharSet.Unicode)] public static extern int BindText (IntPtr stmt, int index, [MarshalAs(UnmanagedType.LPWStr)] string val, int n, IntPtr free); - [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_blob", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_blob", CallingConvention = CallingConvention.Cdecl)] public static extern int BindBlob (IntPtr stmt, int index, byte[] val, int n, IntPtr free); - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_count", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_count", CallingConvention = CallingConvention.Cdecl)] public static extern int ColumnCount (IntPtr stmt); - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_name", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_name", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr ColumnName (IntPtr stmt, int index); - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_name16", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_name16", CallingConvention = CallingConvention.Cdecl)] static extern IntPtr ColumnName16Internal (IntPtr stmt, int index); public static string ColumnName16(IntPtr stmt, int index) { return Marshal.PtrToStringUni(ColumnName16Internal(stmt, index)); } - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_type", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_type", CallingConvention = CallingConvention.Cdecl)] public static extern ColType ColumnType (IntPtr stmt, int index); - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_int", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_int", CallingConvention = CallingConvention.Cdecl)] public static extern int ColumnInt (IntPtr stmt, int index); - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_int64", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_int64", CallingConvention = CallingConvention.Cdecl)] public static extern long ColumnInt64 (IntPtr stmt, int index); - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_double", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_double", CallingConvention = CallingConvention.Cdecl)] public static extern double ColumnDouble (IntPtr stmt, int index); - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_text", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_text", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr ColumnText (IntPtr stmt, int index); - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_text16", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_text16", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr ColumnText16 (IntPtr stmt, int index); - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_blob", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_blob", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr ColumnBlob (IntPtr stmt, int index); - [DllImport(LibraryPath, EntryPoint = "sqlite3_column_bytes", CallingConvention=CallingConvention.Cdecl)] + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_bytes", CallingConvention = CallingConvention.Cdecl)] public static extern int ColumnBytes (IntPtr stmt, int index); public static string ColumnString (IntPtr stmt, int index) @@ -5243,7 +5479,7 @@ public static int Changes (Sqlite3DatabaseHandle db) public static Sqlite3Statement Prepare2 (Sqlite3DatabaseHandle db, string query) { - Sqlite3Statement stmt = default (Sqlite3Statement); + Sqlite3Statement stmt = default(Sqlite3Statement); #if USE_WP8_NATIVE_SQLITE || USE_SQLITEPCL_RAW var r = Sqlite3.sqlite3_prepare_v2 (db, query, out stmt); #else @@ -5253,6 +5489,7 @@ public static Sqlite3Statement Prepare2 (Sqlite3DatabaseHandle db, string query) if (r != 0) { throw SQLiteException.New ((Result)r, GetErrmsg (db)); } + return stmt; } @@ -5394,6 +5631,7 @@ public static byte[] ColumnByteArray (Sqlite3Statement stmt, int index) if (length > 0) { return ColumnBlob (stmt, index); } + return new byte[0]; } @@ -5402,6 +5640,17 @@ public static Result EnableLoadExtension (Sqlite3DatabaseHandle db, int onoff) return (Result)Sqlite3.sqlite3_enable_load_extension (db, onoff); } + public static Result LoadExtension (Sqlite3DatabaseHandle db, string fileName, string initFunctionName, + out string errorMessage) + { + var result = (Result)Sqlite3.sqlite3_load_extension (db, SQLitePCL.utf8z.FromString (fileName), + SQLitePCL.utf8z.FromString (initFunctionName), out SQLitePCL.utf8z pzErrMsg); + + errorMessage = pzErrMsg.utf8_to_string (); + + return result; + } + public static int LibVersionNumber () { return Sqlite3.sqlite3_libversion_number (); @@ -5417,7 +5666,8 @@ public static ExtendedResult ExtendedErrCode (Sqlite3DatabaseHandle db) return (ExtendedResult)Sqlite3.sqlite3_extended_errcode (db); } - public static Sqlite3BackupHandle BackupInit (Sqlite3DatabaseHandle destDb, string destName, Sqlite3DatabaseHandle sourceDb, string sourceName) + public static Sqlite3BackupHandle BackupInit (Sqlite3DatabaseHandle destDb, string destName, + Sqlite3DatabaseHandle sourceDb, string sourceName) { return Sqlite3.sqlite3_backup_init (destDb, destName, sourceDb, sourceName); } @@ -5440,6 +5690,418 @@ public enum ColType : int Text = 3, Blob = 4, Null = 5 + } + } + + /// + /// Base class for custom attributes that provide metadata for custom type handlers + /// + [AttributeUsage (AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = true)] + public abstract class CustomTypeAttribute : Attribute + { + /// + /// Gets the name/key for this attribute + /// + public abstract string AttributeName { get; } + } + + /// + /// Provides access to custom attributes and their metadata + /// + public class CustomTypeMetadata + { + private readonly Dictionary _attributes; + private readonly TableMapping.Column _column; + + internal CustomTypeMetadata (TableMapping.Column column) + { + _column = column; + _attributes = new Dictionary (); + + // Extract custom attributes from the property/field + if (column.PropertyInfo != null) { + var customAttrs = column.PropertyInfo.GetCustomAttributes () + .OfType () + .ToArray (); + + foreach (var attr in customAttrs) { + _attributes[attr.AttributeName] = attr; + } + } + } + + /// + /// Gets a custom attribute by its name/key + /// + /// The type of attribute to retrieve + /// The name/key of the attribute + /// The attribute instance, or null if not found + public TAttribute GetAttribute (string attributeName) where TAttribute : CustomTypeAttribute + { + return _attributes.TryGetValue (attributeName, out var attr) ? attr as TAttribute : null; + } + + /// + /// Gets a custom attribute by its type + /// + /// The type of attribute to retrieve + /// The attribute instance, or null if not found + public TAttribute GetAttribute () where TAttribute : CustomTypeAttribute + { + return _attributes.Values.OfType ().FirstOrDefault (); + } + + /// + /// Checks if an attribute exists by name/key + /// + /// The name/key of the attribute + /// True if the attribute exists + public bool HasAttribute (string attributeName) + { + return _attributes.ContainsKey (attributeName); + } + + /// + /// Checks if an attribute exists by type + /// + /// The type of attribute to check for + /// True if the attribute exists + public bool HasAttribute () where TAttribute : CustomTypeAttribute + { + return _attributes.Values.OfType ().Any (); + } + + /// + /// Gets all custom attributes + /// + /// Collection of all custom attributes + public IEnumerable GetAllAttributes () + { + return _attributes.Values; + } + + /// + /// Gets all custom attributes of a specific type + /// + /// The type of attributes to retrieve + /// Collection of attributes of the specified type + public IEnumerable GetAllAttributes () where TAttribute : CustomTypeAttribute + { + return _attributes.Values.OfType (); + } + + /// + /// Gets the underlying column information + /// + public TableMapping.Column Column => _column; + } + + /// + /// Defines how to handle a custom type in SQLite operations + /// + /// The custom type to handle + public abstract class CustomTypeHandler : ICustomTypeHandler + { + public abstract void Initialize (SQLiteConnection connection); + + /// + /// Gets the SQL type declaration for this custom type + /// + /// Metadata including column information and custom attributes + /// SQL type string (e.g., "TEXT", "BLOB", etc.) + public abstract string GetSqlType (CustomTypeMetadata metadata); + + /// + /// Converts the custom type value to a value that can be bound to SQLite parameters + /// + /// The custom type value + /// Metadata including column information and custom attributes + /// Value that SQLite can bind (string, byte[], int, etc.) + public object ConvertToBindableValue (object value, CustomTypeMetadata metadata) + { + return ConvertToBindableValue ((T)value, metadata); + } + + /// + /// Converts the custom type value to a value that can be bound to SQLite parameters + /// + /// The custom type value + /// Metadata including column information and custom attributes + /// Value that SQLite can bind (string, byte[], int, etc.) + public abstract object ConvertToBindableValue (T value, CustomTypeMetadata metadata); + + /// + /// Gets the SQL expression to use when inserting/updating values of this type + /// + /// The parameter placeholder (usually "?") + /// Metadata including column information and custom attributes + /// SQL expression (e.g., "GeomFromText(?, 4326)") + public virtual string GetInsertExpression (string parameterPlaceholder, CustomTypeMetadata metadata) + { + return parameterPlaceholder; + } + + /// + /// Gets the SQL expression to use when selecting values of this type + /// + /// The column name + /// Metadata including column information and custom attributes + /// SQL expression (e.g., "AsText(geometry)") + public virtual string GetSelectExpression (string columnName, CustomTypeMetadata metadata) + { + return $"\"{columnName}\""; + } + + + /// + /// Converts a value read from SQLite back to the custom type + /// + /// Value from SQLite + /// Metadata including column information and custom attributes + /// Instance of the custom type + public object ConvertFromDatabaseValueInternal (object value, CustomTypeMetadata metadata) + { + return ConvertFromDatabaseValue (value, metadata); + } + + /// + /// Converts a value read from SQLite back to the custom type + /// + /// Value from SQLite + /// Metadata including column information and custom attributes + /// Instance of the custom type + public abstract T ConvertFromDatabaseValue (object value, CustomTypeMetadata metadata); + + /// + /// Gets the SQL command to add a column of this type to an existing table + /// + /// Name of the table + /// Metadata including column information and custom attributes + /// SQL command to add the column, or null to use default ALTER TABLE + public virtual (string sql, CommandType commandType) GetAddColumnSql (string tableName, CustomTypeMetadata metadata) + { + return (null, CommandType.None); // Use default ALTER TABLE ADD COLUMN + } + + /// + /// Gets the SQL command to create an index for this column type + /// + /// Name of the index + /// Name of the table + /// Name of the column + /// Whether the index should be unique + /// Metadata including column information and custom attributes + /// SQL command to create the index, or null to use default CREATE INDEX + public virtual (string sql, CommandType commandType) GetCreateIndexSql (string indexName, string tableName, string columnName, bool isUnique, CustomTypeMetadata metadata) + { + return (null, CommandType.None); // Use default CREATE INDEX + } + + /// + /// Called after a table is created to perform any additional setup + /// + /// The SQLite connection + /// Name of the table + /// Metadata including column information and custom attributes + public virtual void OnTableCreated (SQLiteConnection connection, string tableName, CustomTypeMetadata metadata) + { + // Override to perform custom setup after table creation + } + + /// + /// Validates that this type should not be used in LINQ expressions + /// + /// The LINQ expression + /// Metadata including column information and custom attributes + public virtual void ValidateLinqUsage (Expression expression, CustomTypeMetadata metadata) + { + throw new NotSupportedException ($"Custom type {typeof (T).Name} cannot be used in LINQ queries. " + + $"Column '{metadata.Column.Name}' of type {typeof (T).Name} is not supported in WHERE clauses or other LINQ operations."); + } + } + + public enum CommandType + { + None = 0, + Execute = 1, + ExecuteScalar = 2 + } + + public interface ICustomTypeHandler + { + /// + /// Called when the custom type handler was added to a connection + /// or a new connection is created with the handler being already registered + /// This allows a handler to e.g. load a sqlite extension + /// + void Initialize (SQLiteConnection connection); + + /// + /// Gets the SQL type declaration for this custom type + /// + /// Metadata including column information and custom attributes + /// SQL type string (e.g., "TEXT", "BLOB", etc.) + string GetSqlType (CustomTypeMetadata metadata); + + /// + /// Converts the custom type value to a value that can be bound to SQLite parameters + /// + /// The custom type value + /// Metadata including column information and custom attributes + /// Value that SQLite can bind (string, byte[], int, etc.) + object ConvertToBindableValue (object value, CustomTypeMetadata metadata); + + /// + /// Gets the SQL expression to use when inserting/updating values of this type + /// + /// The parameter placeholder (usually "?") + /// Metadata including column information and custom attributes + /// SQL expression (e.g., "GeomFromText(?, 4326)") + string GetInsertExpression (string parameterPlaceholder, CustomTypeMetadata metadata); + + /// + /// Gets the SQL expression to use when selecting values of this type + /// + /// The column name + /// Metadata including column information and custom attributes + /// SQL expression (e.g., "AsText(geometry)") + string GetSelectExpression (string columnName, CustomTypeMetadata metadata); + + /// + /// Converts a value read from SQLite back to the custom type + /// + /// Value from SQLite + /// Metadata including column information and custom attributes + /// Instance of the custom type + object ConvertFromDatabaseValueInternal (object value, CustomTypeMetadata metadata); + + /// + /// Gets the SQL command to add a column of this type to an existing table + /// + /// Name of the table + /// Metadata including column information and custom attributes + /// SQL command to add the column, or null to use default ALTER TABLE + (string sql, CommandType commandType) GetAddColumnSql (string tableName, CustomTypeMetadata metadata); + + /// + /// Gets the SQL command to create an index for this column type + /// + /// Name of the index + /// Name of the table + /// Name of the column + /// Whether the index should be unique + /// Metadata including column information and custom attributes + /// SQL command to create the index, or null to use default CREATE INDEX + (string sql, CommandType commandType) GetCreateIndexSql (string indexName, string tableName, string columnName, bool isUnique, CustomTypeMetadata metadata); + + /// + /// Called after a table is created to perform any additional setup + /// + /// The SQLite connection + /// Name of the table + /// Metadata including column information and custom attributes + void OnTableCreated (SQLiteConnection connection, string tableName, CustomTypeMetadata metadata); + + /// + /// Validates that this type should not be used in LINQ expressions + /// + /// The LINQ expression + /// Metadata including column information and custom attributes + void ValidateLinqUsage (Expression expression, CustomTypeMetadata metadata); + } + + /// + /// Registry for custom type handlers + /// + public static class CustomTypeRegistry + { + private static readonly ConcurrentDictionary _handlers = new ConcurrentDictionary (); + + public static IReadOnlyCollection Handlers => _handlers.Values.ToArray (); + + /// + /// Registers a custom type handler + /// + /// The custom type + /// The handler instance + public static bool RegisterHandler (CustomTypeHandler handler) + { + return _handlers.TryAdd(typeof (T).FullName, handler); + } + + /// + /// Gets the handler for a specific type + /// + /// The custom type + /// The handler, or null if not registered + public static CustomTypeHandler GetHandler () + { + return _handlers.TryGetValue (typeof (T).FullName, out var handler) ? (CustomTypeHandler)handler : null; + } + + /// + /// Gets the handler for a specific type + /// + /// The custom type + /// + /// The handler, or null if not registered + public static bool TryGetHandler (Type type, out ICustomTypeHandler handler) + { + var currentType = type; + var derivedTypes = new List (); + + while (currentType != typeof(object)) + { + if (_handlers.TryGetValue (currentType.FullName, out handler)) { + + // _should_ we find a suitable handler for a base type, we will add these types to the dictionary + // to speed up future lookups + foreach (var typeCainElt in derivedTypes) { + _handlers.TryAdd(typeCainElt.FullName, handler); + } + + return true; + } + + if (currentType == typeof(object)) + break; + + // remember all types in the hierarchy we have checked + derivedTypes.Add (currentType); + + currentType = currentType.BaseType; + + } + + handler = null; + return false; + } + + /// + /// Checks if a type has a registered handler + /// + /// The type to check + /// True if a handler is registered + public static bool HasHandler (Type type) + { + return _handlers.ContainsKey (type.FullName); + } + + /// + /// Removes a handler registration + /// + /// The custom type + public static void UnregisterHandler () + { + _handlers.TryRemove (typeof (T).FullName, out var __); + } + + /// + /// Resets the type handler cache. + /// + public static void Reset () + { + _handlers.Clear (); } } } diff --git a/tests/SQLite.Tests/CustomTypesTests.cs b/tests/SQLite.Tests/CustomTypesTests.cs new file mode 100644 index 00000000..bc473f9c --- /dev/null +++ b/tests/SQLite.Tests/CustomTypesTests.cs @@ -0,0 +1,540 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +#if NETFX_CORE +using Microsoft.VisualStudio.TestPlatform.UnitTestFramework; +using SetUp = Microsoft.VisualStudio.TestPlatform.UnitTestFramework.TestInitializeAttribute; +using TestFixture = Microsoft.VisualStudio.TestPlatform.UnitTestFramework.TestClassAttribute; +using Test = Microsoft.VisualStudio.TestPlatform.UnitTestFramework.TestMethodAttribute; +#else +using NUnit.Framework; +#endif + + +namespace SQLite.Tests { + [TestFixture] + public class CustomTypesTests { + public class CustomTypeTestObj { + [PrimaryKey] + public Guid Id { get; set; } + public JsonDocument JsonColumn { get; set; } + + public override string ToString() { + return string.Format("[TestObj: Id={0}, Json={1}]", Id, JsonColumn?.ToString ()); + } + } + + public class TestObjQueryResult + { + public JsonDocument JsonColumn { get; set; } + } + + public class JsonDocumentTypeHandler : CustomTypeHandler + { + public int InitializeCalled { get; set; } + public int TableCreatedCalled { get; set; } + + public override void Initialize (SQLiteConnection connection) + { + InitializeCalled++; + } + + public override string GetSqlType (CustomTypeMetadata metadata) + { + return "TEXT"; + } + + public override object ConvertToBindableValue (JsonDocument value, CustomTypeMetadata metadata) + { + if (value is null) + return null; + + using var stream = new MemoryStream(); + Utf8JsonWriter writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + value.WriteTo(writer); + writer.Flush(); + string json = Encoding.UTF8.GetString(stream.ToArray()); + return json; + } + + public override JsonDocument ConvertFromDatabaseValue (object value, CustomTypeMetadata metadata) + { + if (value is string { } s) + return JsonDocument.Parse (s); + + if (value is null) + return null; + + throw new NotSupportedException ( + $"Cannot convert '{value}' ({value.GetType ().FullName}) to JsonDocument"); + } + + public override void OnTableCreated (SQLiteConnection connection, string tableName, CustomTypeMetadata metadata) + { + TableCreatedCalled++; + base.OnTableCreated(connection, tableName, metadata); + } + } + + [SetUp] + public void SetUp () + { + CustomTypeRegistry.Reset (); + } + + [Test] + public void WhenRegistered_ShouldCallInitializeOnCustomTypeHandler () + { + var db = new SQLiteConnection (TestPath.GetTempFileName ()); + var h = new JsonDocumentTypeHandler (); + db.DefineCustomType (h); + + Assert.AreEqual (1, h.InitializeCalled); + } + + [Test] + public void EachNewConnection_ShouldCallInitializeOnCustomTypeHandler () + { + var db = new SQLiteConnection (TestPath.GetTempFileName ()); + var h = new JsonDocumentTypeHandler (); + db.DefineCustomType (h); //1 + + // Act + var db2 = new SQLiteConnection (db.DatabasePath); // 2 + var db3 = new SQLiteConnection (db.DatabasePath); // 3 + + // Assert + Assert.AreEqual (3, h.InitializeCalled); + + db.Close(); + db2.Close(); + db3.Close(); + } + + [Test] + public void ShouldCallInitializeOnCustomTypeHandler_JustOnce () + { + var db = new SQLiteConnection (TestPath.GetTempFileName ()); + var h = new JsonDocumentTypeHandler (); + + // Act + db.DefineCustomType (h); + db.DefineCustomType (h); + + // Assert + Assert.AreEqual (1, h.InitializeCalled); + + db.Close(); + } + + [Test] + public void CanCreateTable_WithColumnForCustomType () + { + // Arrange + var db = new SQLiteConnection (TestPath.GetTempFileName ()); + var h = new JsonDocumentTypeHandler (); + db.DefineCustomType (h); + + // Act + db.CreateTable(); + + // Assert + var jsonColumn = db.TableMappings.SingleOrDefault (t => t.TableName == "CustomTypeTestObj").Columns.Last (); + Assert.AreEqual ("JsonColumn", jsonColumn.Name); + Assert.AreEqual(typeof(JsonDocument), jsonColumn.ColumnType); + Assert.AreEqual (1, h.TableCreatedCalled); // important for cases like spatialite where we want to create a geometryconstraint + db.Close(); + } + + [Test] + public void CanMigrateTable_WithColumnForCustomType () + { + // Arrange + var db = new SQLiteConnection (TestPath.GetTempFileName ()); + var h = new JsonDocumentTypeHandler (); + db.DefineCustomType (h); + + db.Execute (""" + CREATE TABLE TestObj ( + Id VARCHAR (36) PRIMARY KEY + NOT NULL + ); + """); + + // Act + db.CreateTable(); + + + // Assert + var jsonColumn = db.TableMappings.SingleOrDefault (t => t.TableName == "CustomTypeTestObj").Columns.Last (); + Assert.AreEqual ("JsonColumn", jsonColumn.Name); + Assert.AreEqual(typeof(JsonDocument), jsonColumn.ColumnType); + Assert.AreEqual (1, h.TableCreatedCalled); // important for cases like spatialite where we want to create a geometryconstraint + db.Close(); + } + + [Test] + public void ShouldPersistAndReadCustomType() + { + var db = new SQLiteConnection(TestPath.GetTempFileName()); + db.DefineCustomType ( new JsonDocumentTypeHandler ()); + db.CreateTable(); + + var obj1 = new CustomTypeTestObj() { Id=new Guid("36473164-C9E4-4CDF-B266-A0B287C85623"), + JsonColumn = JsonDocument.Parse (""" + { + "name": "Joe", + "age": 16, + "canDrive": false + } + """)}; + var obj2 = new CustomTypeTestObj() { Id=new Guid("BC5C4C4A-CA57-4B61-8B53-9FD4673528B6"), + JsonColumn = JsonDocument.Parse (""" + { + "name": "Mary", + "age": 25, + "canDrive": true + } + """)}; + + var numIn1 = db.Insert(obj1); + var numIn2 = db.Insert(obj2); + Assert.AreEqual(1, numIn1); + Assert.AreEqual(1, numIn2); + + var result = db.Query("select JsonColumn from CustomTypeTestObj").ToList(); + Assert.AreEqual(2, result.Count); + + Assert.IsNotNull (result[0].JsonColumn); + Assert.IsNotNull (result[1].JsonColumn); + + Assert.AreEqual (obj1.JsonColumn.ToString (), result[0].JsonColumn.ToString ()); + Assert.AreEqual (obj2.JsonColumn.ToString (), result[1].JsonColumn.ToString ()); + + db.Close(); + } + + [Test] + public void ShouldSupportCustomType_InCustomQueryType () + { + var db = new SQLiteConnection(TestPath.GetTempFileName()); + db.DefineCustomType ( new JsonDocumentTypeHandler ()); + db.CreateTable(); + + var obj1 = new CustomTypeTestObj() { Id=new Guid("36473164-C9E4-4CDF-B266-A0B287C85623"), + JsonColumn = JsonDocument.Parse (""" + { + "name": "Joe", + "age": 16, + "canDrive": false + } + """)}; + var obj2 = new CustomTypeTestObj() { Id=new Guid("BC5C4C4A-CA57-4B61-8B53-9FD4673528B6"), + JsonColumn = JsonDocument.Parse (""" + { + "name": "Mary", + "age": 25, + "canDrive": true + } + """)}; + + var numIn1 = db.Insert(obj1); + var numIn2 = db.Insert(obj2); + Assert.AreEqual(1, numIn1); + Assert.AreEqual(1, numIn2); + + var result = db.Query("select * from CustomTypeTestObj").ToList(); + Assert.AreEqual(2, result.Count); + + Assert.AreEqual(obj1.Id, result[0].Id); + Assert.AreEqual(obj2.Id, result[1].Id); + + Assert.IsNotNull (result[0].JsonColumn); + Assert.IsNotNull (result[1].JsonColumn); + + Assert.AreEqual (obj1.JsonColumn.ToString (), result[0].JsonColumn.ToString ()); + Assert.AreEqual (obj2.JsonColumn.ToString (), result[1].JsonColumn.ToString ()); + + db.Close(); + } + + [Test] + public void ShouldThrow_IfCustomTypeIsUsedInQuery() + { + var db = new SQLiteConnection(TestPath.GetTempFileName()); + db.DefineCustomType ( new JsonDocumentTypeHandler ()); + db.CreateTable(); + + var obj1 = new CustomTypeTestObj() { Id=new Guid("36473164-C9E4-4CDF-B266-A0B287C85623"), + JsonColumn = JsonDocument.Parse (""" + { + "name": "Joe", + "age": 16, + "canDrive": false + } + """)}; + var obj2 = new CustomTypeTestObj() { Id=new Guid("BC5C4C4A-CA57-4B61-8B53-9FD4673528B6"), + JsonColumn = JsonDocument.Parse (""" + { + "name": "Mary", + "age": 25, + "canDrive": true + } + """)}; + + var numIn1 = db.Insert(obj1); + var numIn2 = db.Insert(obj2); + Assert.AreEqual(1, numIn1); + Assert.AreEqual(1, numIn2); + + NotSupportedException exception = null; + + try { + // Act + db.Table ().Where (o => o.JsonColumn.RootElement.ValueKind == JsonValueKind.Null).ToList (); + } + catch (NotSupportedException ex) { + exception = ex; + } + + // Assert + Assert.IsNotNull (exception); + Assert.AreEqual ("Custom type JsonDocument cannot be used in LINQ queries. Column 'JsonColumn' of type JsonDocument is not supported in WHERE clauses or other LINQ operations.", exception.Message); + + db.Close(); + } + + [Test] + public void ShouldSupportBaseTypeColumnType () + { + var db = new SQLiteConnection (TestPath.GetTempFileName ()); + db.DefineCustomType (new BaseClassTypeHandler ()); + db.CreateTable(); + + var obj1 = new BaseClassTestObj() { + Id=new Guid("36473164-C9E4-4CDF-B266-A0B287C85623"), + CustomColumn = new BaseClass {Name="base"} + }; + var obj2 = new BaseClassTestObj () { + Id = new Guid ("BC5C4C4A-CA57-4B61-8B53-9FD4673528B6"), + CustomColumn = new DerivedClass () { Name = "derived" } + }; + + var numIn1 = db.Insert(obj1); + var numIn2 = db.Insert(obj2); + Assert.AreEqual(1, numIn1); + Assert.AreEqual(1, numIn2); + + // Act + var result = db.Query("select * from BaseClassTestObj").ToList(); + + // Assert + Assert.AreEqual(2, result.Count); + + Assert.AreEqual(obj1.Id, result[0].Id); + Assert.AreEqual(obj2.Id, result[1].Id); + + Assert.IsNotNull (result[0].CustomColumn); + Assert.IsNotNull (result[1].CustomColumn); + + Assert.IsInstanceOf(result[0].CustomColumn); + Assert.IsInstanceOf(result[1].CustomColumn); + + Assert.AreEqual("base", result[0].CustomColumn.Name); + Assert.AreEqual("derived", result[1].CustomColumn.Name); + + db.Close(); + } + + public class BaseClassTestObj { + [PrimaryKey] + public Guid Id { get; set; } + public BaseClass CustomColumn { get; set; } + + public override string ToString() { + return string.Format("[TestObj: Id={0}, CustomColumn={1}:{2}]", Id, CustomColumn?.GetType ()?.Name, CustomColumn?.Name); + } + + } + public class BaseClassTypeHandler : CustomTypeHandler + { + public override void Initialize (SQLiteConnection connection) + { + } + + public override string GetSqlType (CustomTypeMetadata metadata) + { + return "TEXT"; + } + + public override object ConvertToBindableValue (BaseClass value, CustomTypeMetadata metadata) + { + if (value is null) + return null; + + if (value is DerivedClass d) + return $"D:{d.Name}"; + return $"B:{value.Name}"; + } + + public override BaseClass ConvertFromDatabaseValue (object value, CustomTypeMetadata metadata) + { + if (value is null) + return null; + + if (value is string s) { + if (s.StartsWith ("D")) + return new DerivedClass { Name = s.Split (':')[1] }; + + return new BaseClass () { Name = s.Split (':')[1] }; + } + + throw new NotSupportedException ($"Value {value} not supported"); + } + } + public class BaseClass + { + public string Name { get; set; } + } + public class DerivedClass : BaseClass + { + + } + + [Test] + public void IfNoCustomIndexSqlProvided_CreatesDefaultIndex () + { + // Arrange + var db = new SQLiteConnection (TestPath.GetTempFileName ()); + var h = new CustomNumberTypeHandler (); + db.DefineCustomType (h); + + // Act + db.CreateTable (); + + // Assert + var def = db.ExecuteScalar (""" + SELECT sql + FROM sqlite_master + WHERE type = 'index' + AND name = 'TestObj2_Amount'; + """); + Assert.AreEqual ("CREATE INDEX \"TestObj2_Amount\" on \"TestObj2\"(\"Amount\")", def); + } + + [Test] + public void IfCustomIndexSqlProvided_CreatesCustomIndex () + { + // Arrange + var db = new SQLiteConnection (TestPath.GetTempFileName ()); + var h = new CustomNumberTypeHandler (); + db.DefineCustomType (h); + + h.CustomIndexSql = "CREATE INDEX acctchng_magnitude ON testobj2(abs(amount));"; + + // Act + db.CreateTable (); + + // Assert + var def = db.ExecuteScalar (""" + SELECT sql + FROM sqlite_master + WHERE type = 'index' + AND name = 'acctchng_magnitude'; + """); + Assert.AreEqual ("CREATE INDEX acctchng_magnitude ON testobj2(abs(amount))", def); + } + + [Test] + public void CanUseCustomSelectExpression () + { + // Arrange + var db = new SQLiteConnection (TestPath.GetTempFileName ()); + var h = new CustomNumberTypeHandler (); + db.DefineCustomType (h); + db.CreateTable (); + + // Note: this is a very contrived example. + // this feature is, however, very important when working with e.g. Spatialite + // as you can use 'AsText(columnName)' to get the geometry as string, which is serializable + h.CustomSelectExpression = "{0}+10"; + + db.Insert (new TestObj2 { Amount = new CustomNumber {Value=1} }); + db.Insert (new TestObj2 { Amount = new CustomNumber {Value=-100} }); + + // Act + var results = db.Table ().ToList (); + + // Assert + Assert.AreEqual (results[0].Amount.Value, 11); + Assert.AreEqual (results[1].Amount.Value, -90); + } + + public class TestObj2 + { + [PrimaryKey] + [AutoIncrement] + public int Id { get; set; } + + [Indexed] + public CustomNumber Amount { get; set; } + } + + public class CustomNumber + { + public long Value { get; set; } + } + + public class CustomNumberTypeHandler : CustomTypeHandler + { + public override void Initialize (SQLiteConnection connection) + { + } + + public override string GetSqlType (CustomTypeMetadata metadata) + { + return "INTEGER"; + } + + public override object ConvertToBindableValue (CustomNumber value, CustomTypeMetadata metadata) + { + return value?.Value ?? 0; + } + + public override CustomNumber ConvertFromDatabaseValue (object value, CustomTypeMetadata metadata) + { + if (value is null) + return new CustomNumber { Value = 0 }; + + return new CustomNumber { Value = (long)value }; + } + + public string CustomIndexSql { get; set; } + + public override (string sql, CommandType commandType) GetCreateIndexSql (string indexName, string tableName, string columnName, + bool isUnique, CustomTypeMetadata metadata) + { + if (CustomIndexSql != null) + return (CustomIndexSql, CommandType.Execute); + + return base.GetCreateIndexSql(indexName, tableName, columnName, isUnique, metadata); + } + + public string CustomSelectExpression { get; set; } + + public override string GetSelectExpression (string columnName, CustomTypeMetadata metadata) + { + if (CustomSelectExpression != null) + return string.Format (CustomSelectExpression, columnName); + + return base.GetSelectExpression(columnName, metadata); + } + } + } +} diff --git a/tests/SQLite.Tests/SQLite.Tests.csproj b/tests/SQLite.Tests/SQLite.Tests.csproj index 5c14b15d..25a7da11 100644 --- a/tests/SQLite.Tests/SQLite.Tests.csproj +++ b/tests/SQLite.Tests/SQLite.Tests.csproj @@ -12,8 +12,17 @@ + + + + USE_SQLITEPCL_RAW;RELEASE + + + USE_SQLITEPCL_RAW;DEBUG + + SQLite.cs