diff --git a/c_src/sqlite3_nif.c b/c_src/sqlite3_nif.c index 31cb7ac9..af1259cf 100644 --- a/c_src/sqlite3_nif.c +++ b/c_src/sqlite3_nif.c @@ -17,6 +17,9 @@ static ErlNifResourceType* connection_type = NULL; static ErlNifResourceType* statement_type = NULL; static sqlite3_mem_methods default_alloc_methods = {0}; +ErlNifPid* log_hook_pid = NULL; +ErlNifMutex* log_hook_mutex = NULL; + typedef struct connection { sqlite3* db; @@ -956,6 +959,11 @@ on_load(ErlNifEnv* env, void** priv, ERL_NIF_TERM info) return -1; } + log_hook_mutex = enif_mutex_create("exqlite:log_hook"); + if (!log_hook_mutex) { + return -1; + } + return 0; } @@ -965,6 +973,7 @@ on_unload(ErlNifEnv* caller_env, void* priv_data) assert(caller_env); sqlite3_config(SQLITE_CONFIG_MALLOC, &default_alloc_methods); + enif_mutex_destroy(log_hook_mutex); } // @@ -1065,6 +1074,61 @@ exqlite_set_update_hook(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) return make_atom(env, "ok"); } +// +// Log Notifications +// + +void +log_callback(void* arg, int iErrCode, const char* zMsg) +{ + if (log_hook_pid == NULL) { + return; + } + + ErlNifEnv* msg_env = enif_alloc_env(); + ERL_NIF_TERM error = make_binary(msg_env, zMsg, strlen(zMsg)); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, make_atom(msg_env, "log"), enif_make_int(msg_env, iErrCode), error); + + if (!enif_send(NULL, log_hook_pid, msg_env, msg)) { + enif_mutex_lock(log_hook_mutex); + sqlite3_config(SQLITE_CONFIG_LOG, NULL, NULL); + enif_free(log_hook_pid); + log_hook_pid = NULL; + enif_mutex_unlock(log_hook_mutex); + } + + enif_free_env(msg_env); +} + +static ERL_NIF_TERM +exqlite_set_log_hook(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) +{ + assert(env); + + if (argc != 1) { + return enif_make_badarg(env); + } + + ErlNifPid* pid = (ErlNifPid*)enif_alloc(sizeof(ErlNifPid)); + if (!enif_get_local_pid(env, argv[0], pid)) { + enif_free(pid); + return make_error_tuple(env, "invalid_pid"); + } + + enif_mutex_lock(log_hook_mutex); + + if (log_hook_pid) { + enif_free(log_hook_pid); + } + + log_hook_pid = pid; + sqlite3_config(SQLITE_CONFIG_LOG, log_callback, NULL); + + enif_mutex_unlock(log_hook_mutex); + + return make_atom(env, "ok"); +} + // // Most of our nif functions are going to be IO bounded // @@ -1086,6 +1150,7 @@ static ErlNifFunc nif_funcs[] = { {"release", 2, exqlite_release, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"enable_load_extension", 2, exqlite_enable_load_extension, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"set_update_hook", 2, exqlite_set_update_hook, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"set_log_hook", 1, exqlite_set_log_hook, ERL_NIF_DIRTY_JOB_IO_BOUND}, }; ERL_NIF_INIT(Elixir.Exqlite.Sqlite3NIF, nif_funcs, on_load, NULL, NULL, on_unload) diff --git a/lib/exqlite/sqlite3.ex b/lib/exqlite/sqlite3.ex index 21f638df..e250f662 100644 --- a/lib/exqlite/sqlite3.ex +++ b/lib/exqlite/sqlite3.ex @@ -223,7 +223,7 @@ defmodule Exqlite.Sqlite3 do * Only one pid can listen to the changes on a given database connection at a time. If this function is called multiple times for the same connection, only the last pid will receive the notifications - * Updates only happen for the connection that is opened. For example, there + * Updates only happen for the connection that is opened. For example, there are two connections A and B. When an update happens on connection B, the hook set for connection A will not receive the update, but the hook for connection B will receive the update. @@ -233,6 +233,30 @@ defmodule Exqlite.Sqlite3 do Sqlite3NIF.set_update_hook(conn, pid) end + @doc """ + Send log messages to a process. + + Each time a message is logged in SQLite a message will be sent to the pid provided as the argument. + + The message is of the form: `{:log, rc, message}`, where: + + * `rc` is an integer [result code](https://www.sqlite.org/rescode.html) or an [extended result code](https://www.sqlite.org/rescode.html#extrc) + * `message` is a string representing the log message + + See [`SQLITE_CONFIG_LOG`](https://www.sqlite.org/c3ref/c_config_covering_index_scan.html) and + ["The Error And Warning Log"](https://www.sqlite.org/errlog.html) for more details. + + ## Restrictions + + * Only one pid can listen to the log messages at a time. + If this function is called multiple times, only the last pid will + receive the notifications + """ + @spec set_log_hook(pid()) :: :ok | {:error, reason()} + def set_log_hook(pid) do + Sqlite3NIF.set_log_hook(pid) + end + defp convert(%Date{} = val), do: Date.to_iso8601(val) defp convert(%Time{} = val), do: Time.to_iso8601(val) defp convert(%NaiveDateTime{} = val), do: NaiveDateTime.to_iso8601(val) diff --git a/lib/exqlite/sqlite3_nif.ex b/lib/exqlite/sqlite3_nif.ex index 8af364ec..df902910 100644 --- a/lib/exqlite/sqlite3_nif.ex +++ b/lib/exqlite/sqlite3_nif.ex @@ -67,5 +67,8 @@ defmodule Exqlite.Sqlite3NIF do @spec set_update_hook(db(), pid()) :: :ok | {:error, reason()} def set_update_hook(_conn, _pid), do: :erlang.nif_error(:not_loaded) + @spec set_log_hook(pid()) :: :ok | {:error, reason()} + def set_log_hook(_pid), do: :erlang.nif_error(:not_loaded) + # add statement inspection tooling https://sqlite.org/c3ref/expanded_sql.html end diff --git a/test/exqlite/sqlite3_test.exs b/test/exqlite/sqlite3_test.exs index 732e8028..773b1ae5 100644 --- a/test/exqlite/sqlite3_test.exs +++ b/test/exqlite/sqlite3_test.exs @@ -491,4 +491,60 @@ defmodule Exqlite.Sqlite3Test do refute_receive {{:insert, "main", "test", 1}, _}, 1000 end end + + describe "set_log_hook/1" do + setup do + {:ok, conn} = Sqlite3.open(":memory:") + on_exit(fn -> Sqlite3.close(conn) end) + {:ok, conn: conn} + end + + test "can receive errors", %{conn: conn} do + assert :ok = Sqlite3.set_log_hook(self()) + + assert {:error, reason} = Sqlite3.prepare(conn, "some invalid sql") + assert reason == "near \"some\": syntax error" + + assert_receive {:log, rc, msg} + assert rc == 1 + assert msg == "near \"some\": syntax error in \"some invalid sql\"" + refute_receive _anything_else + end + + test "only one pid can listen at a time", %{conn: conn} do + assert :ok = Sqlite3.set_log_hook(self()) + + task = + Task.async(fn -> + :ok = Sqlite3.set_log_hook(self()) + assert {:error, reason} = Sqlite3.prepare(conn, "some invalid sql") + assert reason == "near \"some\": syntax error" + assert_receive {:log, rc, msg} + assert rc == 1 + assert msg == "near \"some\": syntax error in \"some invalid sql\"" + refute_receive _anything_else + end) + + Task.await(task) + refute_receive _anything_else + end + + test "receives notifications from all connections", %{conn: conn1} do + assert :ok = Sqlite3.set_log_hook(self()) + assert {:ok, conn2} = Sqlite3.open(":memory:") + on_exit(fn -> Sqlite3.close(conn2) end) + + assert {:error, _reason} = Sqlite3.prepare(conn1, "some invalid sql 1") + assert_receive {:log, rc, msg} + assert rc == 1 + assert msg == "near \"some\": syntax error in \"some invalid sql 1\"" + refute_receive _anything_else + + assert {:error, _reason} = Sqlite3.prepare(conn2, "some invalid sql 2") + assert_receive {:log, rc, msg} + assert rc == 1 + assert msg == "near \"some\": syntax error in \"some invalid sql 2\"" + refute_receive _anything_else + end + end end