/*
* SQLitePluginModule.cs
*
* A React Native module that wraps SQLite.
*
* Thread-safety. Except where otherwise noted, all of this class's code runs on a single
* ActionQueue which frees us from having to worry about thread-safety. We have a programming
* model similar to that of the UI thread.
*
* All ReactMethods must run on the same AwaitingQueue. This prevents the ReactMethods from
* interleaving when an `await` happens. This design enables us to think of each ReactMethod
* as being atomic relative to the other ReactMethods.
*/
using Newtonsoft.Json.Linq;
using ReactNative.Bridge;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Org.PGSQLite.SQLitePlugin
{
///
/// A module that allows JS to utilize sqlite databases.
///
public class SQLitePluginModule : NativeModuleBase
{
public enum WebSQLError
{
Unknown = 0,
Database = 1,
Version = 2,
TooLarge = 3,
Quota = 4,
Syntax = 5,
Constraint = 6,
Timeout = 7
}
private static readonly IntPtr NegativePointer = new IntPtr(-1);
private static WebSQLError sqliteToWebSQLError(SQLite.Net.Interop.Result sqliteError)
{
switch (sqliteError)
{
case SQLite.Net.Interop.Result.Error:
return WebSQLError.Syntax;
case SQLite.Net.Interop.Result.Full:
return WebSQLError.Quota;
case SQLite.Net.Interop.Result.Constraint:
return WebSQLError.Constraint;
default:
return WebSQLError.Unknown;
}
}
public class SQLiteError
{
public WebSQLError code { get; private set; }
public string message { get; private set; }
public SQLiteError(WebSQLError aCode, string aMessage)
{
code = aCode;
message = aMessage;
}
}
private class RNSQLiteException : Exception
{
public object JsonMessage { get; private set; }
public RNSQLiteException() : base()
{
}
public RNSQLiteException(object jsonMessage) : base()
{
JsonMessage = jsonMessage;
}
public RNSQLiteException(string message) : base(message)
{
JsonMessage = message;
}
public RNSQLiteException(string message, Exception inner) : base(message, inner)
{
JsonMessage = message;
}
}
private static byte[] GetNullTerminatedUtf8(string s)
{
var utf8Length = Encoding.UTF8.GetByteCount(s);
var bytes = new byte[utf8Length + 1];
Encoding.UTF8.GetBytes(s, 0, s.Length, bytes, 0);
return bytes;
}
// Throws when the file already exists.
private static Windows.Foundation.IAsyncOperation CopyDbAsync(Windows.Storage.StorageFile srcDbFile, string destDbFileName)
{
// This implementation is closely related to ResolveDbFilePath.
return srcDbFile.CopyAsync(Windows.Storage.ApplicationData.Current.LocalFolder, destDbFileName, Windows.Storage.NameCollisionOption.FailIfExists);
}
private static string ResolveDbFilePath(string dbFileName)
{
// This implementation is closely related to CopyDbAsync.
return Path.Combine(Windows.Storage.ApplicationData.Current.LocalFolder.Path, dbFileName);
}
private static Windows.Foundation.IAsyncOperation ResolveAssetFile(string assetFilePath, string dbFileName)
{
if (assetFilePath == null || assetFilePath.Length == 0)
{
return null;
}
else if (assetFilePath == "1")
{
// Built path to pre-populated DB asset from app bundle www subdirectory
return Windows.Storage.StorageFile.GetFileFromApplicationUriAsync(new Uri(
"ms-appx:///www/" + dbFileName));
}
else if (assetFilePath[0] == '~')
{
// Built path to pre-populated DB asset from app bundle subdirectory
return Windows.Storage.StorageFile.GetFileFromApplicationUriAsync(new Uri(
"ms-appx:///" + assetFilePath.Substring(1)));
}
else
{
// Built path to pre-populated DB asset from app sandbox directory
return Windows.Storage.StorageFile.GetFileFromApplicationUriAsync(new Uri(
"ms-appdata:///local/" + assetFilePath));
}
}
private class OpenDB
{
public SQLite.Net.Interop.IDbHandle Handle { get; private set; }
public string Path { get; private set; }
public OpenDB(SQLite.Net.Interop.IDbHandle handle, string path)
{
Handle = handle;
Path = path;
}
}
private readonly SQLite.Net.Platform.WinRT.SQLiteApiWinRT _sqliteAPI;
private readonly Dictionary _openDBs = new Dictionary();
private readonly AwaitingQueue _awaitingQueue = new AwaitingQueue();
private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
///
/// Instantiates the .
///
internal SQLitePluginModule()
{
_sqliteAPI = new SQLite.Net.Platform.WinRT.SQLiteApiWinRT(tempFolderPath: null, useWinSqlite: true);
}
public override void OnReactInstanceDispose()
{
_cancellationTokenSource.Cancel();
// TODO: When React Native Windows introduces asynchronous disposal
// (`OnReactInstanceDisposeAsync`), we should start using that and
// we should run this work on the awaiting queue. Currently, there's
// a race condition where we might close the database while one of
// the ReactMethods is in the middle of an `await`.
// https://github.com/Microsoft/react-native-windows/issues/1873
foreach (var dbInfoPair in _openDBs)
{
if (_sqliteAPI.Close(dbInfoPair.Value.Handle) != SQLite.Net.Interop.Result.OK)
{
System.Diagnostics.Debug.WriteLine("SQLitePluginModule: Error closing database: " + dbInfoPair.Value.Path);
}
}
_openDBs.Clear();
}
///
/// The name of the native module.
///
public override string Name
{
get
{
return "SQLite";
}
}
private async void QueueWithCancellation(Func work)
{
await _awaitingQueue.RunOrQueue(work, _cancellationTokenSource.Token);
}
public class EchoStringValueOptions
{
public string Value { get; set; }
}
[ReactMethod]
public void echoStringValue(EchoStringValueOptions options, ICallback success, ICallback error)
{
QueueWithCancellation(() =>
{
success.Invoke(options.Value);
return Task.CompletedTask;
});
}
public class OpenOptions
{
// Path at which to store the database
public string Name { get; set; }
// Optional. When creating the DB, uses this file as the initial state.
public string AssetFileName { get; set; }
public bool ReadOnly { get; set; }
}
[ReactMethod]
public void open(OpenOptions options, ICallback success, ICallback error)
{
QueueWithCancellation(async () =>
{
var dbFileName = options.Name;
if (dbFileName == null)
{
error.Invoke("You must specify database name");
return;
}
if (_openDBs.ContainsKey(dbFileName))
{
success.Invoke("Database opened");
return;
}
var assetFileOp = ResolveAssetFile(options.AssetFileName, dbFileName);
var assetFile = assetFileOp == null ? null : await assetFileOp;
// NoMutex means SQLite can be safely used by multiple threads provided that no
// single database connection is used simultaneously in two or more threads.
SQLite.Net.Interop.SQLiteOpenFlags sqlOpenFlags = SQLite.Net.Interop.SQLiteOpenFlags.NoMutex;
string absoluteDbPath;
if (options.ReadOnly && assetFileOp != null)
{
sqlOpenFlags |= SQLite.Net.Interop.SQLiteOpenFlags.ReadOnly;
absoluteDbPath = assetFile.Path;
}
else
{
sqlOpenFlags |= SQLite.Net.Interop.SQLiteOpenFlags.ReadWrite | SQLite.Net.Interop.SQLiteOpenFlags.Create;
absoluteDbPath = ResolveDbFilePath(dbFileName);
// Option to create from resource (pre-populated) if db does not exist:
if (assetFileOp != null)
{
try
{
await CopyDbAsync(assetFile, dbFileName);
}
catch (Exception)
{
// CopyDbAsync throws when the file already exists.
}
}
}
SQLite.Net.Interop.IDbHandle dbHandle;
if (_sqliteAPI.Open(GetNullTerminatedUtf8(absoluteDbPath), out dbHandle, (int)sqlOpenFlags, IntPtr.Zero) == SQLite.Net.Interop.Result.OK)
{
_openDBs[dbFileName] = new OpenDB(dbHandle, absoluteDbPath);
success.Invoke("Database opened");
}
else
{
error.Invoke("Unable to open DB");
}
});
}
public class CloseOptions
{
public string Path { get; set; }
}
[ReactMethod]
public void close(CloseOptions options, ICallback success, ICallback error)
{
QueueWithCancellation(() =>
{
var dbFileName = options.Path;
if (dbFileName == null)
{
error.Invoke("You must specify database path");
return Task.CompletedTask;
}
if (!_openDBs.ContainsKey(dbFileName))
{
error.Invoke("Specified db was not open");
return Task.CompletedTask;
}
var dbInfo = _openDBs[dbFileName];
_openDBs.Remove(dbFileName);
if (_sqliteAPI.Close(dbInfo.Handle) != SQLite.Net.Interop.Result.OK)
{
System.Diagnostics.Debug.WriteLine("SQLitePluginModule: Error closing database: " + dbInfo.Path);
}
success.Invoke("DB closed");
return Task.CompletedTask;
});
}
[ReactMethod]
public void attach(JObject options, ICallback success, ICallback error)
{
QueueWithCancellation(() =>
{
error.Invoke("attach isn't supported on this platform");
return Task.CompletedTask;
});
}
public class DeleteOptions
{
public string Path { get; set; }
}
[ReactMethod]
public void delete(DeleteOptions options, ICallback success, ICallback error)
{
QueueWithCancellation(async () =>
{
var dbFileName = options.Path;
if (dbFileName == null)
{
error.Invoke("You must specify database path");
return;
}
if (_openDBs.ContainsKey(dbFileName))
{
var dbInfo = _openDBs[dbFileName];
_openDBs.Remove(dbFileName);
if (_sqliteAPI.Close(dbInfo.Handle) != SQLite.Net.Interop.Result.OK)
{
System.Diagnostics.Debug.WriteLine("SQLitePluginModule: Error closing database: " + dbInfo.Path);
}
}
var absoluteDbPath = ResolveDbFilePath(dbFileName);
try
{
var dbFile = await Windows.Storage.StorageFile.GetFileFromPathAsync(absoluteDbPath);
await dbFile.DeleteAsync();
}
catch (FileNotFoundException)
{
error.Invoke("The database does not exist on that path");
return;
}
success.Invoke("DB deleted");
});
}
public class DBArgs
{
public string DBName { get; set; }
}
public class DBQuery
{
public int QID { get; set; }
public JArray Params { get; set; } // optional
public string SQL { get; set; }
}
public class ExecuteSqlBatchOptions
{
public DBArgs DBArgs { get; set; }
public List Executes { get; set; }
}
private void BindStatement(SQLite.Net.Interop.IDbStatement statement, int argIndex, JToken arg)
{
switch (arg.Type)
{
case JTokenType.Undefined:
case JTokenType.Null:
_sqliteAPI.BindNull(statement, argIndex);
break;
case JTokenType.Boolean:
_sqliteAPI.BindInt(statement, argIndex, arg.ToObject() ? 1 : 0);
break;
case JTokenType.Integer:
_sqliteAPI.BindInt64(statement, argIndex, arg.ToObject());
break;
case JTokenType.Float:
_sqliteAPI.BindDouble(statement, argIndex, arg.ToObject());
break;
case JTokenType.String:
_sqliteAPI.BindText16(statement, argIndex, arg.ToObject(), -1, NegativePointer);
break;
default:
_sqliteAPI.BindText16(statement, argIndex, arg.ToObject(), -1, NegativePointer);
break;
}
}
private object ExtractColumn(SQLite.Net.Interop.IDbStatement statement, int columnIndex)
{
var columnType = _sqliteAPI.ColumnType(statement, columnIndex);
switch (columnType)
{
case SQLite.Net.Interop.ColType.Integer:
return _sqliteAPI.ColumnInt64(statement, columnIndex);
case SQLite.Net.Interop.ColType.Float:
return _sqliteAPI.ColumnDouble(statement, columnIndex);
case SQLite.Net.Interop.ColType.Blob:
return _sqliteAPI.ColumnBlob(statement, columnIndex);
case SQLite.Net.Interop.ColType.Text:
return _sqliteAPI.ColumnText16(statement, columnIndex);
case SQLite.Net.Interop.ColType.Null:
default:
return null;
}
}
private Dictionary ExtractRow(SQLite.Net.Interop.IDbStatement statement)
{
var row = new Dictionary();
var columnCount = _sqliteAPI.ColumnCount(statement);
for (var i = 0; i < columnCount; i++)
{
var columnName = _sqliteAPI.ColumnName16(statement, i);
var columnValue = ExtractColumn(statement, i);
if (columnValue != null)
{
row[columnName] = columnValue;
}
}
return row;
}
public delegate void SQLiteErrorEvent(SQLiteError error);
public event SQLiteErrorEvent OnSQLiteError;
private bool _isExecutingQuery = false;
private Dictionary ExecuteQuery(OpenDB dbInfo, DBQuery query)
{
System.Diagnostics.Debug.Assert(!_isExecutingQuery, "SQLitePluginModule: Only 1 query should be executing at a time.");
_isExecutingQuery = true;
try
{
if (query.SQL == null)
{
throw new RNSQLiteException("You must specify a sql query to execute");
}
try
{
var previousRowsAffected = _sqliteAPI.TotalChanges(dbInfo.Handle);
var statement = _sqliteAPI.Prepare2(dbInfo.Handle, query.SQL);
if (query.Params != null)
{
var argIndex = 0;
foreach (var arg in query.Params.Children())
{
// sqlite bind uses 1-based indexing for the arguments
BindStatement(statement, argIndex + 1, arg);
argIndex++;
}
}
var resultRows = new List>();
long? insertId = null;
var rowsAffected = 0;
SQLiteError error = null;
var keepGoing = true;
while (keepGoing)
{
switch (_sqliteAPI.Step(statement))
{
case SQLite.Net.Interop.Result.Row:
resultRows.Add(ExtractRow(statement));
break;
case SQLite.Net.Interop.Result.Done:
var nowRowsAffected = _sqliteAPI.TotalChanges(dbInfo.Handle);
rowsAffected = nowRowsAffected - previousRowsAffected;
var nowInsertId = _sqliteAPI.LastInsertRowid(dbInfo.Handle);
if (rowsAffected > 0 && nowInsertId != 0)
{
insertId = nowInsertId;
}
keepGoing = false;
break;
default:
var webErrorCode = sqliteToWebSQLError(_sqliteAPI.ErrCode(dbInfo.Handle));
var message = _sqliteAPI.Errmsg16(dbInfo.Handle);
error = new SQLiteError(webErrorCode, message);
keepGoing = false;
break;
}
}
_sqliteAPI.Finalize(statement);
if (error != null)
{
NotifyOnSQLiteException(error);
throw new RNSQLiteException(error);
}
var resultSet = new Dictionary
{
{ "rows", resultRows },
{ "rowsAffected", rowsAffected }
};
if (insertId != null)
{
resultSet["insertId"] = insertId;
}
return resultSet;
}
catch (SQLite.Net.SQLiteException ex)
{
var error = new SQLiteError(sqliteToWebSQLError(ex.Result), ex.Message);
NotifyOnSQLiteException(error);
throw new RNSQLiteException(error);
}
}
finally
{
_isExecutingQuery = false;
}
}
private void NotifyOnSQLiteException(SQLiteError error)
{
try
{
OnSQLiteError?.Invoke(error);
}
catch (Exception)
{
// no-op
}
}
[ReactMethod]
public void executeSqlBatch(ExecuteSqlBatchOptions options, ICallback success, ICallback error)
{
QueueWithCancellation(() =>
{
var dbFileName = options.DBArgs.DBName;
if (dbFileName == null)
{
error.Invoke("You must specify database path");
return Task.CompletedTask;
}
OpenDB dbInfo;
if (!_openDBs.TryGetValue(dbFileName, out dbInfo))
{
error.Invoke("No such database, you must open it first");
return Task.CompletedTask;
}
var results = new List>();
foreach (var query in options.Executes)
{
try
{
var rawResult = ExecuteQuery(dbInfo, query);
results.Add(new Dictionary
{
{ "qid", query.QID },
{ "type", "success" },
{ "result", rawResult }
});
}
catch (RNSQLiteException ex)
{
results.Add(new Dictionary
{
{ "qid", query.QID },
{ "type", "error" },
{ "error", ex.JsonMessage },
{ "result", ex.JsonMessage }
});
}
}
success.Invoke(results);
return Task.CompletedTask;
});
}
[ReactMethod]
public void backgroundExecuteSqlBatch(ExecuteSqlBatchOptions options, ICallback success, ICallback error)
{
// Currently, all ReactMethods are run on the same ActionQueue. This prevents
// queries from being able to run in parallel but it makes the code simpler.
//
// `executeSqlBatch` takes care of putting the work on the awaiting queue
// so we don't have to.
executeSqlBatch(options, success, error);
}
}
}