From 81e0602c97dab38448dafa45c99fbe59a97fea0a Mon Sep 17 00:00:00 2001 From: czh <2565523901@qq.com> Date: Mon, 9 Mar 2026 19:11:38 +0800 Subject: [PATCH] feat: add optional Ecto type support for database integration Add Ecto.Type implementations for seamless database integration: - Fsrs.Ecto.CardType - Store card states in database - Fsrs.Ecto.SchedulerType - Store scheduler configurations - Fsrs.Ecto module with usage documentation - Comprehensive tests (only run if Ecto is available) These types are optional and only loaded if Ecto is present, maintaining zero dependencies for users who don't need database support. Includes complete documentation with schema and migration examples. --- lib/lulucat/fsrs/ecto.ex | 52 +++++++++++++++ lib/lulucat/fsrs/ecto/card_type.ex | 66 +++++++++++++++++++ lib/lulucat/fsrs/ecto/scheduler_type.ex | 64 +++++++++++++++++++ test/lulucat/fsrs_ecto/card_type_test.exs | 77 +++++++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 lib/lulucat/fsrs/ecto.ex create mode 100644 lib/lulucat/fsrs/ecto/card_type.ex create mode 100644 lib/lulucat/fsrs/ecto/scheduler_type.ex create mode 100644 test/lulucat/fsrs_ecto/card_type_test.exs 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