diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 682f2a6..8c4fe61 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -15,6 +15,18 @@ permissions: jobs: test: services: + mongodb: + image: mongo:8.0 + env: + MONGO_INITDB_ROOT_USERNAME: test_user + MONGO_INITDB_ROOT_PASSWORD: test_pass + ports: + - 27017:27017 + options: >- + --health-cmd="mongosh --quiet --eval 'db.adminCommand({ping: 1})'" + --health-interval=5s + --health-timeout=5s + --health-retries=10 db: image: postgres:12 ports: ["5432:5432"] @@ -123,6 +135,7 @@ jobs: - name: Seed adapter test databases run: | + docker exec -i ${{ job.services.mongodb.id }} mongosh --quiet -u test_user -p test_pass --authenticationDatabase admin < test/support/adapters/mongodb-seed.js PGPASSWORD=postgres psql -h localhost -U postgres -d postgres -c "CREATE DATABASE test_db;" PGPASSWORD=postgres psql -h localhost -U postgres -d test_db -f test/support/adapters/postgresql-seed.sql mysql -h 127.0.0.1 -uroot -proot_pass -e "CREATE DATABASE IF NOT EXISTS test_db;" diff --git a/README.md b/README.md index a581e01..ad39e70 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ QueryCanary is a lightweight tool for monitoring your production data using SQL. ## What It Does - ✅ Run SQL checks against your production database +- ✅ Query MongoDB with JSON find, count, distinct and aggregation commands - ✅ Schedule checks with simple choices, local times, or custom cron expressions - ✅ Get alerts via email or Slack when values drift or break - ✅ See historical trends and chart results over time diff --git a/assets/js/hooks/sql_editor.js b/assets/js/hooks/sql_editor.js index 5aa0df3..8596c1f 100644 --- a/assets/js/hooks/sql_editor.js +++ b/assets/js/hooks/sql_editor.js @@ -2,6 +2,7 @@ import { EditorView, basicSetup } from 'codemirror'; import { EditorState } from '@codemirror/state'; import { keymap } from "@codemirror/view" import { sql, PostgreSQL, MySQL } from '@codemirror/lang-sql'; +import { json } from '@codemirror/lang-json'; import { indentWithTab } from "@codemirror/commands"; import { acceptCompletion @@ -47,7 +48,7 @@ const SQLEditor = { } // Configure SQL extension with properly formatted schema - const sqlLang = sql({ + const sqlLang = this.el.dataset.dialect === 'mongodb' ? json() : sql({ dialect: dialect, schema: schemaData || [] }); diff --git a/assets/package-lock.json b/assets/package-lock.json index ace87a9..07a65e1 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -7,6 +7,7 @@ "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", + "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-sql": "^6.8.0", "@codemirror/language": "^6.10.6", "@codemirror/lint": "^6.8.4", @@ -43,6 +44,16 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, "node_modules/@codemirror/lang-sql": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.8.0.tgz", @@ -134,6 +145,17 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/lr": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", diff --git a/assets/package.json b/assets/package.json index 182d57c..1214941 100644 --- a/assets/package.json +++ b/assets/package.json @@ -2,6 +2,7 @@ "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", + "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-sql": "^6.8.0", "@codemirror/language": "^6.10.6", "@codemirror/lint": "^6.8.4", diff --git a/docker-compose.adapters.yml b/docker-compose.adapters.yml index 2e13817..872e8e8 100644 --- a/docker-compose.adapters.yml +++ b/docker-compose.adapters.yml @@ -1,5 +1,15 @@ version: "3.9" services: + mongodb: + image: mongo:8.0 + environment: + MONGO_INITDB_ROOT_USERNAME: test_user + MONGO_INITDB_ROOT_PASSWORD: test_pass + ports: + - "27017:27017" + volumes: + - ./test/support/adapters/mongodb-seed.js:/docker-entrypoint-initdb.d/seed.js + postgres: image: postgres:15 restart: always diff --git a/lib/query_canary/connections/adapters/mongodb.ex b/lib/query_canary/connections/adapters/mongodb.ex new file mode 100644 index 0000000..1b1b9a3 --- /dev/null +++ b/lib/query_canary/connections/adapters/mongodb.ex @@ -0,0 +1,329 @@ +defmodule QueryCanary.Connections.Adapters.MongoDB do + @moduledoc """ + MongoDB queries expressed as JSON find, count, distinct or aggregate commands. + Cursors are fully consumed and BSON values are converted for result storage. + """ + @behaviour QueryCanary.Connections.Adapter + + @query_options %{ + "projection" => :projection, + "sort" => :sort, + "limit" => :limit, + "skip" => :skip, + "batchSize" => :batch_size, + "maxTimeMS" => :max_time, + "allowDiskUse" => :allow_disk_use, + "collation" => :collation, + "hint" => :hint, + "comment" => :comment + } + + def connect(details) do + opts = [ + hostname: details.hostname, + port: details.port || 27017, + database: details.database, + username: details.username, + password: details.password, + auth_source: details[:auth_source] || details.database, + direct_connection: details[:direct_connection] || false, + socket_options: details[:socket_options] || [], + pool_size: 1, + connect_timeout: 3_000, + timeout: 3_000 + ] + + safely(fn -> + with {:ok, pid} <- Mongo.start_link(opts ++ ssl_options(details)) do + case safely(fn -> + Mongo.command(pid, [ping: 1], timeout: 3_000, checkout_timeout: 3_000) + end) do + {:ok, _} -> + {:ok, pid} + + {:error, reason} -> + disconnect(pid) + {:error, reason} + end + end + end) + end + + def query(conn, query, params \\ [], opts \\ []) do + safely(fn -> + with {:ok, command} <- parse_query(query, params) do + execute(conn, command, query_timeout_options(opts)) + end + end) + end + + @doc "Parses JSON and substitutes whole-string positional parameters and Extended JSON values." + def parse_query(query, params \\ []) do + safely(fn -> + case Jason.decode(query) do + {:ok, command} when is_map(command) -> {:ok, decode_values(command, params)} + {:ok, _} -> {:error, "MongoDB queries must be a JSON object"} + {:error, _} -> {:error, "Invalid MongoDB JSON query"} + end + end) + end + + defp execute(conn, %{"find" => collection} = command, opts) when is_binary(collection) do + conn + |> Mongo.find(collection, command["filter"] || %{}, query_options(command, opts)) + |> format_cursor() + end + + defp execute(conn, %{"aggregate" => collection, "pipeline" => pipeline} = command, opts) + when is_binary(collection) and is_list(pipeline) do + conn + |> Mongo.aggregate(collection, pipeline, query_options(command, opts)) + |> format_cursor() + end + + defp execute(conn, %{"count" => collection} = command, opts) when is_binary(collection) do + case Mongo.count_documents( + conn, + collection, + command["query"] || %{}, + query_options(command, opts) + ) do + {:ok, count} -> {:ok, format_results([%{"value" => count}])} + {:error, reason} -> {:error, reason} + end + end + + defp execute(conn, %{"distinct" => collection, "key" => key} = command, opts) + when is_binary(collection) and is_binary(key) do + case Mongo.distinct( + conn, + collection, + key, + command["query"] || %{}, + query_options(command, opts) + ) do + {:ok, values} -> {:ok, format_results(Enum.map(values, &%{"value" => &1}))} + {:error, reason} -> {:error, reason} + end + end + + defp execute(conn, %{"ping" => 1}, opts), do: run_command(conn, [ping: 1], opts) + defp execute(conn, %{"buildInfo" => 1}, opts), do: run_command(conn, [buildInfo: 1], opts) + + defp execute(_, _, _), + do: {:error, "Use a MongoDB find, count, distinct or aggregate JSON command"} + + defp run_command(conn, command, opts) do + case Mongo.command(conn, command, opts) do + {:ok, result} -> + {:ok, format_results([Map.drop(result, ["ok", "$clusterTime", "operationTime"])])} + + {:error, reason} -> + {:error, reason} + end + end + + def list_tables(conn) do + safely(fn -> + {:ok, conn |> Mongo.show_collections(query_timeout_options([])) |> Enum.sort()} + end) + end + + def get_table_schema(conn, collection) do + safely(fn -> + case Mongo.find(conn, collection, %{}, [limit: 100] ++ query_timeout_options([])) do + {:error, reason} -> + {:error, reason} + + cursor -> + fields = + cursor + |> Enum.flat_map(fn document -> + Enum.map(document, fn {key, value} -> {key, bson_type(value)} end) + end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Enum.sort() + |> Enum.map(fn {field, types} -> + %{ + label: field, + detail: types |> Enum.uniq() |> Enum.sort() |> Enum.join(" | "), + section: collection, + type: "keyword" + } + end) + + {:ok, fields} + end + end) + end + + def get_database_schema(conn, _database) do + with {:ok, collections} <- list_tables(conn) do + Enum.reduce_while(collections, {:ok, %{}}, fn collection, {:ok, schema} -> + case get_table_schema(conn, collection) do + {:ok, fields} -> {:cont, {:ok, Map.put(schema, collection, fields)}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + end + + def disconnect(pid) do + if Process.alive?(pid), do: GenServer.stop(pid, :normal) + :ok + catch + :exit, _ -> :ok + end + + defp query_options(command, opts) do + options = + Enum.flat_map(@query_options, fn {key, option} -> + if Map.has_key?(command, key), do: [{option, command[key]}], else: [] + end) + + options = + case command["cursor"] do + %{"batchSize" => size} -> Keyword.put(options, :batch_size, size) + _ -> options + end + + Keyword.merge(options, opts) + end + + defp query_timeout_options(opts) do + opts = Keyword.put_new(opts, :timeout, 4_000) + Keyword.put_new(opts, :checkout_timeout, opts[:timeout]) + end + + defp format_cursor({:error, reason}), do: {:error, reason} + defp format_cursor(cursor), do: {:ok, cursor |> Enum.to_list() |> format_results()} + + @doc "Converts query documents to the shared result shape with JSON-safe BSON values." + def format_results(documents) do + rows = Enum.map(documents, &normalize_value/1) + columns = rows |> Enum.flat_map(&Map.keys/1) |> Enum.uniq() |> Enum.sort() + # Scalar metrics prefer an explicitly projected value over an aggregation group id. + columns = if "value" in columns, do: ["value" | List.delete(columns, "value")], else: columns + %{rows: rows, columns: columns, original_columns: columns, num_rows: length(rows), raw: rows} + end + + defp decode_values(%{"$oid" => value} = doc, _) when map_size(doc) == 1, + do: BSON.ObjectId.decode!(value) + + defp decode_values(%{"$date" => value} = doc, _) when map_size(doc) == 1 do + case value do + value when is_integer(value) -> + DateTime.from_unix!(value, :millisecond) + + value when is_binary(value) -> + {:ok, datetime, _} = DateTime.from_iso8601(value) + datetime + end + end + + defp decode_values(%{"$numberDecimal" => value} = doc, _) when map_size(doc) == 1, + do: Decimal.new(value) + + defp decode_values(%{"$numberLong" => value} = doc, _) when map_size(doc) == 1, + do: String.to_integer(value) + + defp decode_values(doc, params) when is_map(doc), + do: Map.new(doc, fn {key, value} -> {key, decode_values(value, params)} end) + + defp decode_values(values, params) when is_list(values), + do: Enum.map(values, &decode_values(&1, params)) + + defp decode_values(value, params) when is_binary(value) do + case Regex.run(~r/^\$([1-9]\d*)$/, value) do + [_, index] -> + case Enum.fetch(params, String.to_integer(index) - 1) do + {:ok, parameter} -> parameter + :error -> raise ArgumentError, "Missing MongoDB query parameter #{value}" + end + + _ -> + value + end + end + + defp decode_values(value, _params), do: value + + defp normalize_value(%BSON.ObjectId{} = value), do: to_string(value) + defp normalize_value(%BSON.Binary{binary: value}), do: Base.encode64(value) + defp normalize_value(%DateTime{} = value), do: DateTime.to_iso8601(value) + defp normalize_value(%Decimal{} = value), do: Decimal.to_string(value) + defp normalize_value(%BSON.LongNumber{value: value}), do: value + defp normalize_value(%_{} = value), do: value |> Map.from_struct() |> normalize_value() + + defp normalize_value(value) when is_map(value), + do: Map.new(value, fn {key, value} -> {key, normalize_value(value)} end) + + defp normalize_value(value) when is_list(value), do: Enum.map(value, &normalize_value/1) + defp normalize_value(value), do: value + + defp bson_type(%BSON.ObjectId{}), do: "objectId" + defp bson_type(%DateTime{}), do: "date" + defp bson_type(%Decimal{}), do: "decimal" + defp bson_type(nil), do: "null" + defp bson_type(value) when is_boolean(value), do: "boolean" + defp bson_type(value) when is_integer(value), do: "integer" + defp bson_type(value) when is_float(value), do: "double" + defp bson_type(value) when is_binary(value), do: "string" + defp bson_type(value) when is_list(value), do: "array" + defp bson_type(_), do: "object" + + defp ssl_options(%{ssl_mode: mode} = details) + when mode in ["require", "verify-ca", "verify-full"] do + ssl_opts = + if mode == "require" do + [verify: :verify_none] + else + [ + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + server_name_indication: to_charlist(details[:tls_hostname] || details.hostname) + ] + end + + ssl_opts = + if mode == "verify-full" do + Keyword.put(ssl_opts, :customize_hostname_check, + match_fun: :public_key.pkix_verify_hostname_match_fun(:https) + ) + else + ssl_opts + end + + ssl_opts = + case details[:ssl_ca_cert] do + "-----BEGIN" <> _ = pem -> + certs = for {:Certificate, der, :not_encrypted} <- :public_key.pem_decode(pem), do: der + Keyword.put(ssl_opts, :cacerts, certs) + + path when is_binary(path) and path != "" -> + ssl_opts |> Keyword.delete(:cacerts) |> Keyword.put(:cacertfile, to_charlist(path)) + + _ -> + ssl_opts + end + + [ssl: true, ssl_opts: ssl_opts] + end + + defp ssl_options(_), do: [ssl: false] + + defp safely(fun) do + case fun.() do + {:error, reason} -> {:error, error_message(reason)} + result -> result + end + rescue + error -> {:error, Exception.message(error)} + catch + :exit, reason -> {:error, "MongoDB connection error: #{inspect(reason)}"} + end + + defp error_message(reason) when is_binary(reason), do: reason + defp error_message(%{__exception__: true} = reason), do: Exception.message(reason) + defp error_message(reason), do: inspect(reason) +end diff --git a/lib/query_canary/connections/connection_manager.ex b/lib/query_canary/connections/connection_manager.ex index edd6796..6b72c89 100644 --- a/lib/query_canary/connections/connection_manager.ex +++ b/lib/query_canary/connections/connection_manager.ex @@ -85,5 +85,7 @@ defmodule QueryCanary.Connections.ConnectionManager do defp connection_test_query(%Server{db_engine: "prometheus"}), do: QueryCanary.Connections.Adapters.Prometheus.version_query() + defp connection_test_query(%Server{db_engine: "mongodb"}), do: ~s({"ping": 1}) + defp connection_test_query(_server), do: "SELECT now()" end diff --git a/lib/query_canary/connections/connection_server.ex b/lib/query_canary/connections/connection_server.ex index 03f6ec4..2ae8e72 100644 --- a/lib/query_canary/connections/connection_server.ex +++ b/lib/query_canary/connections/connection_server.ex @@ -210,6 +210,9 @@ defmodule QueryCanary.Connections.ConnectionServer do username: server.db_username, password: server.db_password, database: server.db_name, + auth_source: server.db_auth_source, + direct_connection: server.ssh_tunnel == true, + tls_hostname: server.db_hostname, ssl_mode: server.db_ssl_mode, ssl_cert: server.db_ssl_cert, ssl_key: server.db_ssl_key, @@ -272,6 +275,9 @@ defmodule QueryCanary.Connections.ConnectionServer do defp adapter_for(%Server{db_engine: "clickhouse"}), do: QueryCanary.Connections.Adapters.ClickHouse + defp adapter_for(%Server{db_engine: "mongodb"}), + do: QueryCanary.Connections.Adapters.MongoDB + defp adapter_for(%Server{db_engine: "prometheus"}), do: QueryCanary.Connections.Adapters.Prometheus diff --git a/lib/query_canary/connections/connection_tester.ex b/lib/query_canary/connections/connection_tester.ex index f8e8298..01a87f1 100644 --- a/lib/query_canary/connections/connection_tester.ex +++ b/lib/query_canary/connections/connection_tester.ex @@ -148,6 +148,7 @@ defmodule QueryCanary.Connections.ConnectionTester do # Get appropriate version query based on DB engine defp get_version_query(%Server{db_engine: "postgresql"}), do: "SELECT version();" defp get_version_query(%Server{db_engine: "mysql"}), do: "SELECT VERSION();" + defp get_version_query(%Server{db_engine: "mongodb"}), do: ~s({"buildInfo": 1}) defp get_version_query(%Server{db_engine: "prometheus"}), do: QueryCanary.Connections.Adapters.Prometheus.version_query() diff --git a/lib/query_canary/servers/server.ex b/lib/query_canary/servers/server.ex index 1d226fb..82b9ecb 100644 --- a/lib/query_canary/servers/server.ex +++ b/lib/query_canary/servers/server.ex @@ -9,6 +9,7 @@ defmodule QueryCanary.Servers.Server do field :db_hostname, :string field :db_port, :integer field :db_name, :string + field :db_auth_source, :string field :db_username, :string field :db_password, :string, redact: true field :db_password_input, :string, virtual: true @@ -43,6 +44,7 @@ defmodule QueryCanary.Servers.Server do :db_hostname, :db_port, :db_name, + :db_auth_source, :db_username, :db_password_input, :db_ssl_mode, diff --git a/lib/query_canary_web/components/sql_editor.ex b/lib/query_canary_web/components/sql_editor.ex index d4cff9e..9eaedf8 100644 --- a/lib/query_canary_web/components/sql_editor.ex +++ b/lib/query_canary_web/components/sql_editor.ex @@ -11,6 +11,10 @@ defmodule QueryCanaryWeb.Components.SQLEditor do def render(assigns) do ~H"""
+

