From 42a177f5a405695af3b255806b162ceaebf61f4f Mon Sep 17 00:00:00 2001 From: Tyler Barker Date: Mon, 29 Dec 2025 17:19:06 +1100 Subject: [PATCH 1/3] Align API with Ecto Repo conventions (breaking change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a breaking change that simplifies the API to match Ecto.Repo.all/2 and Ecto.Repo.one/2 semantics. Changes: - query_all/4 now returns list directly (was {:ok, list}), raises on errors - query_all!/4 removed (use query_all/4 instead) - query_one/4 now returns result or nil (was {:ok, result | nil}) - query_one/4 now raises MultipleResultsError on multiple results - File-based API (use SqlKit) follows the same changes Also: - Add sobelow_skip comments for false positive SQL injection warnings - Update all documentation to reflect new API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 + CHANGELOG.md | 51 +++++++- CLAUDE.md | 32 +++--- README.md | 155 ++++++++++++------------- lib/sql_kit.ex | 137 ++++++++-------------- lib/sql_kit/duckdb.ex | 9 +- lib/sql_kit/duckdb/pool.ex | 8 +- lib/sql_kit/query.ex | 48 +++----- mix.exs | 2 +- test/sql_kit/duckdb_test.exs | 115 +++++++++---------- test/sql_kit_test.exs | 217 ++++++++++++++--------------------- 11 files changed, 360 insertions(+), 418 deletions(-) diff --git a/.gitignore b/.gitignore index a84f98c..de5dd2f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,10 @@ erl_crash.dump # Ignore package tarball (built via "mix hex.build"). sql_kit-*.tar +# SQLite / DuckDB +*.db +*.duckdb + # General .DS_Store __MACOSX/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e459a04..2285fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,55 @@ # Changelog -## Unreleased +## 0.2.0 + +### Breaking Changes + +- **API aligned with Ecto Repo conventions** - This is a breaking change that simplifies the API to match [`Ecto.Repo.all/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:all/2) and [`Ecto.Repo.one/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:one/2) semantics. + + **`query_all/4` now returns list directly** (was `{:ok, list}`) + ```elixir + # Before + {:ok, users} = SqlKit.query_all(Repo, "SELECT * FROM users") + + # After + users = SqlKit.query_all(Repo, "SELECT * FROM users") + ``` + + **`query_all!/4` removed** (use `query_all/4` instead) + ```elixir + # Before + users = SqlKit.query_all!(Repo, "SELECT * FROM users") + + # After + users = SqlKit.query_all(Repo, "SELECT * FROM users") + ``` + + **`query_one/4` now returns result or nil directly** (was `{:ok, result | nil}`) + ```elixir + # Before + {:ok, user} = SqlKit.query_one(Repo, "SELECT * FROM users WHERE id = $1", [1]) + {:ok, nil} = SqlKit.query_one(Repo, "SELECT * FROM users WHERE id = $1", [999]) + + # After + user = SqlKit.query_one(Repo, "SELECT * FROM users WHERE id = $1", [1]) + nil = SqlKit.query_one(Repo, "SELECT * FROM users WHERE id = $1", [999]) + ``` + + **`query_one/4` now raises on multiple results** (was `{:error, MultipleResultsError}`) + ```elixir + # Before + {:error, %SqlKit.MultipleResultsError{}} = + SqlKit.query_one(Repo, "SELECT * FROM users") + + # After + # Raises SqlKit.MultipleResultsError + SqlKit.query_one(Repo, "SELECT * FROM users") + ``` + + **File-based API follows same changes:** + - `MyModule.query_all/3` returns list directly + - `MyModule.query_all!/3` removed + - `MyModule.query_one/3` returns result or nil, raises on multiple ### Added diff --git a/CLAUDE.md b/CLAUDE.md index ade4802..c4b2d1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,18 +54,20 @@ test/ ```elixir # Execute SQL strings directly with any Ecto repo -SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21]) +SqlKit.query_all(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21]) # => [%{id: 1, name: "Alice", age: 30}, ...] SqlKit.query_one!(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1]) # => %{id: 1, name: "Alice"} -SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users", [], as: User) +SqlKit.query_all(MyApp.Repo, "SELECT * FROM users", [], as: User) # => [%User{id: 1, name: "Alice"}, ...] -# Non-bang variants -SqlKit.query_all(repo, sql, params, opts) # => {:ok, results} | {:error, reason} -SqlKit.query_one(repo, sql, params, opts) # => {:ok, result | nil} | {:error, reason} +# query_all returns list directly, raises on errors (matches Ecto.Repo.all/2) +SqlKit.query_all(repo, sql, params, opts) # => [results] + +# query_one returns result or nil, raises on errors/multiple (matches Ecto.Repo.one/2) +SqlKit.query_one(repo, sql, params, opts) # => result | nil # Aliases for query_one SqlKit.query!(repo, sql, params, opts) @@ -95,14 +97,12 @@ end # Usage (same API for both) MyApp.Reports.SQL.query!("stats.sql", [id]) # single row (alias for query_one!) -MyApp.Reports.SQL.query_one!("stats.sql", [id]) # single row -MyApp.Reports.SQL.query_all!("activity.sql", [id], as: Activity) # all rows as structs +MyApp.Reports.SQL.query_one!("stats.sql", [id]) # single row (raises if no results) +MyApp.Reports.SQL.query_one("stats.sql", [id]) # single row or nil +MyApp.Reports.SQL.query_all("activity.sql", [id], as: Activity) # all rows as structs MyApp.Reports.SQL.load!("stats.sql") # just get SQL string -# Non-bang variants return {:ok, result} | {:error, reason} -MyApp.Reports.SQL.query("stats.sql", [id]) -MyApp.Reports.SQL.query_one("stats.sql", [id]) -MyApp.Reports.SQL.query_all("activity.sql", [id]) +# Non-bang load returns {:ok, result} | {:error, reason} MyApp.Reports.SQL.load("stats.sql") ``` @@ -136,7 +136,7 @@ DuckDB is unique - it's not an Ecto adapter but a direct NIF driver. SqlKit prov ```elixir # Direct connection (BYO) {:ok, conn} = SqlKit.DuckDB.connect(":memory:") -SqlKit.query_all!(conn, "SELECT * FROM users", []) +SqlKit.query_all(conn, "SELECT * FROM users", []) SqlKit.DuckDB.disconnect(conn) # Pooled connection (recommended for production) @@ -149,7 +149,7 @@ SqlKit.DuckDB.disconnect(conn) # Then use the pool: {:ok, pool} = SqlKit.DuckDB.Pool.start_link(name: MyPool, database: ":memory:") -SqlKit.query_all!(pool, "SELECT * FROM events", []) +SqlKit.query_all(pool, "SELECT * FROM events", []) # File-based SQL with DuckDB (use :backend instead of :repo) defmodule MyApp.Analytics.SQL do @@ -160,7 +160,7 @@ defmodule MyApp.Analytics.SQL do files: ["daily_summary.sql"] end -MyApp.Analytics.SQL.query_all!("daily_summary.sql", [~D[2024-01-01]]) +MyApp.Analytics.SQL.query_all("daily_summary.sql", [~D[2024-01-01]]) ``` Key differences from Ecto-based databases: @@ -177,8 +177,8 @@ Key differences from Ecto-based databases: ```elixir # Caching is enabled by default -SqlKit.query_all!(pool, "SELECT * FROM events WHERE id = $1", [1]) -SqlKit.query_all!(pool, "SELECT * FROM events WHERE id = $1", [2]) # uses cached statement +SqlKit.query_all(pool, "SELECT * FROM events WHERE id = $1", [1]) +SqlKit.query_all(pool, "SELECT * FROM events WHERE id = $1", [2]) # uses cached statement # Disable caching for specific queries SqlKit.DuckDB.Pool.query!(pool, sql, params, cache: false) diff --git a/README.md b/README.md index 967e1cd..a4f63aa 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Add `sql_kit` to your dependencies in `mix.exs`: ```elixir def deps do [ - {:sql_kit, "~> 0.1.0"} + {:sql_kit, "~> 0.2.0"} ] end ``` @@ -55,7 +55,7 @@ For DuckDB support, also add `duckdbex`: ```elixir def deps do [ - {:sql_kit, "~> 0.1.0"}, + {:sql_kit, "~> 0.2.0"}, {:duckdbex, "~> 0.3"} ] end @@ -69,23 +69,26 @@ Execute SQL strings directly with any Ecto repo: ```elixir # Get all rows as a list of maps -SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21]) +SqlKit.query_all(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21]) # => [%{id: 1, name: "Alice", age: 30}, %{id: 2, name: "Bob", age: 25}] -# Get a single row +# Get a single row (raises if no results) SqlKit.query_one!(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1]) # => %{id: 1, name: "Alice", age: 30} +# Get a single row or nil (raises on multiple results) +SqlKit.query_one(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1]) +# => %{id: 1, name: "Alice", age: 30} + +SqlKit.query_one(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [999]) +# => nil + # Cast results to structs -SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users", [], as: User) +SqlKit.query_all(MyApp.Repo, "SELECT * FROM users", [], as: User) # => [%User{id: 1, name: "Alice", age: 30}, ...] -# Non-bang variants return {:ok, result} or {:error, reason} -SqlKit.query_one(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1]) -# => {:ok, %{id: 1, name: "Alice"}} - # ClickHouse uses named parameters as a map -SqlKit.query_all!(ClickHouseRepo, "SELECT * FROM users WHERE age > {age:UInt32}", %{age: 21}) +SqlKit.query_all(ClickHouseRepo, "SELECT * FROM users WHERE age > {age:UInt32}", %{age: 21}) # => [%{id: 1, name: "Alice", age: 30}, ...] ``` @@ -119,16 +122,20 @@ end #### 3. Execute queries ```elixir -# Get a single row as a map +# Get a single row as a map (raises if no results) MyApp.Reports.SQL.query_one!("stats.sql", [user_id]) # => %{id: 1, name: "Alice", total_sales: 1000} -# You can also use query!/3, which is an alias for query_one!/3 +# Get a single row or nil (raises on multiple results) +MyApp.Reports.SQL.query_one("stats.sql", [user_id]) +# => %{id: 1, name: "Alice", total_sales: 1000} + +# You can also use query!/3 and query/3, which are aliases for query_one!/3 and query_one/3 MyApp.Reports.SQL.query!("stats.sql", [user_id]) # => %{id: 1, name: "Alice", total_sales: 1000} # Get all rows -MyApp.Reports.SQL.query_all!("activity.sql", [company_id]) +MyApp.Reports.SQL.query_all("activity.sql", [company_id]) # => [%{id: 1, ...}, %{id: 2, ...}] # Cast results to structs @@ -151,13 +158,13 @@ For scripts, one-off analysis, or simple use cases: ```elixir # In-memory database {:ok, conn} = SqlKit.DuckDB.connect(":memory:") -SqlKit.query_all!(conn, "SELECT 1 as num", []) +SqlKit.query_all(conn, "SELECT 1 as num", []) # => [%{num: 1}] SqlKit.DuckDB.disconnect(conn) # File-based database {:ok, conn} = SqlKit.DuckDB.connect("analytics.duckdb") -SqlKit.query_all!(conn, "SELECT * FROM events", []) +SqlKit.query_all(conn, "SELECT * FROM events", []) SqlKit.DuckDB.disconnect(conn) # With custom configuration @@ -194,7 +201,7 @@ Then query using the pool: ```elixir pool = SqlKit.DuckDB.Pool.pool(MyApp.AnalyticsPool) -SqlKit.query_all!(pool, "SELECT * FROM events WHERE date > $1", [~D[2024-01-01]]) +SqlKit.query_all(pool, "SELECT * FROM events WHERE date > $1", [~D[2024-01-01]]) # => [%{id: 1, date: ~D[2024-01-15], ...}, ...] ``` @@ -212,7 +219,7 @@ defmodule MyApp.Analytics.SQL do end # Usage -MyApp.Analytics.SQL.query_all!("daily_summary.sql", [~D[2024-01-01]]) +MyApp.Analytics.SQL.query_all("daily_summary.sql", [~D[2024-01-01]]) ``` ### Loading Extensions @@ -221,9 +228,9 @@ DuckDB extensions (Parquet, JSON, HTTPFS, etc.) are loaded via SQL: ```elixir pool = SqlKit.DuckDB.Pool.pool(MyApp.AnalyticsPool) -SqlKit.query!(pool, "INSTALL 'parquet';", []) -SqlKit.query!(pool, "LOAD 'parquet';", []) -SqlKit.query_all!(pool, "SELECT * FROM 'data.parquet'", []) +SqlKit.query_one!(pool, "INSTALL 'parquet';", []) +SqlKit.query_one!(pool, "LOAD 'parquet';", []) +SqlKit.query_all(pool, "SELECT * FROM 'data.parquet'", []) ``` ### Streaming Large Results @@ -317,7 +324,7 @@ defmodule MyApp.Users do alias MyApp.Users.User def get_active_users(company_id, min_age) do - SqlKit.query_all!(MyApp.Repo, """ + SqlKit.query_all(MyApp.Repo, """ SELECT id, name, email, age FROM users WHERE company_id = $1 @@ -333,7 +340,7 @@ defmodule MyApp.Users do alias MyApp.Users.User def get_active_users(company_id, min_age) do - MyApp.Users.SQL.query_all!("active_users.sql", [company_id, min_age], as: User) + MyApp.Users.SQL.query_all("active_users.sql", [company_id, min_age], as: User) end end @@ -360,19 +367,20 @@ Note: You must specify either `:repo` or `:backend`, but not both. These functions are defined directly on the `SqlKit` module and work with any Ecto repo: -#### `SqlKit.query_all!(repo, sql, params \\ [], opts \\ [])` +#### `SqlKit.query_all(repo, sql, params \\ [], opts \\ [])` -Executes SQL and returns all rows as a list of maps. +Executes SQL and returns all rows as a list of maps. Raises on query execution errors. +Matches [`Ecto.Repo.all/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:all/2) semantics. ```elixir -SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users") +SqlKit.query_all(MyApp.Repo, "SELECT * FROM users") # => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}] -SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21], as: User) +SqlKit.query_all(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21], as: User) # => [%User{id: 1, name: "Alice"}, ...] # ClickHouse uses named parameters as a map -SqlKit.query_all!(ClickHouseRepo, "SELECT * FROM users WHERE age > {age:UInt32}", %{age: 21}) +SqlKit.query_all(ClickHouseRepo, "SELECT * FROM users WHERE age > {age:UInt32}", %{age: 21}) # => [%{id: 1, name: "Alice"}, ...] ``` @@ -383,6 +391,8 @@ Executes SQL and returns exactly one row as a map. - Raises `SqlKit.NoResultsError` if no rows returned - Raises `SqlKit.MultipleResultsError` if more than one row returned +Matches [`Ecto.Repo.one!/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:one!/2) semantics. + ```elixir SqlKit.query_one!(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1]) # => %{id: 1, name: "Alice"} @@ -399,33 +409,25 @@ SqlKit.query_one!(ClickHouseRepo, "SELECT * FROM users WHERE id = {id:UInt32}", Alias for `SqlKit.query_one!/4`. See `SqlKit.query_one!/4` documentation. -#### `SqlKit.query_all(repo, sql, params \\ [], opts \\ [])` - -Returns `{:ok, results}` on success, `{:error, exception}` on failure. - -```elixir -SqlKit.query_all(MyApp.Repo, "SELECT * FROM users") -# => {:ok, [%{id: 1, name: "Alice"}, ...]} - -# ClickHouse uses named parameters as a map -SqlKit.query_all(ClickHouseRepo, "SELECT * FROM users WHERE age > {age:UInt32}", %{age: 21}) -# => {:ok, [%{id: 1, name: "Alice"}, ...]} -``` - #### `SqlKit.query_one(repo, sql, params \\ [], opts \\ [])` -Returns `{:ok, result}` on one result, `{:ok, nil}` on no results, or `{:error, exception}` on multiple results or errors. +Executes SQL and returns one row or nil. Raises on query execution errors or multiple results. +Matches [`Ecto.Repo.one/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:one/2) semantics. + +- Returns `result` on exactly one result +- Returns `nil` on no results +- Raises `SqlKit.MultipleResultsError` if more than one row returned ```elixir SqlKit.query_one(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1]) -# => {:ok, %{id: 1, name: "Alice"}} +# => %{id: 1, name: "Alice"} SqlKit.query_one(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [999]) -# => {:ok, nil} +# => nil # ClickHouse uses named parameters as a map SqlKit.query_one(ClickHouseRepo, "SELECT * FROM users WHERE id = {id:UInt32}", %{id: 1}) -# => {:ok, %{id: 1, name: "Alice"}} +# => %{id: 1, name: "Alice"} ``` #### `SqlKit.query(repo, sql, params \\ [], opts \\ [])` @@ -436,6 +438,23 @@ Alias for `SqlKit.query_one/4`. See `SqlKit.query_one/4` documentation. These functions are generated by `use SqlKit` and available on your SQL modules: +#### `query_all(filename, params \\ [], opts \\ [])` + +Executes a query and returns all rows as a list of maps. Raises on query execution errors. +Matches [`Ecto.Repo.all/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:all/2) semantics. + +```elixir +SQL.query_all("users.sql", [company_id]) +# => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}] + +SQL.query_all("users.sql", [company_id], as: User) +# => [%User{id: 1, name: "Alice"}, %User{id: 2, name: "Bob"}] + +# ClickHouse uses named parameters as a map +ClickHouseSQL.query_all("users.sql", %{company_id: 123}) +# => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}] +``` + #### `query_one!(filename, params \\ [], opts \\ [])` Executes a query and returns a single row as a map. @@ -459,61 +478,37 @@ ClickHouseSQL.query_one!("user.sql", %{user_id: 1}) Alias for `query_one!/3`. See `query_one!/3` documentation. -#### `query_all!(filename, params \\ [], opts \\ [])` - -Executes a query and returns all rows as a list of maps. - -```elixir -SQL.query_all!("users.sql", [company_id]) -# => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}] - -SQL.query_all!("users.sql", [company_id], as: User) -# => [%User{id: 1, name: "Alice"}, %User{id: 2, name: "Bob"}] - -# ClickHouse uses named parameters as a map -ClickHouseSQL.query_all!("users.sql", %{company_id: 123}) -# => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}] -``` - -#### `load!(filename)` - -Returns the SQL string for the given file. - -```elixir -SQL.load!("users.sql") -# => "SELECT * FROM users" -``` - #### `query_one(filename, params \\ [], opts \\ [])` +Executes a query and returns one row or nil. Raises on query execution errors or multiple results. +Matches [`Ecto.Repo.one/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:one/2) semantics. + ```elixir SQL.query_one("user.sql", [user_id]) -# => {:ok, %{id: 1, name: "Alice"}} +# => %{id: 1, name: "Alice"} SQL.query_one("missing_user.sql", [999]) -# => {:ok, nil} # No results returns nil, not an error +# => nil # No results returns nil +# Raises SqlKit.MultipleResultsError on multiple results SQL.query_one("all_users.sql", []) -# => {:error, %SqlKit.MultipleResultsError{count: 10}} # ClickHouse uses named parameters as a map ClickHouseSQL.query_one("user.sql", %{user_id: 1}) -# => {:ok, %{id: 1, name: "Alice"}} +# => %{id: 1, name: "Alice"} ``` #### `query(filename, params \\ [], opts \\ [])` Alias for `query_one/3`. See `query_one/3` documentation. -#### `query_all(filename, params \\ [], opts \\ [])` +#### `load!(filename)` -```elixir -SQL.query_all("users.sql", [company_id]) -# => {:ok, [%{id: 1, name: "Alice"}, ...]} +Returns the SQL string for the given file. -# ClickHouse uses named parameters as a map -ClickHouseSQL.query_all("users.sql", %{company_id: 123}) -# => {:ok, [%{id: 1, name: "Alice"}, ...]} +```elixir +SQL.load!("users.sql") +# => "SELECT * FROM users" ``` #### `load(filename)` diff --git a/lib/sql_kit.ex b/lib/sql_kit.ex index 37b7ad9..868563c 100644 --- a/lib/sql_kit.ex +++ b/lib/sql_kit.ex @@ -9,7 +9,7 @@ defmodule SqlKit do Execute SQL strings directly with any Ecto repo: - SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21]) + SqlKit.query_all(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21]) # => [%{id: 1, name: "Alice", age: 30}, ...] SqlKit.query_one!(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1], as: User) @@ -51,7 +51,7 @@ defmodule SqlKit do files: ["daily_summary.sql"] end - MyApp.Analytics.SQL.query_all!("daily_summary.sql", [~D[2024-01-01]]) + MyApp.Analytics.SQL.query_all("daily_summary.sql", [~D[2024-01-01]]) ## Supported Databases @@ -113,6 +113,8 @@ defmodule SqlKit do @doc """ Executes a SQL query and returns all rows as a list of maps or structs. + Raises on query execution errors. This matches [`Ecto.Repo.all/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:all/2) semantics. + ## Backend The first argument can be: @@ -128,43 +130,24 @@ defmodule SqlKit do ## Examples - # With Ecto repo - SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users") + SqlKit.query_all(MyApp.Repo, "SELECT * FROM users") # => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}] - SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21]) + SqlKit.query_all(MyApp.Repo, "SELECT * FROM users WHERE age > $1", [21]) # => [%{id: 1, name: "Alice", age: 30}] - SqlKit.query_all!(MyApp.Repo, "SELECT * FROM users", [], as: User) + SqlKit.query_all(MyApp.Repo, "SELECT * FROM users", [], as: User) # => [%User{id: 1, name: "Alice"}, %User{id: 2, name: "Bob"}] # With DuckDB connection {:ok, conn} = SqlKit.DuckDB.connect(":memory:") - SqlKit.query_all!(conn, "SELECT 1 as num", []) + SqlKit.query_all(conn, "SELECT 1 as num", []) # => [%{num: 1}] # With DuckDB pool - SqlKit.query_all!(MyApp.DuckDBPool, "SELECT * FROM events", []) - """ - @spec query_all!(backend(), String.t(), list() | map(), keyword()) :: [map() | struct()] - def query_all!(backend, sql, params \\ [], opts \\ []) do - SqlKit.Query.all!(backend, sql, params, opts) - end - - @doc """ - Executes a SQL query and returns all rows as a list of maps or structs. - - Returns `{:ok, results}` on success, `{:error, exception}` on failure. - - See `query_all!/4` for backend and options documentation. - - ## Examples - - SqlKit.query_all(MyApp.Repo, "SELECT * FROM users") - # => {:ok, [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}]} + SqlKit.query_all(MyApp.DuckDBPool, "SELECT * FROM events", []) """ - @spec query_all(backend(), String.t(), list() | map(), keyword()) :: - {:ok, [map() | struct()]} | {:error, term()} + @spec query_all(backend(), String.t(), list() | map(), keyword()) :: [map() | struct()] def query_all(backend, sql, params \\ [], opts \\ []) do SqlKit.Query.all(backend, sql, params, opts) end @@ -175,7 +158,7 @@ defmodule SqlKit do Raises `SqlKit.NoResultsError` if no rows are returned. Raises `SqlKit.MultipleResultsError` if more than one row is returned. - See `query_all!/4` for backend documentation. + See `query_all/4` for backend documentation. ## Options @@ -198,12 +181,13 @@ defmodule SqlKit do end @doc """ - Executes a SQL query and returns one row as a map or struct. + Executes a SQL query and returns one row as a map or struct, or nil if no results. - Returns `{:ok, result}` on exactly one result, `{:ok, nil}` on no results, - or `{:error, exception}` on multiple results or other errors. + Returns the result directly, or nil on no results. + Raises `SqlKit.MultipleResultsError` if more than one row is returned. + Raises on query execution errors. This matches [`Ecto.Repo.one/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:one/2) semantics. - See `query_all!/4` for backend documentation. + See `query_all/4` for backend documentation. ## Options @@ -215,13 +199,12 @@ defmodule SqlKit do ## Examples SqlKit.query_one(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [1]) - # => {:ok, %{id: 1, name: "Alice"}} + # => %{id: 1, name: "Alice"} SqlKit.query_one(MyApp.Repo, "SELECT * FROM users WHERE id = $1", [999]) - # => {:ok, nil} + # => nil """ - @spec query_one(backend(), String.t(), list() | map(), keyword()) :: - {:ok, map() | struct() | nil} | {:error, term()} + @spec query_one(backend(), String.t(), list() | map(), keyword()) :: map() | struct() | nil def query_one(backend, sql, params \\ [], opts \\ []) do SqlKit.Query.one(backend, sql, params, opts) end @@ -237,8 +220,7 @@ defmodule SqlKit do @doc """ Alias for `query_one/4`. See `query_one/4` documentation. """ - @spec query(backend(), String.t(), list() | map(), keyword()) :: - {:ok, map() | struct() | nil} | {:error, term()} + @spec query(backend(), String.t(), list() | map(), keyword()) :: map() | struct() | nil def query(backend, sql, params \\ [], opts \\ []) do SqlKit.Query.one(backend, sql, params, opts) end @@ -399,9 +381,9 @@ defmodule SqlKit do end @doc """ - Executes a SQL query and returns all rows as a list of maps. + Executes a SQL query and returns all rows as a list of maps or structs. - Returns `{:ok, results}` on success, `{:error, exception}` on failure. + Raises on query execution errors. This matches [`Ecto.Repo.all/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:all/2) semantics. ## Options @@ -412,47 +394,20 @@ defmodule SqlKit do ## Examples SQL.query_all("users.sql", [company_id]) - # => {:ok, [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}]} - - # ClickHouse uses named parameters as a map - ClickHouseSQL.query_all("users.sql", %{company_id: 123}) - # => {:ok, [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}]} - """ - @spec query_all(String.t(), list() | map(), keyword()) :: {:ok, [map() | struct()]} | {:error, term()} - def query_all(filename, params \\ [], opts \\ []) do - {:ok, query_all!(filename, params, opts)} - rescue - e -> {:error, e} - end - - @doc """ - Executes a SQL query and returns all rows as a list of maps. - - Raises on error. - - ## Options - - - `:as` - Struct module to cast results into - - `:unsafe_atoms` - If `true`, uses `String.to_atom/1` instead of - `String.to_existing_atom/1` for column names. Default: `false` - - ## Examples - - SQL.query_all!("users.sql", [company_id]) # => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}] - SQL.query_all!("users.sql", [company_id], as: User) + SQL.query_all("users.sql", [company_id], as: User) # => [%User{id: 1, name: "Alice"}, %User{id: 2, name: "Bob"}] # ClickHouse uses named parameters as a map - ClickHouseSQL.query_all!("users.sql", %{company_id: 123}) + ClickHouseSQL.query_all("users.sql", %{company_id: 123}) # => [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}] """ - @spec query_all!(String.t(), list() | map(), keyword()) :: [map() | struct()] - def query_all!(filename, params \\ [], opts \\ []) do + @spec query_all(String.t(), list() | map(), keyword()) :: [map() | struct()] + def query_all(filename, params \\ [], opts \\ []) do sql = load!(filename) backend = get_backend() - SqlKit.Query.all!(backend, sql, params, opts) + SqlKit.Query.all(backend, sql, params, opts) end # Returns the configured backend for query execution. @@ -469,10 +424,11 @@ defmodule SqlKit do end @doc """ - Executes a SQL query and returns a single row as a map or struct. + Executes a SQL query and returns a single row as a map or struct, or nil if no results. - Returns `{:ok, result}` on exactly one result, `{:ok, nil}` on no results, - or `{:error, exception}` on multiple results or other errors. + Returns the result directly, or nil on no results. + Raises `SqlKit.MultipleResultsError` if more than one row is returned. + Raises on query execution errors. This matches [`Ecto.Repo.one/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:one/2) semantics. ## Options @@ -483,30 +439,26 @@ defmodule SqlKit do ## Examples SQL.query_one("user.sql", [user_id]) - # => {:ok, %{id: 1, name: "Alice"}} + # => %{id: 1, name: "Alice"} SQL.query_one("missing.sql", [999]) - # => {:ok, nil} + # => nil # ClickHouse uses named parameters as a map ClickHouseSQL.query_one("user.sql", %{user_id: 1}) - # => {:ok, %{id: 1, name: "Alice"}} + # => %{id: 1, name: "Alice"} """ - @spec query_one(String.t(), list() | map(), keyword()) :: - {:ok, map() | struct() | nil} | {:error, term()} + @spec query_one(String.t(), list() | map(), keyword()) :: map() | struct() | nil def query_one(filename, params \\ [], opts \\ []) do case query_all(filename, params, opts) do - {:ok, []} -> - {:ok, nil} - - {:ok, [row]} -> - {:ok, row} + [] -> + nil - {:ok, rows} -> - {:error, SqlKit.MultipleResultsError.exception(filename: filename, count: length(rows))} + [row] -> + row - {:error, _} = error -> - error + rows -> + raise SqlKit.MultipleResultsError, filename: filename, count: length(rows) end end @@ -516,6 +468,8 @@ defmodule SqlKit do Raises `SqlKit.NoResultsError` if no rows are returned. Raises `SqlKit.MultipleResultsError` if more than one row is returned. + This matches [`Ecto.Repo.one!/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:one!/2) semantics. + ## Options - `:as` - Struct module to cast result into @@ -536,7 +490,7 @@ defmodule SqlKit do """ @spec query_one!(String.t(), list() | map(), keyword()) :: map() | struct() def query_one!(filename, params \\ [], opts \\ []) do - case query_all!(filename, params, opts) do + case query_all(filename, params, opts) do [] -> raise SqlKit.NoResultsError, filename: filename @@ -551,8 +505,7 @@ defmodule SqlKit do @doc """ Alias for `query_one/3`. See `query_one/3` documentation. """ - @spec query(String.t(), list() | map(), keyword()) :: - {:ok, map() | struct() | nil} | {:error, term()} + @spec query(String.t(), list() | map(), keyword()) :: map() | struct() | nil defdelegate query(filename, params \\ [], opts \\ []), to: __MODULE__, as: :query_one @doc """ diff --git a/lib/sql_kit/duckdb.ex b/lib/sql_kit/duckdb.ex index c7d9e9f..9678bd0 100644 --- a/lib/sql_kit/duckdb.ex +++ b/lib/sql_kit/duckdb.ex @@ -10,7 +10,7 @@ if Code.ensure_loaded?(Duckdbex) do For simple use cases, scripts, or explicit control: {:ok, conn} = SqlKit.DuckDB.connect(":memory:") - SqlKit.query_all!(conn, "SELECT 1 as num", []) + SqlKit.query_all(conn, "SELECT 1 as num", []) # => [%{num: 1}] SqlKit.DuckDB.disconnect(conn) @@ -26,7 +26,7 @@ if Code.ensure_loaded?(Duckdbex) do ] # Then use the pool name with SqlKit functions - SqlKit.query_all!(MyApp.AnalyticsPool, "SELECT * FROM events", []) + SqlKit.query_all(MyApp.AnalyticsPool, "SELECT * FROM events", []) ## Loading Extensions @@ -34,7 +34,7 @@ if Code.ensure_loaded?(Duckdbex) do SqlKit.query!(conn, "INSTALL 'parquet';", []) SqlKit.query!(conn, "LOAD 'parquet';", []) - SqlKit.query_all!(conn, "SELECT * FROM 'data.parquet'", []) + SqlKit.query_all(conn, "SELECT * FROM 'data.parquet'", []) ## Notes @@ -128,7 +128,7 @@ if Code.ensure_loaded?(Duckdbex) do Executes a SQL query and returns columns and rows. This is a low-level function. Users should typically use - `SqlKit.query_all!/3`, `SqlKit.query_one!/3`, etc. instead. + `SqlKit.query_all/3`, `SqlKit.query_one!/3`, etc. instead. ## Examples @@ -151,6 +151,7 @@ if Code.ensure_loaded?(Duckdbex) do See `query/3` for details. """ + # sobelow_skip ["SQL.Query"] @spec query!(Connection.t(), String.t(), list()) :: {[String.t()], [[term()]]} def query!(%Connection{} = conn, sql, params) do case query(conn, sql, params) do diff --git a/lib/sql_kit/duckdb/pool.ex b/lib/sql_kit/duckdb/pool.ex index fb01df8..bf9cb96 100644 --- a/lib/sql_kit/duckdb/pool.ex +++ b/lib/sql_kit/duckdb/pool.ex @@ -21,12 +21,12 @@ if Code.ensure_loaded?(Duckdbex) do Then use the pool with SqlKit functions using the pool reference: pool = SqlKit.DuckDB.Pool.pool(MyApp.AnalyticsPool) - SqlKit.query_all!(pool, "SELECT * FROM events", []) + SqlKit.query_all(pool, "SELECT * FROM events", []) Or get the pool reference from start_link: {:ok, pool} = SqlKit.DuckDB.Pool.start_link(name: MyPool, database: ":memory:") - SqlKit.query_all!(pool, "SELECT * FROM events", []) + SqlKit.query_all(pool, "SELECT * FROM events", []) ## Options @@ -82,7 +82,7 @@ if Code.ensure_loaded?(Duckdbex) do ## Example pool = SqlKit.DuckDB.Pool.pool(MyApp.AnalyticsPool) - SqlKit.query_all!(pool, "SELECT * FROM events", []) + SqlKit.query_all(pool, "SELECT * FROM events", []) """ @spec pool(atom()) :: t() def pool(name) when is_atom(name) do @@ -189,10 +189,12 @@ if Code.ensure_loaded?(Duckdbex) do SqlKit.DuckDB.Pool.query!(pool, sql, params, timeout: 10_000) """ + # sobelow_skip ["SQL.Query"] @spec query!(t() | atom(), String.t(), list(), keyword()) :: {[String.t()], [[term()]]} def query!(pool, sql, params, opts \\ []) + # sobelow_skip ["SQL.Query"] def query!(%__MODULE__{name: name}, sql, params, opts), do: query!(name, sql, params, opts) def query!(pool_name, sql, params, opts) when is_atom(pool_name) do diff --git a/lib/sql_kit/query.ex b/lib/sql_kit/query.ex index 84eb005..76f858f 100644 --- a/lib/sql_kit/query.ex +++ b/lib/sql_kit/query.ex @@ -1,27 +1,16 @@ defmodule SqlKit.Query do @moduledoc false - # Internal module - users should use SqlKit.query_all!, SqlKit.query_one!, etc. + # Internal module - users should use SqlKit.query_all, SqlKit.query_one, etc. @doc """ Executes SQL and returns all rows as a list of maps or structs. - """ - @spec all!(backend :: term(), String.t(), list() | map(), keyword()) :: [map() | struct()] - def all!(backend, sql, params \\ [], opts \\ []) do - {columns, rows} = execute!(backend, sql, params) - SqlKit.transform_rows(columns, rows, opts) - end - @doc """ - Executes SQL and returns all rows as a list of maps or structs. - - Returns `{:ok, results}` on success, `{:error, exception}` on failure. + Raises on query execution errors. """ - @spec all(backend :: term(), String.t(), list() | map(), keyword()) :: - {:ok, [map() | struct()]} | {:error, term()} + @spec all(backend :: term(), String.t(), list() | map(), keyword()) :: [map() | struct()] def all(backend, sql, params \\ [], opts \\ []) do - {:ok, all!(backend, sql, params, opts)} - rescue - e -> {:error, e} + {columns, rows} = execute!(backend, sql, params) + SqlKit.transform_rows(columns, rows, opts) end @doc """ @@ -34,7 +23,7 @@ defmodule SqlKit.Query do def one!(backend, sql, params \\ [], opts \\ []) do query_name = Keyword.get(opts, :query_name) || truncate_sql(sql) - case all!(backend, sql, params, opts) do + case all(backend, sql, params, opts) do [] -> raise SqlKit.NoResultsError, query: query_name @@ -47,28 +36,25 @@ defmodule SqlKit.Query do end @doc """ - Executes SQL and returns one row as a map or struct. + Executes SQL and returns one row as a map or struct, or nil if no results. - Returns `{:ok, result}` on exactly one result, `{:ok, nil}` on no results, - or `{:error, exception}` on multiple results or other errors. + Returns the result directly, or nil on no results. + Raises `SqlKit.MultipleResultsError` if more than one row is returned. + Raises on query execution errors. """ - @spec one(backend :: term(), String.t(), list() | map(), keyword()) :: - {:ok, map() | struct() | nil} | {:error, term()} + @spec one(backend :: term(), String.t(), list() | map(), keyword()) :: map() | struct() | nil def one(backend, sql, params \\ [], opts \\ []) do query_name = Keyword.get(opts, :query_name) || truncate_sql(sql) case all(backend, sql, params, opts) do - {:ok, []} -> - {:ok, nil} - - {:ok, [row]} -> - {:ok, row} + [] -> + nil - {:ok, rows} -> - {:error, SqlKit.MultipleResultsError.exception(query: query_name, count: length(rows))} + [row] -> + row - {:error, _} = error -> - error + rows -> + raise SqlKit.MultipleResultsError, query: query_name, count: length(rows) end end diff --git a/mix.exs b/mix.exs index 688505f..35a55f8 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule SqlKit.MixProject do use Mix.Project - @version "0.1.0" + @version "0.2.0" def project do [ diff --git a/test/sql_kit/duckdb_test.exs b/test/sql_kit/duckdb_test.exs index b3767ed..5004ae7 100644 --- a/test/sql_kit/duckdb_test.exs +++ b/test/sql_kit/duckdb_test.exs @@ -128,16 +128,16 @@ defmodule SqlKit.DuckDBTest do %{conn: conn} end - test "query_all! returns list of maps", %{conn: conn} do - results = SqlKit.query_all!(conn, "SELECT * FROM users ORDER BY id", []) + test "query_all returns list of maps", %{conn: conn} do + results = SqlKit.query_all(conn, "SELECT * FROM users ORDER BY id", []) assert length(results) == 3 assert hd(results).id == 1 assert hd(results).name == "Alice" end - test "query_all! with parameters", %{conn: conn} do - results = SqlKit.query_all!(conn, "SELECT * FROM users WHERE age > $1", [26]) + test "query_all with parameters", %{conn: conn} do + results = SqlKit.query_all(conn, "SELECT * FROM users WHERE age > $1", [26]) assert length(results) == 2 names = Enum.map(results, & &1.name) @@ -145,22 +145,13 @@ defmodule SqlKit.DuckDBTest do assert "Charlie" in names end - test "query_all! with :as option", %{conn: conn} do - results = SqlKit.query_all!(conn, "SELECT id, name, age FROM users ORDER BY id", [], as: User) + test "query_all with :as option", %{conn: conn} do + results = SqlKit.query_all(conn, "SELECT id, name, age FROM users ORDER BY id", [], as: User) assert length(results) == 3 assert %User{id: 1, name: "Alice", age: 30} = hd(results) end - test "query_all returns {:ok, results}", %{conn: conn} do - assert {:ok, results} = SqlKit.query_all(conn, "SELECT * FROM users ORDER BY id", []) - assert length(results) == 3 - end - - test "query_all returns {:error, _} on error", %{conn: conn} do - assert {:error, _} = SqlKit.query_all(conn, "SELECT * FROM nonexistent", []) - end - test "query_one! returns single map", %{conn: conn} do result = SqlKit.query_one!(conn, "SELECT * FROM users WHERE id = $1", [1]) @@ -180,13 +171,19 @@ defmodule SqlKit.DuckDBTest do end end - test "query_one returns {:ok, result}", %{conn: conn} do - assert {:ok, result} = SqlKit.query_one(conn, "SELECT * FROM users WHERE id = $1", [1]) + test "query_one returns result", %{conn: conn} do + result = SqlKit.query_one(conn, "SELECT * FROM users WHERE id = $1", [1]) assert result.name == "Alice" end - test "query_one returns {:ok, nil} when no results", %{conn: conn} do - assert {:ok, nil} = SqlKit.query_one(conn, "SELECT * FROM users WHERE id = $1", [999]) + test "query_one returns nil when no results", %{conn: conn} do + assert SqlKit.query_one(conn, "SELECT * FROM users WHERE id = $1", [999]) == nil + end + + test "query_one raises on multiple results", %{conn: conn} do + assert_raise SqlKit.MultipleResultsError, fn -> + SqlKit.query_one(conn, "SELECT * FROM users", []) + end end test "query!/4 is alias for query_one!/4", %{conn: conn} do @@ -195,7 +192,7 @@ defmodule SqlKit.DuckDBTest do end test "query/4 is alias for query_one/4", %{conn: conn} do - assert {:ok, result} = SqlKit.query(conn, "SELECT * FROM users WHERE id = $1", [1]) + result = SqlKit.query(conn, "SELECT * FROM users WHERE id = $1", [1]) assert result.name == "Alice" end end @@ -288,21 +285,21 @@ defmodule SqlKit.DuckDBTest do %{pool: pool} end - test "query_all! with pool", %{pool: pool} do - results = SqlKit.query_all!(pool, "SELECT * FROM users ORDER BY id", []) + test "query_all with pool", %{pool: pool} do + results = SqlKit.query_all(pool, "SELECT * FROM users ORDER BY id", []) assert length(results) == 3 assert hd(results).name == "Alice" end - test "query_all! with pool and parameters", %{pool: pool} do - results = SqlKit.query_all!(pool, "SELECT * FROM users WHERE age > $1", [26]) + test "query_all with pool and parameters", %{pool: pool} do + results = SqlKit.query_all(pool, "SELECT * FROM users WHERE age > $1", [26]) assert length(results) == 2 end - test "query_all! with pool and :as option", %{pool: pool} do - results = SqlKit.query_all!(pool, "SELECT id, name, age FROM users ORDER BY id", [], as: User) + test "query_all with pool and :as option", %{pool: pool} do + results = SqlKit.query_all(pool, "SELECT id, name, age FROM users ORDER BY id", [], as: User) assert length(results) == 3 assert %User{id: 1, name: "Alice", age: 30} = hd(results) @@ -326,18 +323,19 @@ defmodule SqlKit.DuckDBTest do end end - test "query_all with pool returns {:ok, results}", %{pool: pool} do - assert {:ok, results} = SqlKit.query_all(pool, "SELECT * FROM users ORDER BY id", []) - assert length(results) == 3 + test "query_one with pool returns result", %{pool: pool} do + result = SqlKit.query_one(pool, "SELECT * FROM users WHERE id = $1", [1]) + assert result.name == "Alice" end - test "query_one with pool returns {:ok, result}", %{pool: pool} do - assert {:ok, result} = SqlKit.query_one(pool, "SELECT * FROM users WHERE id = $1", [1]) - assert result.name == "Alice" + test "query_one with pool returns nil when no results", %{pool: pool} do + assert SqlKit.query_one(pool, "SELECT * FROM users WHERE id = $1", [999]) == nil end - test "query_one with pool returns {:ok, nil} when no results", %{pool: pool} do - assert {:ok, nil} = SqlKit.query_one(pool, "SELECT * FROM users WHERE id = $1", [999]) + test "query_one with pool raises on multiple results", %{pool: pool} do + assert_raise SqlKit.MultipleResultsError, fn -> + SqlKit.query_one(pool, "SELECT * FROM users", []) + end end end @@ -410,7 +408,7 @@ defmodule SqlKit.DuckDBTest do # Second connection: verify data persisted {:ok, conn2} = DuckDB.connect(path) - results = SqlKit.query_all!(conn2, "SELECT * FROM persistence_test ORDER BY id", []) + results = SqlKit.query_all(conn2, "SELECT * FROM persistence_test ORDER BY id", []) assert length(results) == 2 assert Enum.at(results, 0).id == 1 @@ -433,7 +431,7 @@ defmodule SqlKit.DuckDBTest do end) # Query via SqlKit API - results = SqlKit.query_all!(pool, "SELECT * FROM pool_file_test ORDER BY id", []) + results = SqlKit.query_all(pool, "SELECT * FROM pool_file_test ORDER BY id", []) assert length(results) == 2 assert hd(results).name == "Alice" @@ -474,7 +472,7 @@ defmodule SqlKit.DuckDBTest do DuckDB.query!(conn, "INSERT INTO restart_test VALUES (2, 'new_data')", []) end) - results = SqlKit.query_all!(pool2, "SELECT * FROM restart_test ORDER BY id", []) + results = SqlKit.query_all(pool2, "SELECT * FROM restart_test ORDER BY id", []) assert length(results) == 2 Supervisor.stop(pool2.pid) @@ -534,22 +532,22 @@ defmodule SqlKit.DuckDBTest do assert sql =~ "SELECT" end - test "query_all! returns all rows", _context do - results = DuckDBSQL.query_all!("all_users.sql") + test "query_all returns all rows", _context do + results = DuckDBSQL.query_all("all_users.sql") assert length(results) == 3 assert hd(results).name == "Alice" end - test "query_all! with :as option", _context do - results = DuckDBSQL.query_all!("all_users.sql", [], as: User) + test "query_all with :as option", _context do + results = DuckDBSQL.query_all("all_users.sql", [], as: User) assert length(results) == 3 assert %User{id: 1, name: "Alice", email: "alice@test.com", age: 30} = hd(results) end - test "query_all! with parameters", _context do - results = DuckDBSQL.query_all!("users_by_age_range.sql", [26, 40]) + test "query_all with parameters", _context do + results = DuckDBSQL.query_all("users_by_age_range.sql", [26, 40]) assert length(results) == 2 names = Enum.map(results, & &1.name) @@ -557,11 +555,6 @@ defmodule SqlKit.DuckDBTest do assert "Charlie" in names end - test "query_all returns {:ok, results}", _context do - assert {:ok, results} = DuckDBSQL.query_all("all_users.sql") - assert length(results) == 3 - end - test "query_one! returns single row", _context do result = DuckDBSQL.query_one!("first_user.sql") @@ -594,13 +587,19 @@ defmodule SqlKit.DuckDBTest do end end - test "query_one returns {:ok, result}", _context do - assert {:ok, result} = DuckDBSQL.query_one("first_user.sql") + test "query_one returns result", _context do + result = DuckDBSQL.query_one("first_user.sql") assert result.name == "Alice" end - test "query_one returns {:ok, nil} for no results", _context do - assert {:ok, nil} = DuckDBSQL.query_one("no_users.sql") + test "query_one returns nil for no results", _context do + assert DuckDBSQL.query_one("no_users.sql") == nil + end + + test "query_one raises on multiple results", _context do + assert_raise SqlKit.MultipleResultsError, fn -> + DuckDBSQL.query_one("all_users.sql") + end end test "query!/3 is alias for query_one!/3", _context do @@ -609,7 +608,7 @@ defmodule SqlKit.DuckDBTest do end test "query/3 is alias for query_one/3", _context do - assert {:ok, result} = DuckDBSQL.query("user_by_id.sql", [1]) + result = DuckDBSQL.query("user_by_id.sql", [1]) assert result.name == "Alice" end end @@ -685,11 +684,11 @@ defmodule SqlKit.DuckDBTest do test "SqlKit functions use cached pool queries", %{pool: pool} do # This uses Pool.query! internally - results = SqlKit.query_all!(pool, "SELECT * FROM cache_test ORDER BY id", []) + results = SqlKit.query_all(pool, "SELECT * FROM cache_test ORDER BY id", []) assert length(results) == 3 # Run again - should use cached statement - results2 = SqlKit.query_all!(pool, "SELECT * FROM cache_test ORDER BY id", []) + results2 = SqlKit.query_all(pool, "SELECT * FROM cache_test ORDER BY id", []) assert length(results2) == 3 end end @@ -1001,7 +1000,7 @@ defmodule SqlKit.DuckDBTest do end) # Query using file-based SQL module - results = DuckDBSQL.query_all!("all_users.sql") + results = DuckDBSQL.query_all("all_users.sql") assert length(results) == 3 assert hd(results).name == "Alice" @@ -1017,7 +1016,7 @@ defmodule SqlKit.DuckDBTest do {:ok, pool2} = Pool.start_link(name: pool_name, database: path, pool_size: 2) # Data should still be there - query using file-based SQL module - results2 = DuckDBSQL.query_all!("all_users.sql") + results2 = DuckDBSQL.query_all("all_users.sql") assert length(results2) == 3 # Verify specific queries still work @@ -1025,7 +1024,7 @@ defmodule SqlKit.DuckDBTest do assert result2.name == "Charlie" # Test with :as option after restart - users = DuckDBSQL.query_all!("all_users.sql", [], as: User) + users = DuckDBSQL.query_all("all_users.sql", [], as: User) assert length(users) == 3 assert %User{id: 1, name: "Alice"} = hd(users) diff --git a/test/sql_kit_test.exs b/test/sql_kit_test.exs index a03374d..631cd2d 100644 --- a/test/sql_kit_test.exs +++ b/test/sql_kit_test.exs @@ -45,7 +45,7 @@ defmodule SqlKitTest do end end - describe "query_all!/3" do + describe "query_all/3" do # Tests for adapters that support sandbox for {adapter, sql_module, repo} <- @sandbox_adapters do @sql_module sql_module @@ -56,7 +56,7 @@ defmodule SqlKitTest do end test "#{adapter}: returns list of maps" do - results = @sql_module.query_all!("all_users.sql") + results = @sql_module.query_all("all_users.sql") assert length(results) == 3 assert Enum.all?(results, &is_map/1) @@ -67,28 +67,34 @@ defmodule SqlKitTest do end test "#{adapter}: casts to struct with :as option" do - results = @sql_module.query_all!("all_users.sql", [], as: User) + results = @sql_module.query_all("all_users.sql", [], as: User) assert length(results) == 3 assert Enum.all?(results, &match?(%User{}, &1)) end test "#{adapter}: returns empty list when no results" do - results = @sql_module.query_all!("no_users.sql") + results = @sql_module.query_all("no_users.sql") assert results == [] end test "#{adapter}: passes parameters to query" do - results = @sql_module.query_all!("user_by_id.sql", [1]) + results = @sql_module.query_all("user_by_id.sql", [1]) assert length(results) == 1 assert hd(results).name == "Alice" end + + test "#{adapter}: raises on file load error" do + assert_raise RuntimeError, ~r/was not included in the :files list/, fn -> + @sql_module.query_all("nonexistent.sql") + end + end end # ClickHouse tests (no sandbox support) test "clickhouse: returns list of maps" do - results = ClickHouseSQL.query_all!("all_users.sql") + results = ClickHouseSQL.query_all("all_users.sql") assert length(results) == 3 assert Enum.all?(results, &is_map/1) @@ -99,23 +105,29 @@ defmodule SqlKitTest do end test "clickhouse: casts to struct with :as option" do - results = ClickHouseSQL.query_all!("all_users.sql", [], as: User) + results = ClickHouseSQL.query_all("all_users.sql", [], as: User) assert length(results) == 3 assert Enum.all?(results, &match?(%User{}, &1)) end test "clickhouse: returns empty list when no results" do - results = ClickHouseSQL.query_all!("no_users.sql") + results = ClickHouseSQL.query_all("no_users.sql") assert results == [] end test "clickhouse: passes parameters to query" do - results = ClickHouseSQL.query_all!("user_by_id.sql", %{id: 1}) + results = ClickHouseSQL.query_all("user_by_id.sql", %{id: 1}) assert length(results) == 1 assert hd(results).name == "Alice" end + + test "clickhouse: raises on file load error" do + assert_raise RuntimeError, ~r/was not included in the :files list/, fn -> + ClickHouseSQL.query_all("nonexistent.sql") + end + end end describe "query_one!/3" do @@ -372,47 +384,7 @@ defmodule SqlKitTest do end end - describe "query_all/3 (non-bang)" do - for {adapter, sql_module, repo} <- @sandbox_adapters do - @sql_module sql_module - @repo repo - - setup do - setup_sandbox(@repo) - end - - test "#{adapter}: returns {:ok, results} on success" do - assert {:ok, results} = @sql_module.query_all("all_users.sql") - assert length(results) == 3 - assert hd(results).name == "Alice" - end - - test "#{adapter}: returns {:ok, []} when no results" do - assert {:ok, []} = @sql_module.query_all("no_users.sql") - end - - test "#{adapter}: returns {:error, exception} on query error" do - assert {:error, %RuntimeError{}} = @sql_module.query_all("nonexistent.sql") - end - end - - # ClickHouse tests (no sandbox support) - test "clickhouse: returns {:ok, results} on success" do - assert {:ok, results} = ClickHouseSQL.query_all("all_users.sql") - assert length(results) == 3 - assert hd(results).name == "Alice" - end - - test "clickhouse: returns {:ok, []} when no results" do - assert {:ok, []} = ClickHouseSQL.query_all("no_users.sql") - end - - test "clickhouse: returns {:error, exception} on query error" do - assert {:error, %RuntimeError{}} = ClickHouseSQL.query_all("nonexistent.sql") - end - end - - describe "query_one/3 (non-bang)" do + describe "query_one/3" do for {adapter, sql_module, repo} <- @sandbox_adapters do @sql_module sql_module @repo repo @@ -421,84 +393,96 @@ defmodule SqlKitTest do setup_sandbox(@repo) end - test "#{adapter}: returns {:ok, result} on exactly one result" do - assert {:ok, result} = @sql_module.query_one("first_user.sql") + test "#{adapter}: returns result on exactly one result" do + result = @sql_module.query_one("first_user.sql") assert result.id == 1 assert result.name == "Alice" # query/3 is an alias for query_one/3 - assert {:ok, ^result} = @sql_module.query("first_user.sql") + assert @sql_module.query("first_user.sql") == result end - test "#{adapter}: returns {:ok, nil} when no results" do - assert {:ok, nil} = @sql_module.query_one("no_users.sql") + test "#{adapter}: returns nil when no results" do + assert @sql_module.query_one("no_users.sql") == nil # query/3 is an alias for query_one/3 - assert {:ok, nil} = @sql_module.query("no_users.sql") + assert @sql_module.query("no_users.sql") == nil end - test "#{adapter}: returns {:error, MultipleResultsError} when multiple results" do - assert {:error, %SqlKit.MultipleResultsError{count: 3}} = - @sql_module.query_one("all_users.sql") + test "#{adapter}: raises MultipleResultsError when multiple results" do + assert_raise SqlKit.MultipleResultsError, ~r/got 3/, fn -> + @sql_module.query_one("all_users.sql") + end # query/3 is an alias for query_one/3 - assert {:error, %SqlKit.MultipleResultsError{count: 3}} = - @sql_module.query("all_users.sql") + assert_raise SqlKit.MultipleResultsError, fn -> + @sql_module.query("all_users.sql") + end end - test "#{adapter}: returns {:error, exception} on query error" do - assert {:error, %RuntimeError{}} = @sql_module.query_one("nonexistent.sql") + test "#{adapter}: raises on file load error" do + assert_raise RuntimeError, ~r/was not included in the :files list/, fn -> + @sql_module.query_one("nonexistent.sql") + end # query/3 is an alias for query_one/3 - assert {:error, %RuntimeError{}} = @sql_module.query("nonexistent.sql") + assert_raise RuntimeError, fn -> + @sql_module.query("nonexistent.sql") + end end test "#{adapter}: casts to struct with :as option" do - assert {:ok, %User{name: "Alice"}} = @sql_module.query_one("first_user.sql", [], as: User) + assert %User{name: "Alice"} = @sql_module.query_one("first_user.sql", [], as: User) # query/3 is an alias for query_one/3 - assert {:ok, %User{name: "Alice"}} = @sql_module.query("first_user.sql", [], as: User) + assert %User{name: "Alice"} = @sql_module.query("first_user.sql", [], as: User) end end # ClickHouse tests (no sandbox support) - test "clickhouse: returns {:ok, result} on exactly one result" do - assert {:ok, result} = ClickHouseSQL.query_one("first_user.sql") + test "clickhouse: returns result on exactly one result" do + result = ClickHouseSQL.query_one("first_user.sql") assert result.id == 1 assert result.name == "Alice" # query/3 is an alias for query_one/3 - assert {:ok, ^result} = ClickHouseSQL.query("first_user.sql") + assert ClickHouseSQL.query("first_user.sql") == result end - test "clickhouse: returns {:ok, nil} when no results" do - assert {:ok, nil} = ClickHouseSQL.query_one("no_users.sql") + test "clickhouse: returns nil when no results" do + assert ClickHouseSQL.query_one("no_users.sql") == nil # query/3 is an alias for query_one/3 - assert {:ok, nil} = ClickHouseSQL.query("no_users.sql") + assert ClickHouseSQL.query("no_users.sql") == nil end - test "clickhouse: returns {:error, MultipleResultsError} when multiple results" do - assert {:error, %SqlKit.MultipleResultsError{count: 3}} = - ClickHouseSQL.query_one("all_users.sql") + test "clickhouse: raises MultipleResultsError when multiple results" do + assert_raise SqlKit.MultipleResultsError, ~r/got 3/, fn -> + ClickHouseSQL.query_one("all_users.sql") + end # query/3 is an alias for query_one/3 - assert {:error, %SqlKit.MultipleResultsError{count: 3}} = - ClickHouseSQL.query("all_users.sql") + assert_raise SqlKit.MultipleResultsError, fn -> + ClickHouseSQL.query("all_users.sql") + end end - test "clickhouse: returns {:error, exception} on query error" do - assert {:error, %RuntimeError{}} = ClickHouseSQL.query_one("nonexistent.sql") + test "clickhouse: raises on file load error" do + assert_raise RuntimeError, ~r/was not included in the :files list/, fn -> + ClickHouseSQL.query_one("nonexistent.sql") + end # query/3 is an alias for query_one/3 - assert {:error, %RuntimeError{}} = ClickHouseSQL.query("nonexistent.sql") + assert_raise RuntimeError, fn -> + ClickHouseSQL.query("nonexistent.sql") + end end test "clickhouse: casts to struct with :as option" do - assert {:ok, %User{name: "Alice"}} = ClickHouseSQL.query_one("first_user.sql", [], as: User) + assert %User{name: "Alice"} = ClickHouseSQL.query_one("first_user.sql", [], as: User) # query/3 is an alias for query_one/3 - assert {:ok, %User{name: "Alice"}} = ClickHouseSQL.query("first_user.sql", [], as: User) + assert %User{name: "Alice"} = ClickHouseSQL.query("first_user.sql", [], as: User) end end @@ -515,16 +499,9 @@ defmodule SqlKitTest do setup_sandbox(@repo) end - test "#{adapter}: query_all! with multiple parameters" do - # age >= 26 AND age <= 32 should return only Alice (30) - results = @sql_module.query_all!("users_by_age_range.sql", [26, 32]) - assert length(results) == 1 - assert hd(results).name == "Alice" - end - test "#{adapter}: query_all with multiple parameters" do # age >= 24 AND age <= 31 should return Alice (30) and Bob (25) - assert {:ok, results} = @sql_module.query_all("users_by_age_range.sql", [24, 31]) + results = @sql_module.query_all("users_by_age_range.sql", [24, 31]) assert length(results) == 2 names = Enum.map(results, & &1.name) assert "Alice" in names @@ -540,20 +517,14 @@ defmodule SqlKitTest do test "#{adapter}: query_one with multiple parameters" do # age >= 29 AND age <= 31 should return only Alice (30) - assert {:ok, result} = @sql_module.query_one("users_by_age_range.sql", [29, 31]) + result = @sql_module.query_one("users_by_age_range.sql", [29, 31]) assert result.name == "Alice" end end # ClickHouse tests (no sandbox, uses map params) - test "clickhouse: query_all! with multiple parameters" do - results = ClickHouseSQL.query_all!("users_by_age_range.sql", %{min_age: 26, max_age: 32}) - assert length(results) == 1 - assert hd(results).name == "Alice" - end - test "clickhouse: query_all with multiple parameters" do - assert {:ok, results} = ClickHouseSQL.query_all("users_by_age_range.sql", %{min_age: 24, max_age: 31}) + results = ClickHouseSQL.query_all("users_by_age_range.sql", %{min_age: 24, max_age: 31}) assert length(results) == 2 names = Enum.map(results, & &1.name) assert "Alice" in names @@ -567,7 +538,7 @@ defmodule SqlKitTest do end test "clickhouse: query_one with multiple parameters" do - assert {:ok, result} = ClickHouseSQL.query_one("users_by_age_range.sql", %{min_age: 29, max_age: 31}) + result = ClickHouseSQL.query_one("users_by_age_range.sql", %{min_age: 29, max_age: 31}) assert result.name == "Alice" end end @@ -597,7 +568,7 @@ defmodule SqlKitTest do defp param_placeholder(:sqlite, _n), do: "?" defp param_placeholder(:tds, n), do: "@#{n}" - describe "SqlKit.query_all!/4 (standalone)" do + describe "SqlKit.query_all/4 (standalone)" do for {adapter, _sql_module, repo} <- @sandbox_adapters do @repo repo @adapter adapter @@ -607,7 +578,7 @@ defmodule SqlKitTest do end test "#{adapter}: executes SQL string directly" do - results = SqlKit.query_all!(@repo, "SELECT * FROM users ORDER BY id") + results = SqlKit.query_all(@repo, "SELECT * FROM users ORDER BY id") assert length(results) == 3 assert hd(results).name == "Alice" @@ -615,14 +586,14 @@ defmodule SqlKitTest do test "#{adapter}: supports parameterized queries" do placeholder = param_placeholder(@adapter, 1) - results = SqlKit.query_all!(@repo, "SELECT * FROM users WHERE id = #{placeholder}", [1]) + results = SqlKit.query_all(@repo, "SELECT * FROM users WHERE id = #{placeholder}", [1]) assert length(results) == 1 assert hd(results).name == "Alice" end test "#{adapter}: supports :as option for struct casting" do - results = SqlKit.query_all!(@repo, "SELECT * FROM users ORDER BY id", [], as: User) + results = SqlKit.query_all(@repo, "SELECT * FROM users ORDER BY id", [], as: User) assert length(results) == 3 assert Enum.all?(results, &match?(%User{}, &1)) @@ -674,26 +645,7 @@ defmodule SqlKitTest do end end - describe "SqlKit.query_all/4 (standalone non-bang)" do - for {adapter, _sql_module, repo} <- @sandbox_adapters do - @repo repo - - setup do - setup_sandbox(@repo) - end - - test "#{adapter}: returns {:ok, results} on success" do - assert {:ok, results} = SqlKit.query_all(@repo, "SELECT * FROM users ORDER BY id") - assert length(results) == 3 - end - - test "#{adapter}: returns {:error, exception} on query error" do - assert {:error, _} = SqlKit.query_all(@repo, "SELECT * FROM nonexistent_table") - end - end - end - - describe "SqlKit.query_one/4 (standalone non-bang)" do + describe "SqlKit.query_one/4 (standalone)" do for {adapter, _sql_module, repo} <- @sandbox_adapters do @repo repo @adapter adapter @@ -702,20 +654,21 @@ defmodule SqlKitTest do setup_sandbox(@repo) end - test "#{adapter}: returns {:ok, result} on exactly one result" do + test "#{adapter}: returns result on exactly one result" do placeholder = param_placeholder(@adapter, 1) - assert {:ok, result} = SqlKit.query_one(@repo, "SELECT * FROM users WHERE id = #{placeholder}", [1]) + result = SqlKit.query_one(@repo, "SELECT * FROM users WHERE id = #{placeholder}", [1]) assert result.name == "Alice" end - test "#{adapter}: returns {:ok, nil} when no results" do + test "#{adapter}: returns nil when no results" do placeholder = param_placeholder(@adapter, 1) - assert {:ok, nil} = SqlKit.query_one(@repo, "SELECT * FROM users WHERE id = #{placeholder}", [999]) + assert SqlKit.query_one(@repo, "SELECT * FROM users WHERE id = #{placeholder}", [999]) == nil end - test "#{adapter}: returns {:error, MultipleResultsError} when multiple results" do - assert {:error, %SqlKit.MultipleResultsError{}} = - SqlKit.query_one(@repo, "SELECT * FROM users") + test "#{adapter}: raises MultipleResultsError when multiple results" do + assert_raise SqlKit.MultipleResultsError, fn -> + SqlKit.query_one(@repo, "SELECT * FROM users") + end end end end @@ -732,8 +685,8 @@ defmodule SqlKitTest do end test "query/4 is an alias for query_one/4" do - {:ok, result1} = SqlKit.query(PostgresRepo, "SELECT * FROM users WHERE id = $1", [1]) - {:ok, result2} = SqlKit.query_one(PostgresRepo, "SELECT * FROM users WHERE id = $1", [1]) + result1 = SqlKit.query(PostgresRepo, "SELECT * FROM users WHERE id = $1", [1]) + result2 = SqlKit.query_one(PostgresRepo, "SELECT * FROM users WHERE id = $1", [1]) assert result1 == result2 end end From f4d88e42b5de9dcd48544aaf60306ddb7c560258 Mon Sep 17 00:00:00 2001 From: Tyler Barker Date: Mon, 29 Dec 2025 17:30:22 +1100 Subject: [PATCH 2/3] Add missing .db postfixes to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index de5dd2f..2d35619 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ sql_kit-*.tar # SQLite / DuckDB *.db +*.db-shm +*.db-wal *.duckdb # General From df93576eaff8a42effa11b10b4a29d2fed478291 Mon Sep 17 00:00:00 2001 From: Tyler Barker Date: Mon, 29 Dec 2025 17:41:58 +1100 Subject: [PATCH 3/3] Show example code earlier in the README --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a4f63aa..a6938e2 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,46 @@ SqlKit provides two ways to execute SQL with automatic result transformation: 1. **Direct SQL execution** - Execute SQL strings directly with any Ecto repo 2. **File-based SQL** - Keep SQL in dedicated files with compile-time embedding +```elixir +# Direct SQL execution +defmodule MyApp.Accounts do + alias MyApp.Accounts.User + + def get_active_users(company_id, min_age) do + SqlKit.query_all(MyApp.Repo, """ + SELECT id, name, email, age + FROM users + WHERE company_id = $1 + AND age >= $2 + AND active = true + ORDER BY name + """, [company_id, min_age], as: User) + end +end + +# File-based SQL +defmodule MyApp.Accounts.SQL do + use SqlKit, + otp_app: :my_app, + repo: MyApp.Repo, + dirname: "accounts", + files: ["active_users.sql", "another_query.sql"] +end + +defmodule MyApp.Accounts do + alias MyApp.Accounts.SQL + alias MyApp.Accounts.User + + def get_active_users(company_id, min_age) do + SQL.query_all("active_users.sql", [company_id, min_age], as: User) + end +end + +# Usage +MyApp.Accounts.get_active_users(123, 21) +# => [%User{id: 1, name: "Alice", email: "alice@example.com", age: 30}, ...] +``` + ## Why? Sometimes raw SQL is the right tool for the job. Complex analytical queries, reports with intricate joins, or database-specific features often demand SQL that's awkward to express through an ORM. @@ -320,8 +360,8 @@ For databases using positional parameters, wrap SqlKit calls in functions to get ```elixir # SQL string -defmodule MyApp.Users do - alias MyApp.Users.User +defmodule MyApp.Accounts do + alias MyApp.Accounts.User def get_active_users(company_id, min_age) do SqlKit.query_all(MyApp.Repo, """ @@ -336,16 +376,17 @@ defmodule MyApp.Users do end # SQL file -defmodule MyApp.Users do - alias MyApp.Users.User +defmodule MyApp.Accounts do + alias MyApp.Accounts.SQL # `use SqlKit` module + alias MyApp.Accounts.User def get_active_users(company_id, min_age) do - MyApp.Users.SQL.query_all("active_users.sql", [company_id, min_age], as: User) + SQL.query_all("active_users.sql", [company_id, min_age], as: User) end end # Usage -MyApp.Users.get_active_users(123, 21) +MyApp.Accounts.get_active_users(123, 21) # => [%User{id: 1, name: "Alice", email: "alice@example.com", age: 30}, ...] ```