From 99c6cef879a25a4fe025bbff304d8d85a1f81cce Mon Sep 17 00:00:00 2001 From: Renato Massaro Date: Sat, 26 Apr 2025 20:30:42 -0300 Subject: [PATCH 1/6] Remove outdated comments --- lib/feeb/db/boot.ex | 1 - lib/feeb/db/schema.ex | 5 ----- 2 files changed, 6 deletions(-) diff --git a/lib/feeb/db/boot.ex b/lib/feeb/db/boot.ex index 26280c0..f3af74f 100644 --- a/lib/feeb/db/boot.ex +++ b/lib/feeb/db/boot.ex @@ -125,7 +125,6 @@ defmodule Feeb.DB.Boot do end def get_all_models do - # TODO: Schema.List should be generated from a JSON that lives in the application's priv/ folder Schema.List.all() |> Enum.map(fn {context, modules} -> modules_details = diff --git a/lib/feeb/db/schema.ex b/lib/feeb/db/schema.ex index f75af95..7b3f957 100644 --- a/lib/feeb/db/schema.ex +++ b/lib/feeb/db/schema.ex @@ -109,11 +109,6 @@ defmodule Feeb.DB.Schema do end defmacro __after_compile__(env, _module) do - # santiy_checks() - # TODO: - # - Quais checks? - # - Que as env vars estao setadas - assert_env = fn var -> if is_nil(Module.get_attribute(env.module, var)), do: raise("Missing @#{var} attribute in #{env.module}") From d52bcefc6407378ddc1e65533f1ce8c69cdfcd95 Mon Sep 17 00:00:00 2001 From: Renato Massaro Date: Sun, 27 Apr 2025 08:23:42 -0300 Subject: [PATCH 2/6] Add support for custom/composite PKs in the Schema definition --- lib/feeb/db/schema.ex | 7 +++++++ test/support/db/schemas/all_types.ex | 2 ++ 2 files changed, 9 insertions(+) diff --git a/lib/feeb/db/schema.ex b/lib/feeb/db/schema.ex index 7b3f957..67eb6ea 100644 --- a/lib/feeb/db/schema.ex +++ b/lib/feeb/db/schema.ex @@ -93,6 +93,12 @@ defmodule Feeb.DB.Schema do @derived_fields [] end + if :primary_keys not in Module.attributes_in(__MODULE__) do + # We default to `[:id]` as primary keys iff the schema does not define custom PKs. We can't + # use an `is_nil/1` check because @primary_keys nil should not be overriden. + @primary_keys [:id] + end + defstruct Map.keys(@schema) ++ unquote(meta_keys) # TODO: Inline? @@ -105,6 +111,7 @@ defmodule Feeb.DB.Schema do def __context__, do: @context def __modded_fields__, do: @modded_fields def __derived_fields__, do: @derived_fields + def __primary_keys__, do: @primary_keys end end diff --git a/test/support/db/schemas/all_types.ex b/test/support/db/schemas/all_types.ex index c809d2f..6c42731 100644 --- a/test/support/db/schemas/all_types.ex +++ b/test/support/db/schemas/all_types.ex @@ -5,6 +5,8 @@ defmodule Sample.AllTypes do @context :test @table :all_types + @primary_keys nil + @schema [ {:boolean, :boolean}, {:boolean_nullable, {:boolean, nullable: true}}, From f6f244b4b1f62662914505138db9dabed91f7d18 Mon Sep 17 00:00:00 2001 From: Renato Massaro Date: Sun, 27 Apr 2025 08:24:55 -0300 Subject: [PATCH 3/6] Add sample Schema with composite PKs (OrderItems) --- priv/test/feebdb_schemas.json | 2 +- .../test/250426163848_order_items.sql | 9 ++++++ priv/test/queries/test/order_items.sql | 0 test/db/boot_test.exs | 2 +- test/support/db/schemas/order_items.ex | 28 +++++++++++++++++++ 5 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 priv/test/migrations/test/250426163848_order_items.sql create mode 100644 priv/test/queries/test/order_items.sql create mode 100644 test/support/db/schemas/order_items.ex diff --git a/priv/test/feebdb_schemas.json b/priv/test/feebdb_schemas.json index 72dadd9..8600636 100644 --- a/priv/test/feebdb_schemas.json +++ b/priv/test/feebdb_schemas.json @@ -1 +1 @@ -{"test":["Elixir.Sample.AllTypes","Elixir.Sample.CustomTypes","Elixir.Sample.Friend","Elixir.Sample.Post"]} +{"test":["Elixir.Sample.AllTypes","Elixir.Sample.CustomTypes","Elixir.Sample.Friend","Elixir.Sample.OrderItems","Elixir.Sample.Post"]} diff --git a/priv/test/migrations/test/250426163848_order_items.sql b/priv/test/migrations/test/250426163848_order_items.sql new file mode 100644 index 0000000..c771654 --- /dev/null +++ b/priv/test/migrations/test/250426163848_order_items.sql @@ -0,0 +1,9 @@ +CREATE TABLE order_items ( + order_id INTEGER, + product_id INTEGER, + quantity INTEGER, + price INTEGER, + inserted_at TEXT, + updated_at TEXT, + PRIMARY KEY (order_id, product_id) +) STRICT; diff --git a/priv/test/queries/test/order_items.sql b/priv/test/queries/test/order_items.sql new file mode 100644 index 0000000..e69de29 diff --git a/test/db/boot_test.exs b/test/db/boot_test.exs index 6725126..0eceeb9 100644 --- a/test/db/boot_test.exs +++ b/test/db/boot_test.exs @@ -24,7 +24,7 @@ defmodule Feeb.DB.BootTest do # Test migrations rarely change and thus can be hard-coded here (string because it gets # formatted to 123_456_789_012 otherwise, which is hard to read). - expected_test_latest = "241108191351" |> String.to_integer() + expected_test_latest = "250426163848" |> String.to_integer() test_latest = Migrator.get_latest_version(:test) assert test_latest == expected_test_latest diff --git a/test/support/db/schemas/order_items.ex b/test/support/db/schemas/order_items.ex new file mode 100644 index 0000000..1d389ed --- /dev/null +++ b/test/support/db/schemas/order_items.ex @@ -0,0 +1,28 @@ +defmodule Sample.OrderItems do + use Feeb.DB.Schema + + @context :test + @table :order_items + + @primary_keys [:order_id, :product_id] + + @schema [ + {:order_id, :integer}, + {:product_id, :integer}, + {:quantity, :integer}, + {:price, :integer}, + {:inserted_at, {:datetime_utc, [], mod: :inserted_at}}, + {:updated_at, {:datetime_utc, [], mod: :updated_at}} + ] + + def new(params) do + params + |> Schema.cast(:all) + |> Schema.create() + end + + def update(%_{} = row, changes) do + row + |> Schema.update(changes) + end +end From b22d94b6a7ad8c871c05a119b9df396f5fa6c033 Mon Sep 17 00:00:00 2001 From: Renato Massaro Date: Sun, 27 Apr 2025 08:26:19 -0300 Subject: [PATCH 4/6] Add support for custom/composite PKs on adhoc queries --- lib/feeb/db/query.ex | 74 +++++++++++---- lib/feeb/db/repo.ex | 12 +-- lib/feeb/db/schema.ex | 3 + test/db/db_test.exs | 53 ++++++++++- test/db/query_test.exs | 204 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 315 insertions(+), 31 deletions(-) diff --git a/lib/feeb/db/query.ex b/lib/feeb/db/query.ex index e1a717c..e01e87a 100644 --- a/lib/feeb/db/query.ex +++ b/lib/feeb/db/query.ex @@ -1,5 +1,6 @@ defmodule Feeb.DB.Query do require Logger + alias Feeb.DB.Schema alias __MODULE__.Binding @initial_q {"", {[], []}, nil} @@ -53,10 +54,14 @@ defmodule Feeb.DB.Query do adhoc_query_id end - def get_templated_query_id({context, domain, :__insert}, target_fields, meta) do + def get_templated_query_id(query_id, target_fields, meta \\ %{}) + + def get_templated_query_id({context, domain, :__insert} = query_id, target_fields, _meta) do + model = Schema.get_model_from_query_id(query_id) + target_fields = if target_fields == :all do - meta.schema.__cols__() + model.__cols__() else raise "Not supported for now, add & test it once needed" target_fields @@ -70,11 +75,14 @@ defmodule Feeb.DB.Query do real_query_id nil -> - compile_templated_query(:__insert, real_query_id, target_fields) + compile_templated_query(:__insert, real_query_id, target_fields, model) end end - def get_templated_query_id({context, domain, :__update}, target_fields, _meta) do + def get_templated_query_id({context, domain, :__update} = query_id, target_fields, _meta) do + model = Schema.get_model_from_query_id(query_id) + + # TODO: Transparently include `updated_at` (if present in the model) target_fields = Enum.sort(target_fields) query_name_suffix = @@ -92,22 +100,24 @@ defmodule Feeb.DB.Query do real_query_id nil -> - compile_templated_query(:__update, real_query_id, target_fields) + compile_templated_query(:__update, real_query_id, target_fields, model) end end def get_templated_query_id({_context, _domain, query_name} = query_id, target_fields, _meta) when query_name in [:__all, :__fetch, :__delete] do + model = Schema.get_model_from_query_id(query_id) + case get(query_id) do {_, _, _} = _compiled_query -> query_id nil -> - compile_templated_query(query_name, query_id, target_fields) + compile_templated_query(query_name, query_id, target_fields, model) end end - defp compile_templated_query(:__insert, {_, domain, _} = query_id, target_fields) do + defp compile_templated_query(:__insert, {_, domain, _} = query_id, target_fields, _model) do columns_clause = target_fields |> Enum.join(", ") values_clause = @@ -123,11 +133,11 @@ defmodule Feeb.DB.Query do query_id end - defp compile_templated_query(:__update, {_, domain, _} = query_id, target_fields) do - # Hard-coded for now, but we may need this to be dynamc in the future - primary_key_col = :id + defp compile_templated_query(:__update, {_, domain, _} = query_id, target_fields, model) do + primary_keys = model.__primary_keys__() + assert_adhoc_query!(primary_keys, query_id, model) - set_clause = + set_conditions = target_fields |> Enum.reduce([], fn field, acc -> ["#{field} = ?" | acc] @@ -135,30 +145,36 @@ defmodule Feeb.DB.Query do |> Enum.reverse() |> Enum.join(", ") - sql = "UPDATE #{domain} SET #{set_clause} WHERE id = ?;" - adhoc_query = {sql, {[], target_fields ++ [primary_key_col]}, :update} + sql = "UPDATE #{domain} SET #{set_conditions} #{generate_where_clause(primary_keys)};" + adhoc_query = {sql, {[], target_fields ++ primary_keys}, :update} append_runtime_query(query_id, adhoc_query) query_id end - defp compile_templated_query(:__all, {_, domain, _} = query_id, _target_fields) do + defp compile_templated_query(:__all, {_, domain, _} = query_id, _target_fields, _model) do sql = "SELECT * FROM #{domain};" adhoc_query = {sql, {[:*], []}, :select} append_runtime_query(query_id, adhoc_query) query_id end - defp compile_templated_query(:__fetch, {_, domain, _} = query_id, _target_fields) do - sql = "SELECT * FROM #{domain} WHERE id = ?;" - adhoc_query = {sql, {[:*], [:id]}, :select} + defp compile_templated_query(:__fetch, {_, domain, _} = query_id, _target_fields, model) do + primary_keys = model.__primary_keys__() + assert_adhoc_query!(primary_keys, query_id, model) + sql = "SELECT * FROM #{domain} #{generate_where_clause(primary_keys)};" + + adhoc_query = {sql, {[:*], primary_keys}, :select} append_runtime_query(query_id, adhoc_query) query_id end - defp compile_templated_query(:__delete, {_, domain, _} = query_id, _target_fields) do - sql = "DELETE FROM #{domain} WHERE id = ?;" - adhoc_query = {sql, {[], [:id]}, :delete} + defp compile_templated_query(:__delete, {_, domain, _} = query_id, _target_fields, model) do + primary_keys = model.__primary_keys__() + assert_adhoc_query!(primary_keys, query_id, model) + sql = "DELETE FROM #{domain} #{generate_where_clause(primary_keys)};" + + adhoc_query = {sql, {[], primary_keys}, :delete} append_runtime_query(query_id, adhoc_query) query_id end @@ -235,6 +251,24 @@ defmodule Feeb.DB.Query do defp get_returning_fields({_, _, operation}) when operation in [:update, :delete], do: "*" + defp generate_where_clause(primary_keys) when is_list(primary_keys) do + where_conditions = + primary_keys + |> Enum.reduce([], fn field, acc -> + ["#{field} = ?" | acc] + end) + |> Enum.reverse() + |> Enum.join(" AND ") + + "WHERE #{where_conditions}" + end + + defp assert_adhoc_query!(nil, query_id, model) do + raise("Can't generate adhoc query #{inspect(query_id)} because #{inspect(model)} has no PKs") + end + + defp assert_adhoc_query!(_, _, _), do: :ok + # Line-break defp handle_line(<<>>, qs, id, q) when not is_nil(id) do {sql, {fields_bindings, params_bindings}, query_type} = q diff --git a/lib/feeb/db/repo.ex b/lib/feeb/db/repo.ex index 291db82..fbf8de5 100644 --- a/lib/feeb/db/repo.ex +++ b/lib/feeb/db/repo.ex @@ -345,22 +345,22 @@ defmodule Feeb.DB.Repo do defp create_schema_from_rows({_, :pragma, _}, _, rows), do: rows defp create_schema_from_rows(query_id, {_, {fields_bindings, _}, :select}, rows) do - model = get_model_from_query_id(query_id) + model = Schema.get_model_from_query_id(query_id) Enum.map(rows, fn row -> Schema.from_row(model, fields_bindings, row) end) end defp create_schema_from_rows(query_id, {_, {_, params_bindings}, :insert}, rows) do - model = get_model_from_query_id(query_id) + model = Schema.get_model_from_query_id(query_id) Enum.map(rows, fn row -> Schema.from_row(model, params_bindings, row) end) end defp create_schema_from_rows(query_id, {_, _, :update}, rows) do - model = get_model_from_query_id(query_id) + model = Schema.get_model_from_query_id(query_id) Enum.map(rows, fn row -> Schema.from_row(model, model.__cols__(), row) end) end defp create_schema_from_rows(query_id, {_, _, :delete}, rows) do - model = get_model_from_query_id(query_id) + model = Schema.get_model_from_query_id(query_id) Enum.map(rows, fn row -> Schema.from_row(model, model.__cols__(), row) end) end @@ -399,10 +399,6 @@ defmodule Feeb.DB.Repo do |> trunc() end - defp get_model_from_query_id({context, domain, _}) do - :persistent_term.get({:db_table_models, {context, domain}}) - end - # TODO: These pragma functions will be removed once that gets turned into a hook def conditional_pragma_based_on_env(conn, :test, _), do: custom_pragma_for_test(conn) def conditional_pragma_based_on_env(conn, _, true), do: custom_pragma_for_test(conn) diff --git a/lib/feeb/db/schema.ex b/lib/feeb/db/schema.ex index 67eb6ea..98d88f1 100644 --- a/lib/feeb/db/schema.ex +++ b/lib/feeb/db/schema.ex @@ -366,6 +366,9 @@ defmodule Feeb.DB.Schema do def get_private(%{__private__: private}, k), do: private[k] def get_private!(%{__private__: private}, k), do: Map.fetch!(private, k) + def get_model_from_query_id({context, table, _}), + do: :persistent_term.get({:db_table_models, {context, table}}) + defp add_missing_values(struct, f, f), do: struct defp add_missing_values(struct, all_fields, added_fields) do diff --git a/test/db/db_test.exs b/test/db/db_test.exs index 5cd7c96..0d21157 100644 --- a/test/db/db_test.exs +++ b/test/db/db_test.exs @@ -2,7 +2,7 @@ defmodule Feeb.DBTest do use Test.Feeb.DBCase, async: true alias Feeb.DB, as: DB alias Feeb.DB.LocalState - alias Sample.{AllTypes, CustomTypes, Friend, Post} + alias Sample.{AllTypes, CustomTypes, Friend, OrderItems, Post} alias Sample.Types.TypedID @context :test @@ -406,6 +406,29 @@ defmodule Feeb.DBTest do assert nil == DB.one({:friends, :get_by_id}, [0]) end + test ":fetch templated query works", %{shard_id: shard_id} do + DB.begin(@context, shard_id, :write) + + assert %{id: 1, name: "Phoebe"} = DB.one({:friends, :fetch}, [1]) + assert nil == DB.one({:friends, :fetch}, [500]) + end + + test ":fetch templated query works on schema with composite PKs", %{shard_id: shard_id} do + DB.begin(@context, shard_id, :write) + + %{order_id: 1, product_id: 2, quantity: 10, price: 50} + |> OrderItems.new() + |> DB.insert!() + + order_item = DB.one({:order_items, :fetch}, [1, 2]) + assert order_item.order_id == 1 + assert order_item.product_id == 2 + assert order_item.quantity == 10 + assert order_item.price == 50 + + assert nil == DB.one({:order_items, :fetch}, [2, 1]) + end + test "supports the :format flag", %{shard_id: shard_id} do DB.begin(@context, shard_id, :write) @@ -560,6 +583,20 @@ defmodule Feeb.DBTest do refute post.updated_at == new_post.updated_at end + test "updates the struct on schema with composite PKs", %{shard_id: shard_id} do + DB.begin(@context, shard_id, :write) + + order_item = + %{order_id: 1, product_id: 2, quantity: 10, price: 50} + |> OrderItems.new() + |> DB.insert!() + + assert {:ok, %{order_id: 1, product_id: 2, quantity: 20}} = + order_item + |> OrderItems.update(%{quantity: 20}) + |> DB.update() + end + test "update attempt that failed to find a matching entry", %{shard_id: shard_id} do DB.begin(@context, shard_id, :write) @@ -655,6 +692,20 @@ defmodule Feeb.DBTest do refute DB.one({:friends, :get_by_id}, [1]) end + test "deletes the struct (schema with composite PKs)", %{shard_id: shard_id} do + DB.begin(@context, shard_id, :write) + + order_item = + %{order_id: 1, product_id: 2, quantity: 10, price: 50} + |> OrderItems.new() + |> DB.insert!() + + assert {:ok, _} = DB.delete(order_item) + + # OrderItem has been deleted + assert [] == DB.all(OrderItems) + end + test "delete attempt that failed to find a matching entry", %{shard_id: shard_id} do DB.begin(@context, shard_id, :write) friend = DB.one({:friends, :get_by_id}, [1]) diff --git a/test/db/query_test.exs b/test/db/query_test.exs index d7fee19..5a3b333 100644 --- a/test/db/query_test.exs +++ b/test/db/query_test.exs @@ -1,13 +1,25 @@ defmodule Feeb.DB.QueryTest do - use ExUnit.Case, async: true + # Reason for `async: false`: this test suite interacts directly with the compiled queries cache. + # While it's technically possible to adapt the cache to be per-test, I don't think it's worth the + # added complexity. As such, I'd rather have this test suite run separately from the rest. + use ExUnit.Case, async: false + import ExUnit.CaptureLog alias Feeb.DB.{Query} @queries_path "priv/test/queries" @chaos_path "#{@queries_path}/test/chaos.sql" + @friends_path "#{@queries_path}/test/friends.sql" + @order_items_path "#{@queries_path}/test/order_items.sql" + @all_types_path "#{@queries_path}/test/all_types.sql" + + setup do + # Ensure that each test starts with a "clean slate" + erase_all_query_caches() + :ok + end describe "compile/2" do - @tag capture_log: true test "handles chaos.sql file" do Query.compile(@chaos_path, {:test, :chaos}) @@ -23,6 +35,184 @@ defmodule Feeb.DB.QueryTest do assert_chaos_query(query_name, Query.fetch!({:test, :chaos, query_name})) end) end + + test "raises a warning if the same .sql file is compiled multiple times" do + Query.compile(@chaos_path, {:test, :chaos}) + + log = + capture_log(fn -> + Query.compile(@chaos_path, {:test, :chaos}) + end) + + assert log =~ "[warning] Recompiling queries for the \"chaos\" domain" + end + end + + describe "get_templated_query_id/3" do + test ":__all" do + Query.compile(@friends_path, {:test, :friends}) + + # Ensures the :__fetch is compiled (due to it being an "ad-hoc" query) + query_id = {:test, :friends, :__all} + Query.get_templated_query_id(query_id, []) + + assert {sql, {target_fields, bindings}, query_type} = Query.fetch!(query_id) + assert query_type == :select + assert target_fields == [:*] + assert bindings == [] + assert sql == "SELECT * FROM friends;" + end + + test ":__fetch" do + Query.compile(@friends_path, {:test, :friends}) + + query_id = {:test, :friends, :__fetch} + Query.get_templated_query_id(query_id, []) + + assert {sql, {target_fields, bindings}, query_type} = Query.fetch!(query_id) + assert query_type == :select + assert target_fields == [:*] + assert bindings == [:id] + assert sql == "SELECT * FROM friends WHERE id = ?;" + end + + test ":__fetch - with composite PK" do + Query.compile(@order_items_path, {:test, :order_items}) + + query_id = {:test, :order_items, :__fetch} + Query.get_templated_query_id(query_id, []) + + assert {sql, {target_fields, bindings}, query_type} = Query.fetch!(query_id) + assert query_type == :select + assert target_fields == [:*] + assert bindings == [:order_id, :product_id] + assert sql == "SELECT * FROM order_items WHERE order_id = ? AND product_id = ?;" + end + + test ":__fetch - raises when schema has no PK" do + Query.compile(@all_types_path, {:test, :all_types}) + + query_id = {:test, :all_types, :__fetch} + + %{message: error} = + assert_raise RuntimeError, fn -> + Query.get_templated_query_id(query_id, []) + end + + assert error =~ "Can't generate adhoc query" + assert error =~ ":__fetch" + assert error =~ "because Sample.AllTypes has no PKs" + end + + test ":__insert - targeting :all fields" do + Query.compile(@friends_path, {:test, :friends}) + + query_id = {:test, :friends, :__insert} + Query.get_templated_query_id(query_id, :all) + + assert {sql, {target_fields, bindings}, query_type} = Query.fetch!(query_id) + assert query_type == :insert + assert target_fields == [] + assert bindings == [:id, :name, :sibling_count] + assert sql == "INSERT INTO friends ( id, name, sibling_count ) VALUES ( ?, ?, ? );" + end + + test ":__update" do + Query.compile(@friends_path, {:test, :friends}) + + query_id = Query.get_templated_query_id({:test, :friends, :__update}, [:name]) + + assert {sql, {target_fields, bindings}, query_type} = Query.fetch!(query_id) + assert query_type == :update + assert target_fields == [] + assert bindings == [:name, :id] + assert sql == "UPDATE friends SET name = ? WHERE id = ?;" + end + + test ":__update - multiple fields updated at once" do + Query.compile(@friends_path, {:test, :friends}) + + query_id = + Query.get_templated_query_id({:test, :friends, :__update}, [:name, :sibling_count]) + + assert {sql, {target_fields, bindings}, query_type} = Query.fetch!(query_id) + assert query_type == :update + assert target_fields == [] + assert bindings == [:name, :sibling_count, :id] + assert sql == "UPDATE friends SET name = ?, sibling_count = ? WHERE id = ?;" + + # Target fields are sorted, so the generated query_id is always the same + assert query_id == + Query.get_templated_query_id({:test, :friends, :__update}, [:sibling_count, :name]) + end + + test ":__update - with composite PK" do + Query.compile(@order_items_path, {:test, :order_items}) + + query_id = Query.get_templated_query_id({:test, :order_items, :__update}, [:quantity]) + + assert {sql, {target_fields, bindings}, query_type} = Query.fetch!(query_id) + assert query_type == :update + assert target_fields == [] + assert bindings == [:quantity, :order_id, :product_id] + assert sql == "UPDATE order_items SET quantity = ? WHERE order_id = ? AND product_id = ?;" + end + + test ":__update - raises when schema has no PK" do + Query.compile(@all_types_path, {:test, :all_types}) + + query_id = {:test, :all_types, :__update} + + %{message: error} = + assert_raise RuntimeError, fn -> + Query.get_templated_query_id(query_id, [:boolean]) + end + + assert error =~ "Can't generate adhoc query" + assert error =~ ":\"__update$boolean\"" + assert error =~ "because Sample.AllTypes has no PKs" + end + + test ":__delete" do + Query.compile(@friends_path, {:test, :friends}) + + query_id = {:test, :friends, :__delete} + Query.get_templated_query_id(query_id, []) + + assert {sql, {target_fields, bindings}, query_type} = Query.fetch!(query_id) + assert query_type == :delete + assert target_fields == [] + assert bindings == [:id] + assert sql == "DELETE FROM friends WHERE id = ?;" + end + + test ":__delete - with composite PK" do + Query.compile(@order_items_path, {:test, :order_items}) + + query_id = {:test, :order_items, :__delete} + Query.get_templated_query_id(query_id, []) + + assert {sql, {target_fields, bindings}, query_type} = Query.fetch!(query_id) + assert query_type == :delete + assert target_fields == [] + assert bindings == [:order_id, :product_id] + assert sql == "DELETE FROM order_items WHERE order_id = ? AND product_id = ?;" + end + + test ":__delete - raises when schema has no PK" do + Query.compile(@all_types_path, {:test, :all_types}) + + query_id = {:test, :all_types, :__delete} + + %{message: error} = + assert_raise RuntimeError, fn -> + Query.get_templated_query_id(query_id, []) + end + + assert error =~ "Can't generate adhoc query" + assert error =~ ":__delete" + assert error =~ "because Sample.AllTypes has no PKs" + end end defp assert_chaos_query(:get, query) do @@ -73,4 +263,14 @@ defmodule Feeb.DB.QueryTest do assert params_b == [:acc_id, :email_address] assert qt == :delete end + + defp erase_all_query_caches do + erase_query_cache({:test, :friends}) + erase_query_cache({:test, :chaos}) + erase_query_cache({:test, :order_items}) + erase_query_cache({:test, :all_types}) + end + + defp erase_query_cache({context, domain}), + do: :persistent_term.erase({:db_sql_queries, {context, domain}}) end From 9f4d34d7b939df674116d6859f07ef8f69c8398f Mon Sep 17 00:00:00 2001 From: Renato Massaro Date: Sun, 27 Apr 2025 08:28:17 -0300 Subject: [PATCH 5/6] Add support for custom/composite PKs on `DB.reload/1` --- lib/feeb/db.ex | 5 +---- test/db/db_test.exs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/lib/feeb/db.ex b/lib/feeb/db.ex index 2d464f3..2c26bab 100644 --- a/lib/feeb/db.ex +++ b/lib/feeb/db.ex @@ -237,11 +237,8 @@ defmodule Feeb.DB do end def reload(%schema{} = struct) do - # Support for custom or composite PKs is TODO - primary_key_cols = [:id] - bindings = - Enum.map(primary_key_cols, fn col_name -> + Enum.map(schema.__primary_keys__() || [], fn col_name -> Map.fetch!(struct, col_name) end) diff --git a/test/db/db_test.exs b/test/db/db_test.exs index 0d21157..6f8e539 100644 --- a/test/db/db_test.exs +++ b/test/db/db_test.exs @@ -790,6 +790,24 @@ defmodule Feeb.DBTest do refute reloaded_post.updated_at == post.updated_at end + test "reloads schemas with composite PKs", %{shard_id: shard_id} do + DB.begin(@context, shard_id, :write) + + order_item = + %{order_id: 1, product_id: 2, quantity: 10, price: 50} + |> OrderItems.new() + |> DB.insert!() + + # Now we can expect the order item to have a quantity of 20 + assert {:ok, _} = + order_item + |> OrderItems.update(%{quantity: 20}) + |> DB.update() + + assert reloaded_order_item = DB.reload(order_item) + assert reloaded_order_item.quantity == 20 + end + test "returns nil when the requested schema is not found", %{shard_id: shard_id} do DB.begin(@context, shard_id, :write) @@ -857,6 +875,23 @@ defmodule Feeb.DBTest do # Order is kept assert [nil, %{id: 1}] = DB.reload([post_2, post_1]) end + + test "raises if the requested schema has no PKs", %{shard_id: shard_id} do + DB.begin(@context, shard_id, :write) + + all_types = + AllTypes.creation_params() + |> AllTypes.new() + |> DB.insert!() + + %{message: error} = + assert_raise RuntimeError, fn -> + DB.reload(all_types) + end + + assert error =~ "Can't generate adhoc query" + assert error =~ "because Sample.AllTypes has no PKs" + end end describe "reload!/1" do From 8692009aa3aad540b25279ec79b7e342688042e6 Mon Sep 17 00:00:00 2001 From: Renato Massaro Date: Sun, 27 Apr 2025 08:35:03 -0300 Subject: [PATCH 6/6] Add deprecation notice Mostly a note to myself --- lib/feeb/db/query.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/feeb/db/query.ex b/lib/feeb/db/query.ex index e01e87a..7aaa1a5 100644 --- a/lib/feeb/db/query.ex +++ b/lib/feeb/db/query.ex @@ -36,7 +36,9 @@ defmodule Feeb.DB.Query do Compiling an adhoc query is useful when you want to select custom fields off of a "select *" query. It's like a subset of the original query """ + @spec compile_adhoc_query(term, term) :: no_return def compile_adhoc_query({context, domain, query_name} = query_id, custom_fields) do + raise "Deprecated; consider implementing this feature as part of `get_templated_query_id/3`" query_name = :"#{query_name}$#{Enum.join(custom_fields, "$")}" adhoc_query_id = {context, domain, query_name}