+ Enter a JSON find, count, distinct or aggregate command. + MongoDB query examples +

SQL Query

-
{@check.query}
+
{@check.query}
diff --git a/lib/query_canary_web/live/docs_live.ex b/lib/query_canary_web/live/docs_live.ex index eea6659..9697b30 100644 --- a/lib/query_canary_web/live/docs_live.ex +++ b/lib/query_canary_web/live/docs_live.ex @@ -30,6 +30,7 @@ defmodule QueryCanaryWeb.DocsLive do
  • <.link navigate={~p"/docs/servers/postgresql"}>PostgreSQL
  • <.link navigate={~p"/docs/servers/mysql"}>MySQL
  • <.link navigate={~p"/docs/servers/clickhouse"}>ClickHouse
  • +
  • <.link navigate={~p"/docs/servers/mongodb"}>MongoDB
  • <.link navigate={~p"/docs/servers/ssh-tunnel"}>SSH Tunnel
  • diff --git a/lib/query_canary_web/live/home_live.ex b/lib/query_canary_web/live/home_live.ex index 2b9030b..b29b435 100644 --- a/lib/query_canary_web/live/home_live.ex +++ b/lib/query_canary_web/live/home_live.ex @@ -346,13 +346,16 @@ defmodule QueryCanaryWeb.HomeLive do
    Coming Soon
    -
    + <.link + navigate={~p"/docs/servers/mongodb"} + class="card bg-base-200 border border-base-300 shadow-sm hover:shadow-md transition-shadow" + >
    MongoDB

    MongoDB

    -
    Coming Soon
    +
    Supported
    -
    + diff --git a/lib/query_canary_web/live/metric_live/form.ex b/lib/query_canary_web/live/metric_live/form.ex index 71ac0f5..3534665 100644 --- a/lib/query_canary_web/live/metric_live/form.ex +++ b/lib/query_canary_web/live/metric_live/form.ex @@ -43,7 +43,17 @@ defmodule QueryCanaryWeb.MetricLive.Form do <.form for={@changeset} as={:metric} phx-change="validate" phx-submit="save"> <.input name="metric[name]" value={@changeset.data.name} label="Name" /> - <.input name="metric[sql]" type="textarea" value={@changeset.data.sql} label="SQL" rows="6" /> + <.input + name="metric[sql]" + type="textarea" + value={@changeset.data.sql} + label="Query (SQL or MongoDB JSON)" + rows="6" + /> +

    + MongoDB queries can use "$1" and "$2" as date parameters for the metric window. + MongoDB examples +

    <.input name="metric[granularity]" type="select" diff --git a/lib/query_canary_web/live/quickstart/check_live.ex b/lib/query_canary_web/live/quickstart/check_live.ex index 0062d5b..fb3827a 100644 --- a/lib/query_canary_web/live/quickstart/check_live.ex +++ b/lib/query_canary_web/live/quickstart/check_live.ex @@ -17,7 +17,7 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do

    2. Configure a Data Integrity Check

    - Write a SQL query that returns a number or boolean. This will be monitored for unexpected values or anomalies. + Write a query that returns a number or boolean. This will be monitored for unexpected values or anomalies.

    <.form for={@form} id="check-form" phx-change="validate" phx-submit="save" class="">
    @@ -32,7 +32,9 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do
    - + <.live_component module={QueryCanaryWeb.Components.SQLEditor} id="check-sql-editor" @@ -82,6 +84,7 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do check = %Check{ user_id: socket.assigns.current_scope.user.id, server_id: server.id, + query: if(server.db_engine == "mongodb", do: ~s({"count": "your_collection", "query": {}})), schedule: "0 8 * * *" } diff --git a/lib/query_canary_web/live/quickstart/database_live.ex b/lib/query_canary_web/live/quickstart/database_live.ex index aa92a3a..b221f97 100644 --- a/lib/query_canary_web/live/quickstart/database_live.ex +++ b/lib/query_canary_web/live/quickstart/database_live.ex @@ -127,13 +127,23 @@ defmodule QueryCanaryWeb.Quickstart.DatabaseLive do -
    @@ -165,6 +175,14 @@ defmodule QueryCanaryWeb.Quickstart.DatabaseLive do > ClickHouse Setup Documentation <.icon name="hero-arrow-right" /> + <.link + :if={Phoenix.HTML.Form.input_value(@form, :db_engine) == "mongodb"} + class="link link-hover text-lg link-info" + target="_blank" + navigate={~p"/docs/servers/mongodb"} + > + MongoDB Setup Documentation <.icon name="hero-arrow-right" /> +
    @@ -181,7 +199,7 @@ defmodule QueryCanaryWeb.Quickstart.DatabaseLive do
    <.input field={@form[:db_hostname]} type="text" label="Hostname" />
    - <.input field={@form[:db_port]} type="number" label="Port" value="5432" /> + <.input field={@form[:db_port]} type="number" label="Port" /> <.input field={@form[:db_username]} type="text" label="Username" /> <.input field={@form[:db_password_input]} @@ -190,6 +208,34 @@ defmodule QueryCanaryWeb.Quickstart.DatabaseLive do placeholder={password_placeholder(@form, :db_password)} /> <.input field={@form[:db_name]} type="text" label="Database" /> + <.input + :if={Phoenix.HTML.Form.input_value(@form, :db_engine) == "mongodb"} + field={@form[:db_auth_source]} + type="text" + label="Authentication Database" + placeholder="Defaults to Database; use admin for Atlas or admin users" + /> + <.input + :if={Phoenix.HTML.Form.input_value(@form, :db_engine) == "mongodb"} + field={@form[:db_ssl_mode]} + type="select" + label="TLS" + options={[ + {"Disabled", "disable"}, + {"Required", "require"}, + {"Verify server certificate", "verify-full"} + ]} + /> +
    + <.input + field={@form[:db_ssl_ca_cert]} + type="textarea" + label="CA Certificate (PEM, optional)" + /> +
    <.input field={@form[:ssh_tunnel]} type="checkbox" label="Use SSH Tunnel?" /> @@ -268,7 +314,7 @@ defmodule QueryCanaryWeb.Quickstart.DatabaseLive do @impl true def mount(_params, _session, socket) do - server = %Server{user_id: socket.assigns.current_scope.user.id} + server = %Server{user_id: socket.assigns.current_scope.user.id, db_port: 5432} # Generate SSH keys when the component mounts # These will be stored in the session and used when saving @@ -325,6 +371,15 @@ defmodule QueryCanaryWeb.Quickstart.DatabaseLive do @impl true def handle_event("validate", %{"server" => server_params}, socket) do + previous_engine = Phoenix.HTML.Form.input_value(socket.assigns.form, :db_engine) + + server_params = + if server_params["db_engine"] == "mongodb" and previous_engine != "mongodb" do + Map.merge(server_params, %{"db_port" => "27017", "db_ssl_mode" => "disable"}) + else + server_params + end + changeset = Servers.change_server( socket.assigns.current_scope, diff --git a/lib/query_canary_web/live/report_live/metric_details_component.ex b/lib/query_canary_web/live/report_live/metric_details_component.ex index 79e64e2..b667d06 100644 --- a/lib/query_canary_web/live/report_live/metric_details_component.ex +++ b/lib/query_canary_web/live/report_live/metric_details_component.ex @@ -157,7 +157,7 @@ defmodule QueryCanaryWeb.ReportLive.MetricDetailsComponent do rows="3" />
    - + <%= if server = selected_metric_editor_server( @servers, @selected_metric_form, @@ -228,7 +228,7 @@ defmodule QueryCanaryWeb.ReportLive.MetricDetailsComponent do
    -
    SQL
    +
    Query
    {metric_sql(@selected_metric.metric)}
    diff --git a/lib/query_canary_web/live/report_live/new_metric_component.ex b/lib/query_canary_web/live/report_live/new_metric_component.ex index acd211d..b3f2c11 100644 --- a/lib/query_canary_web/live/report_live/new_metric_component.ex +++ b/lib/query_canary_web/live/report_live/new_metric_component.ex @@ -72,7 +72,12 @@ defmodule QueryCanaryWeb.ReportLive.NewMetricComponent do label="Description" rows="2" /> - <.input field={@new_metric_form[:sql]} type="textarea" label="SQL" rows="6" /> + <.input + field={@new_metric_form[:sql]} + type="textarea" + label="Query (SQL or MongoDB JSON)" + rows="6" + />
    <.input field={@new_metric_form[:granularity]} diff --git a/lib/query_canary_web/live/server_live/form.ex b/lib/query_canary_web/live/server_live/form.ex index 04ed690..09d8ba1 100644 --- a/lib/query_canary_web/live/server_live/form.ex +++ b/lib/query_canary_web/live/server_live/form.ex @@ -31,6 +31,7 @@ defmodule QueryCanaryWeb.ServerLive.Form do PostgreSQL: "postgresql", MySQL: "mysql", ClickHouse: "clickhouse", + MongoDB: "mongodb", Prometheus: "prometheus" ]} label="Engine" @@ -47,6 +48,13 @@ defmodule QueryCanaryWeb.ServerLive.Form do placeholder={password_placeholder(@form, :db_password)} /> <.input field={@form[:db_name]} type="text" label="Database" /> + <.input + :if={Phoenix.HTML.Form.input_value(@form, :db_engine) == "mongodb"} + field={@form[:db_auth_source]} + type="text" + label="Authentication Database" + placeholder="Defaults to Database; use admin for Atlas or admin users" + />
    <.input field={@form[:ssh_tunnel]} type="checkbox" label="Use SSH Tunnel?" /> @@ -63,10 +71,7 @@ defmodule QueryCanaryWeb.ServerLive.Form do <.input field={@form[:ssh_username]} type="text" label="SSH Username" />
    -
    +

    SSL Connection Configuration

    @@ -74,17 +79,27 @@ defmodule QueryCanaryWeb.ServerLive.Form do field={@form[:db_ssl_mode]} type="select" label="SSL Mode" - options={ - [ - {"disable - Don't allow a SSL connection", "disable"}, - {"allow - Use SSL if the server requires it", "allow"}, - {"prefer - Try SSL but allow falling back to non-SSL", "prefer"}, - {"require - Force SSL, don't allow connection without", "require"}, - {"verify-ca - Force SSL, and verify the server has a valid certificate", - "verify-ca"} - # {"verify-full - Force SSL, and verify the server has a specific SSL certificate", "verify-full"} - ] - } + options={[ + {"disable - Don't allow a SSL connection", "disable"}, + {"allow - Use SSL if the server requires it", "allow"}, + {"prefer - Try SSL but allow falling back to non-SSL", "prefer"}, + {"require - Force SSL, don't allow connection without", "require"}, + {"verify-ca - Force SSL, and verify the server has a valid certificate", + "verify-ca"}, + {"verify-full - Verify the server certificate and hostname", "verify-full"} + ]} + /> +

    + MongoDB uses plain TCP with allow or prefer. Choose require or verify-full for TLS. +

    + <.input + :if={Phoenix.HTML.Form.input_value(@form, :db_engine) == "mongodb"} + field={@form[:db_ssl_ca_cert]} + type="textarea" + label="CA Certificate (PEM, optional)" />
    <%!--
    @@ -163,6 +178,15 @@ defmodule QueryCanaryWeb.ServerLive.Form do @impl true def handle_event("validate", %{"server" => server_params}, socket) do + previous_engine = Phoenix.HTML.Form.input_value(socket.assigns.form, :db_engine) + + server_params = + if server_params["db_engine"] == "mongodb" and previous_engine != "mongodb" do + Map.merge(server_params, %{"db_port" => "27017", "db_ssl_mode" => "disable"}) + else + server_params + end + changeset = Servers.change_server(socket.assigns.current_scope, socket.assigns.server, server_params) diff --git a/mix.exs b/mix.exs index 1e9a51f..c7840c5 100644 --- a/mix.exs +++ b/mix.exs @@ -66,6 +66,7 @@ defmodule QueryCanary.MixProject do {:myxql, "~> 0.7.1"}, {:exqlite, "~> 0.30.1"}, {:ch, "~> 0.3.2"}, + {:mongodb_driver, "~> 1.6"}, # Scheduling {:crontab, "~> 1.1"}, {:oban, "~> 2.19"}, diff --git a/mix.lock b/mix.lock index aa99363..ee262de 100644 --- a/mix.lock +++ b/mix.lock @@ -34,6 +34,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mongodb_driver": {:hex, :mongodb_driver, "1.6.4", "d7a36f4083ff1a91b65eba86fdfacaaa2870aa791bfe226ff2af9d28c90a6dcf", [:mix], [{:db_connection, "~> 2.6", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, ">= 2.1.1 and < 3.0.0-0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ezstd, "~> 1.1", [hex: :ezstd, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bcac423730223e5dbffbc8397f9131e03eec1ad53a2d809c1395882bb43e597c"}, "myxql": {:hex, :myxql, "0.7.1", "7c7b75aa82227cd2bc9b7fbd4de774fb19a1cdb309c219f411f82ca8860f8e01", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a491cdff53353a09b5850ac2d472816ebe19f76c30b0d36a43317a67c9004936"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "0.3.2", "d4fa4056ade0ae33b5a9eb64554a1b3779689282e37513260125d2d6b32e4874", [:mix], [], "hexpm", "28b9a9f4094fda1aa8ca72f732ff3223eb54aa3eda4fed9022254de2c152b138"}, diff --git a/priv/docs/overview.md b/priv/docs/overview.md index 4a7cd0e..3db58a5 100644 --- a/priv/docs/overview.md +++ b/priv/docs/overview.md @@ -33,7 +33,7 @@ QueryCanary helps you recognize that by letting you: ## How It Works ### 1. Connect your database -We currently support [Postgres](/docs/servers/postgresql/) / [MySQL](/docs/servers/mysql/) + [SSH tunneling](/docs/servers/ssh-tunnel/). Our Quickstart will walk you through setting up a read only user. +We support [Postgres](/docs/servers/postgresql/), [MySQL](/docs/servers/mysql/), [ClickHouse](/docs/servers/clickhouse/) and [MongoDB](/docs/servers/mongodb/) with [SSH tunneling](/docs/servers/ssh-tunnel/). MongoDB checks use JSON commands. Our Quickstart will walk you through setting up a read only user. More database engines will be introduced as requested, if you need one, please email support@querycanary.com diff --git a/priv/docs/servers/mongodb.md b/priv/docs/servers/mongodb.md new file mode 100644 index 0000000..9313e03 --- /dev/null +++ b/priv/docs/servers/mongodb.md @@ -0,0 +1,100 @@ +--- +title: Using MongoDB with QueryCanary +--- +QueryCanary supports MongoDB checks and metrics using JSON queries, with direct connections or SSH tunnels. + +## Connect a database + +Create a dedicated user with the `read` role on the database you want to monitor: + +```javascript +use your_database +db.createUser({ + user: "querycanary_reader", + pwd: "your_secure_password", + roles: [{role: "read", db: "your_database"}] +}) +``` + +Select **MongoDB** in Quickstart or the server form, then enter: + +- **Hostname**: a MongoDB server hostname (without `mongodb://`). +- **Port**: `27017` by default. +- **Database**: the database to query. +- **Username / Password**: your monitoring user's credentials. +- **Authentication Database**: the database where the user was created. Leave blank to use Database. For Atlas users or users created in `admin`, enter `admin`. +- **TLS / SSL Mode**: disable for a local server without TLS; use `verify-full` for Atlas or a server with a trusted TLS certificate. `require` enables TLS without certificate verification. MongoDB does not negotiate TLS automatically: `allow` and `prefer` use plain TCP. + +For Atlas, use an individual server hostname from the cluster's standard connection string and allow QueryCanary's IP in the network access list. The hostname field does not accept connection strings or SRV addresses. Direct connections discover replica set members; with SSH tunneling, QueryCanary connects only to the specified host through the tunnel. + +## Write a check + +Queries are JSON objects, using double quotes for keys and strings. JavaScript shell expressions such as `db.users.countDocuments()` are not supported. + +Count matching documents (returns one row with a numeric `value`, including zero): + +```json +{"count": "users", "query": {"active": true}} +``` + +Aggregate a numeric value: + +```json +{ + "aggregate": "orders", + "pipeline": [ + {"$match": {"status": "paid"}}, + {"$group": {"_id": null, "value": {"$sum": "$total"}}}, + {"$project": {"_id": 0, "value": 1}} + ] +} +``` + +Find documents with filtering, projection, sorting and a limit: + +```json +{ + "find": "orders", + "filter": {"total": {"$gt": 100}}, + "projection": {"_id": 0, "total": 1}, + "sort": {"total": -1}, + "limit": 10 +} +``` + +Distinct values return a row with a `value` for each match: + +```json +{"distinct": "orders", "key": "status", "query": {}} +``` + +Supported query options include `limit`, `skip`, `sort`, `projection`, `batchSize`, `maxTimeMS`, `allowDiskUse`, `collation`, `hint` and `comment` where applicable. Aggregations also accept `cursor.batchSize`. All cursor batches are consumed. Collection fields in the saved schema are inferred from up to 100 documents per collection. + +## Dates and object IDs + +Use Extended JSON wrappers to query BSON types: + +```json +{ + "find": "orders", + "filter": { + "_id": {"$oid": "507f1f77bcf86cd799439011"}, + "created_at": {"$gte": {"$date": "2025-01-01T00:00:00Z"}} + } +} +``` + +`$date` accepts an ISO 8601 timestamp or integer milliseconds since the Unix epoch. `$numberDecimal` and `$numberLong` wrappers are also supported. Results convert object IDs, dates and decimals to strings for display and storage, while ordinary numeric and boolean values retain their types. + +## Metrics with time windows + +In a MongoDB metric query, the whole strings `"$1"` and `"$2"` are replaced with BSON dates for the beginning and end of the metric window. They are substituted as values rather than interpolated into the JSON text. + +```json +{ + "count": "users", + "query": {"created_at": {"$gte": "$1", "$lt": "$2"}} +} +``` + +For aggregation metrics, project a single numeric field named `value`. Empty aggregations return no rows; use a count query when a zero is required for an empty collection. diff --git a/priv/repo/migrations/20261001000000_add_mongodb_auth_source.exs b/priv/repo/migrations/20261001000000_add_mongodb_auth_source.exs new file mode 100644 index 0000000..b5e772e --- /dev/null +++ b/priv/repo/migrations/20261001000000_add_mongodb_auth_source.exs @@ -0,0 +1,9 @@ +defmodule QueryCanary.Repo.Migrations.AddMongodbAuthSource do + use Ecto.Migration + + def change do + alter table(:servers) do + add :db_auth_source, :string + end + end +end diff --git a/test/query_canary/connections/mongodb_test.exs b/test/query_canary/connections/mongodb_test.exs new file mode 100644 index 0000000..318772c --- /dev/null +++ b/test/query_canary/connections/mongodb_test.exs @@ -0,0 +1,201 @@ +defmodule QueryCanary.Connections.Adapters.MongoDBTest do + use ExUnit.Case, async: true + alias QueryCanary.Connections.Adapters.MongoDB + + test "rejects invalid JSON and non-object queries before accessing the database" do + for query <- ["db.users.find({})", "[]", "null", "42"] do + assert {:error, message} = MongoDB.query(nil, query) + assert is_binary(message) + end + + assert {:error, _} = MongoDB.query(nil, ~s({"delete": "users"})) + end + + test "decodes typed values and whole-string parameters without interpolating text" do + query = + ~s({"find":"users","filter":{"created_at":{"$gte":"$1","$lt":"$2"},"name":"contains $1","id":{"$oid":"507f1f77bcf86cd799439011"},"date":{"$date":"2025-01-01T00:00:00Z"},"amount":{"$numberDecimal":"12.50"}}}) + + from = ~U[2025-01-01 00:00:00Z] + to = ~U[2025-01-02 00:00:00Z] + assert {:ok, %{"filter" => filter}} = MongoDB.parse_query(query, [from, to]) + assert filter["created_at"] == %{"$gte" => from, "$lt" => to} + assert filter["name"] == "contains $1" + assert filter["id"] == BSON.ObjectId.decode!("507f1f77bcf86cd799439011") + assert filter["date"] == from + assert filter["amount"] == Decimal.new("12.50") + end + + test "returns errors for missing parameters or invalid typed values" do + for query <- [ + ~s({"count":"users","query":{"date":"$1"}}), + ~s({"find":"users","filter":{"id":{"$oid":"invalid"}}}), + ~s({"find":"users","filter":{"date":{"$date":"invalid"}}}) + ] do + assert {:error, _} = MongoDB.query(nil, query) + end + end + + test "normalizes nested BSON results without interning database field names" do + id = BSON.ObjectId.decode!("507f1f77bcf86cd799439011") + + result = + MongoDB.format_results([ + %{ + "_id" => id, + "value" => 10, + "nested" => [ + %{ + "date" => ~U[2025-01-01 00:00:00Z], + "amount" => Decimal.new("12.50"), + "binary" => %BSON.Binary{binary: <<1, 2>>} + } + ] + }, + %{"other" => true} + ]) + + assert result.columns == ["value", "_id", "nested", "other"] + assert result.num_rows == 2 + + assert [ + %{ + "_id" => "507f1f77bcf86cd799439011", + "value" => 10, + "nested" => [ + %{"date" => "2025-01-01T00:00:00Z", "amount" => "12.50", "binary" => "AQI="} + ] + }, + %{"other" => true} + ] = result.rows + + assert Jason.encode!(result.rows) + assert %{rows: [], columns: [], num_rows: 0} = MongoDB.format_results([]) + end +end + +defmodule QueryCanary.Connections.Adapters.MongoDBIntegrationTest do + use QueryCanary.DataCase, async: false + alias QueryCanary.Connections.Adapters.MongoDB + alias QueryCanary.Connections.{ConnectionManager, ConnectionServer, ConnectionTester} + import QueryCanary.AccountsFixtures + import QueryCanary.ServersFixtures + import QueryCanary.ChecksFixtures + import QueryCanary.MetricsFixtures + + @moduletag :database_adapters + @details %{ + hostname: "localhost", + port: 27017, + database: "test_db", + username: "test_user", + password: "test_pass", + auth_source: "admin", + ssl_mode: "disable" + } + + setup do + assert {:ok, conn} = MongoDB.connect(@details) + on_exit(fn -> MongoDB.disconnect(conn) end) + %{conn: conn} + end + + test "counts documents, including an empty result", %{conn: conn} do + assert {:ok, %{rows: [%{"value" => 2}], columns: ["value"]}} = + MongoDB.query(conn, ~s({"count":"numbers","query":{"value":{"$gte":20}}})) + + assert {:ok, %{rows: [%{"value" => 0}]}} = + MongoDB.query(conn, ~s({"count":"numbers","query":{"value":{"$gt":100}}})) + end + + test "finds with projection and sorting and consumes multiple cursor batches", %{conn: conn} do + assert {:ok, %{rows: [%{"value" => 30}, %{"value" => 20}, %{"value" => 10}], num_rows: 3}} = + MongoDB.query( + conn, + ~s({"find":"numbers","projection":{"_id":0,"value":1},"sort":{"value":-1},"batchSize":1}) + ) + + assert {:ok, %{rows: [], num_rows: 0}} = + MongoDB.query(conn, ~s({"find":"numbers","filter":{"value":100}})) + end + + test "aggregates, distinguishes values and converts BSON dates", %{conn: conn} do + assert {:ok, %{rows: [%{"_id" => nil, "value" => 60}], columns: ["value", "_id"]}} = + MongoDB.query( + conn, + ~s({"aggregate":"numbers","pipeline":[{"$group":{"_id":null,"value":{"$sum":"$value"}}}],"cursor":{"batchSize":1}}) + ) + + assert {:ok, %{rows: rows}} = MongoDB.query(conn, ~s({"distinct":"numbers","key":"value"})) + assert Enum.sort(Enum.map(rows, & &1["value"])) == [10, 20, 30] + + assert {:ok, %{rows: [%{"_id" => "507f1f77bcf86cd799439011", "created_at" => date}]}} = + MongoDB.query( + conn, + ~s({"find":"numbers","filter":{"_id":{"$oid":"507f1f77bcf86cd799439011"}},"projection":{"created_at":1}}) + ) + + assert date == "2025-01-01T12:00:00.000Z" + end + + test "discovers collection fields", %{conn: conn} do + assert {:ok, tables} = MongoDB.list_tables(conn) + assert "numbers" in tables + assert {:ok, %{"numbers" => fields}} = MongoDB.get_database_schema(conn, "test_db") + assert Enum.any?(fields, &(&1.label == "created_at" and &1.detail == "date")) + assert Enum.any?(fields, &(&1.label == "value" and &1.detail == "integer")) + end + + test "reports database errors without terminating the connection", %{conn: conn} do + assert {:error, message} = + MongoDB.query(conn, ~s({"aggregate":"numbers","pipeline":[{"$invalid":{}}]})) + + assert is_binary(message) + assert {:ok, _} = MongoDB.query(conn, ~s({"ping":1})) + end + + test "rejects invalid credentials during connection validation" do + assert {:error, reason} = MongoDB.connect(%{@details | password: "incorrect"}) + assert is_binary(reason) + end + + test "connection manager, diagnostics, scheduled checks and metric windows" do + scope = user_scope_fixture() + + server = + server_fixture(scope, %{ + db_engine: "mongodb", + db_hostname: "127.0.0.1", + db_port: 27017, + db_username: "test_user", + db_password_input: "test_pass", + db_auth_source: "admin", + db_ssl_mode: "disable" + }) + + on_exit(fn -> ConnectionServer.invalidate(server.id) end) + + assert {:ok, _} = ConnectionManager.test_connection(server) + assert {:ok, %{version: version}} = ConnectionTester.diagnose_connection(server) + assert version != "Unknown version" + + check = check_fixture(scope, %{server_id: server.id, query: ~s({"count":"numbers"})}) + assert {:ok, result} = QueryCanary.Checks.run_check(check) + assert result.success + assert result.result == [%{"value" => 3}] + + metric = + metric_fixture(scope, %{ + server: server, + sql: ~s({"count":"numbers","query":{"created_at":{"$gte":"$1","$lt":"$2"}}}) + }) + + assert {:ok, result} = + QueryCanary.Metrics.run_metric_range( + metric, + ~U[2025-01-01 00:00:00Z], + ~U[2025-01-03 00:00:00Z] + ) + + assert Decimal.equal?(result.value, Decimal.new(2)) + end +end diff --git a/test/query_canary_web/live/mongodb_quickstart_test.exs b/test/query_canary_web/live/mongodb_quickstart_test.exs new file mode 100644 index 0000000..f561438 --- /dev/null +++ b/test/query_canary_web/live/mongodb_quickstart_test.exs @@ -0,0 +1,23 @@ +defmodule QueryCanaryWeb.MongoDBQuickstartTest do + use QueryCanaryWeb.ConnCase + import Phoenix.LiveViewTest + import QueryCanary.ServersFixtures + setup :register_and_log_in_user + + test "quickstart enables MongoDB and sets connection defaults", %{conn: conn} do + {:ok, view, _} = live(conn, ~p"/quickstart") + assert has_element?(view, "input[type=radio][value=mongodb]:not([disabled])") + view |> form("#server-form", server: %{db_engine: "mongodb"}) |> render_change() + assert has_element?(view, "#server_db_port[value='27017']") + assert has_element?(view, "#server_db_auth_source") + assert has_element?(view, "a[href='/docs/servers/mongodb']") + end + + test "check editor uses JSON and starts with a count query", %{conn: conn, scope: scope} do + server = server_fixture(scope, %{db_engine: "mongodb", db_port: 27017}) + {:ok, view, html} = live(conn, ~p"/quickstart/check?server_id=#{server.id}") + assert html =~ "MongoDB JSON Query" + assert has_element?(view, "[phx-hook=SQLEditor][data-dialect=mongodb]") + assert has_element?(view, "input[name='check[query]'][value*='count']") + end +end diff --git a/test/query_canary_web/live/server_live_test.exs b/test/query_canary_web/live/server_live_test.exs index 8f88428..ac6a0d2 100644 --- a/test/query_canary_web/live/server_live_test.exs +++ b/test/query_canary_web/live/server_live_test.exs @@ -19,6 +19,39 @@ defmodule QueryCanaryWeb.ServerLiveTest do setup :register_and_log_in_user + test "creates a MongoDB server with its authentication database", %{conn: conn, scope: scope} do + {:ok, view, _} = live(conn, ~p"/servers/new") + assert has_element?(view, "#server_db_engine option[value=mongodb]", "MongoDB") + + view + |> form("#server-form", server: %{db_engine: "mongodb"}) + |> render_change() + + assert has_element?(view, "#server_db_port[value='27017']") + assert has_element?(view, "#server_db_auth_source") + + view + |> form("#server-form", + server: %{ + name: "MongoDB metrics", + db_engine: "mongodb", + db_hostname: "localhost", + db_port: 27017, + db_name: "analytics", + db_auth_source: "admin", + db_username: "reader", + db_password_input: "test_password", + db_ssl_mode: "disable" + } + ) + |> render_submit() + + assert_redirect(view, ~p"/servers") + + assert [%{db_engine: "mongodb", db_port: 27017, db_auth_source: "admin"}] = + QueryCanary.Servers.list_servers(scope) + end + defp create_server(%{scope: scope}) do server = server_fixture(scope) %{server: server} diff --git a/test/support/adapters/mongodb-seed.js b/test/support/adapters/mongodb-seed.js new file mode 100644 index 0000000..98b0d27 --- /dev/null +++ b/test/support/adapters/mongodb-seed.js @@ -0,0 +1,7 @@ +const database = db.getSiblingDB("test_db"); +database.numbers.deleteMany({}); +database.numbers.insertMany([ + {_id: ObjectId("507f1f77bcf86cd799439011"), value: 10, created_at: ISODate("2025-01-01T12:00:00Z"), nested: {enabled: true}}, + {_id: ObjectId("507f1f77bcf86cd799439012"), value: 20, created_at: ISODate("2025-01-02T12:00:00Z"), nested: {enabled: false}}, + {_id: ObjectId("507f1f77bcf86cd799439013"), value: 30, created_at: ISODate("2025-01-03T12:00:00Z"), amount: NumberDecimal("12.50")} +]);