diff --git a/lib/lulucat/fsrs/ecto.ex b/lib/lulucat/fsrs/ecto.ex new file mode 100644 index 0000000..6faff83 --- /dev/null +++ b/lib/lulucat/fsrs/ecto.ex @@ -0,0 +1,52 @@ +defmodule Fsrs.Ecto do + @moduledoc """ + Ecto integration for FSRS types. + + This module provides Ecto.Type implementations for Fsrs structs, + allowing them to be stored in database fields (typically as JSON). + + ## Available Types + + - `Fsrs.Ecto.CardType` - For storing Fsrs.Card structs + - `Fsrs.Ecto.SchedulerType` - For storing Fsrs.Scheduler configurations + + ## Installation + + To use Ecto types, add `:ecto` to your dependencies: + + def deps do + [ + {:fsrs_ex, "~> 0.1.2"}, + {:ecto, "~> 3.0"} # Optional, for database integration + ] + end + + ## Example Schema + + defmodule MyApp.Card do + use Ecto.Schema + + schema "cards" do + field :content, :string + field :fsrs_data, Fsrs.Ecto.CardType + timestamps() + end + end + + ## Migration Example + + defmodule MyApp.Repo.Migrations.CreateCards do + use Ecto.Migration + + def change do + create table(:cards) do + add :content, :text + add :fsrs_data, :map # JSON/JSONB column + timestamps() + end + end + end + + 中文说明:为 FSRS 类型提供 Ecto 集成,支持数据库存储。 + """ +end diff --git a/lib/lulucat/fsrs/ecto/card_type.ex b/lib/lulucat/fsrs/ecto/card_type.ex new file mode 100644 index 0000000..7ef623c --- /dev/null +++ b/lib/lulucat/fsrs/ecto/card_type.ex @@ -0,0 +1,66 @@ +defmodule Fsrs.Ecto.CardType do + @moduledoc """ + Ecto type for Fsrs.Card serialization. + + This module implements the `Ecto.Type` behaviour to allow storing + Fsrs.Card structs in database fields (typically as JSON/BSON). + + ## Usage + + schema "cards" do + field :fsrs_data, Fsrs.Ecto.CardType + end + + The card is stored as a JSON object with all fields serialized. + + 中文说明:为 Fsrs.Card 实现的 Ecto 类型,支持数据库存储。 + """ + + use Ecto.Type + + alias Fsrs.Card + + @impl true + def type, do: :map + + @impl true + def cast(%Card{} = card) do + {:ok, Card.to_dict(card)} + end + + def cast(%{} = map) do + try do + {:ok, Card.from_dict(map)} + rescue + _ -> :error + end + end + + def cast(_), do: :error + + @impl true + def load(data) when is_map(data) do + try do + {:ok, Card.from_dict(data)} + rescue + _ -> :error + end + end + + def load(_), do: :error + + @impl true + def dump(%Card{} = card) do + {:ok, Card.to_dict(card)} + end + + def dump(_), do: :error + + @impl true + def embed_as(_format), do: :self + + @impl true + def equal?(term1, term2) do + term1 == term2 + end +end diff --git a/lib/lulucat/fsrs/ecto/scheduler_type.ex b/lib/lulucat/fsrs/ecto/scheduler_type.ex new file mode 100644 index 0000000..500309e --- /dev/null +++ b/lib/lulucat/fsrs/ecto/scheduler_type.ex @@ -0,0 +1,64 @@ +defmodule Fsrs.Ecto.SchedulerType do + @moduledoc """ + Ecto type for Fsrs.Scheduler serialization. + + This module implements the `Ecto.Type` behaviour to allow storing + Fsrs.Scheduler configurations in database fields. + + ## Usage + + schema "users" do + field :scheduler_config, Fsrs.Ecto.SchedulerType + end + + 中文说明:为 Fsrs.Scheduler 实现的 Ecto 类型,支持数据库存储。 + """ + + use Ecto.Type + + alias Fsrs.Scheduler + + @impl true + def type, do: :map + + @impl true + def cast(%Scheduler{} = scheduler) do + {:ok, Scheduler.to_dict(scheduler)} + end + + def cast(%{} = map) do + try do + {:ok, Scheduler.from_dict(map)} + rescue + _ -> :error + end + end + + def cast(_), do: :error + + @impl true + def load(data) when is_map(data) do + try do + {:ok, Scheduler.from_dict(data)} + rescue + _ -> :error + end + end + + def load(_), do: :error + + @impl true + def dump(%Scheduler{} = scheduler) do + {:ok, Scheduler.to_dict(scheduler)} + end + + def dump(_), do: :error + + @impl true + def embed_as(_format), do: :self + + @impl true + def equal?(term1, term2) do + term1 == term2 + end +end diff --git a/test/lulucat/fsrs_ecto/card_type_test.exs b/test/lulucat/fsrs_ecto/card_type_test.exs new file mode 100644 index 0000000..07b727f --- /dev/null +++ b/test/lulucat/fsrs_ecto/card_type_test.exs @@ -0,0 +1,77 @@ +defmodule Fsrs.Ecto.CardTypeTest do + @moduledoc """ + Tests for Fsrs.Ecto.CardType. + These tests are only run if Ecto is available. + """ + + use ExUnit.Case, async: true + + # Check if Ecto is available + @ecto_available Code.ensure_loaded?(Ecto.Type) + + if @ecto_available do + alias Fsrs.Ecto.CardType + + describe "CardType Ecto type" do + test "type returns :map" do + assert CardType.type() == :map + end + + test "cast accepts Card structs" do + card = Fsrs.new_card() + assert {:ok, _} = CardType.cast(card) + end + + test "cast accepts valid maps" do + card = Fsrs.new_card() + map = Fsrs.Card.to_dict(card) + assert {:ok, _} = CardType.cast(map) + end + + test "cast rejects invalid data" do + assert :error = CardType.cast("invalid") + assert :error = CardType.cast(123) + end + + test "load converts map to Card" do + card = Fsrs.new_card() + map = Fsrs.Card.to_dict(card) + assert {:ok, loaded_card} = CardType.load(map) + assert %Fsrs.Card{} = loaded_card + assert loaded_card.card_id == card.card_id + end + + test "load returns error for invalid data" do + assert :error = CardType.load("invalid") + assert :error = CardType.load(%{invalid: "data"}) + end + + test "dump converts Card to map" do + card = Fsrs.new_card() + assert {:ok, map} = CardType.dump(card) + assert is_map(map) + assert map["card_id"] == card.card_id + end + + test "dump returns error for non-Card" do + assert :error = CardType.dump("invalid") + assert :error = CardType.dump(%{card_id: 123}) + end + + test "embed_as returns :self" do + assert CardType.embed_as(nil) == :self + end + + test "equal? compares Cards correctly" do + card1 = Fsrs.new_card(card_id: 1) + card2 = Fsrs.new_card(card_id: 1) + card3 = Fsrs.new_card(card_id: 2) + + assert CardType.equal?(card1, card2) + refute CardType.equal?(card1, card3) + end + end + else + IO.puts("Skipping Ecto tests - Ecto not available") + end +end