diff --git a/backend/lib/richard_burton/author.ex b/backend/lib/richard_burton/author.ex index d8d69a7e..e0365c99 100644 --- a/backend/lib/richard_burton/author.ex +++ b/backend/lib/richard_burton/author.ex @@ -81,6 +81,7 @@ defmodule RichardBurton.Author do def link_fingerprint(changeset = %Ecto.Changeset{valid?: false}), do: changeset + @spec search(binary(), :fuzzy | :prefix) :: any() def search(term, :prefix) when is_binary(term) do from(a in Author, where: ilike(a.name, ^"#{term}%")) |> Repo.all() @@ -97,4 +98,15 @@ defmodule RichardBurton.Author do keywords when is_list(keywords) -> keywords end end + + def nest(authors) when is_binary(authors) do + authors |> String.split(",") |> Enum.map(&%{"name" => String.trim(&1)}) + end + + def flatten(authors) when is_list(authors), do: Enum.map_join(authors, ", ", &get_name/1) + def flatten(authors), do: authors + + def get_name(%Author{name: name}), do: name + def get_name(%{"name" => name}), do: name + def get_name(%{name: name}), do: name end diff --git a/backend/lib/richard_burton/country.ex b/backend/lib/richard_burton/country.ex index 5b9b90db..fc4868d9 100644 --- a/backend/lib/richard_burton/country.ex +++ b/backend/lib/richard_burton/country.ex @@ -1,16 +1,134 @@ defmodule RichardBurton.Country do @moduledoc """ - Utilities for standardized country manipulation + Schema for countries """ + use Ecto.Schema import Ecto.Changeset - def validate_country(changeset) do - validate_change(changeset, :country, fn :country, country -> - if Countries.exists?(:alpha2, country) do + alias RichardBurton.Country + alias RichardBurton.Repo + alias RichardBurton.Publication + alias RichardBurton.Util + + @derive {Jason.Encoder, only: [:code]} + schema "countries" do + field(:code, :string) + + many_to_many(:publications, Publication, join_through: "publication_countries") + + timestamps() + end + + @doc false + def changeset(country, attrs \\ %{}) + + @doc false + def changeset(country, attrs = %Country{}) do + changeset(country, Map.from_struct(attrs)) + end + + @doc false + def changeset(country, attrs) do + country + |> cast(attrs, [:code]) + |> validate_required([:code]) + |> validate_code() + |> unique_constraint(:code) + end + + def validate_code(changeset) do + validate_change(changeset, :code, fn :code, code -> + if Countries.exists?(:alpha2, code) do [] else - [country: {"Invalid ISO-3361-1 alpha2 country code", [validation: :alpha2]}] + [code: {"Invalid ISO-3361-1 alpha2 country code: #{code}", [validation: :alpha2]}] end end) end + + def validate_countries(changeset = %Ecto.Changeset{}) do + changeset + |> validate_required([:countries]) + |> validate_change(:countries, fn :countries, countries -> + case validate_countries(countries) do + {:ok} -> [] + {:error, message} -> [countries: {message, [validation: :alpha2]}] + end + end) + end + + def validate_countries(countries) when is_binary(countries) do + invalid = + countries + |> nest() + |> Enum.map(&changeset(%Country{}, &1)) + |> Enum.reject(fn cset -> cset.valid? end) + + message = "Invalid countries: #{Enum.map_join(invalid, ", ", &get_change(&1, :code))}" + + case invalid do + [] -> {:ok} + _ -> {:error, message} + end + end + + @spec fingerprint(binary() | maybe_improper_list()) :: binary() + def fingerprint(countries) when is_binary(countries) do + countries + |> nest() + |> Enum.map(fn %{"code" => code} -> %Country{code: code} end) + |> fingerprint() + end + + def fingerprint(countries) when is_list(countries) do + countries + |> Enum.map(fn %Country{code: code} -> code end) + |> Enum.sort() + |> Enum.join() + |> Util.create_fingerprint() + end + + def maybe_insert!(attrs) do + %__MODULE__{} + |> changeset(attrs) + |> Repo.maybe_insert!([:code]) + end + + def all do + Repo.all(Country) + end + + def link(changeset = %{valid?: true}) do + countries = + changeset + |> get_change(:countries) + |> Enum.map(&apply_changes/1) + |> Enum.map(&maybe_insert!/1) + + put_assoc(changeset, :countries, countries) + end + + def link(changeset = %{valid?: false}), do: changeset + + def link_fingerprint(changeset = %Ecto.Changeset{valid?: true}) do + countries_fingerprint = + changeset + |> get_field(:countries) + |> fingerprint + + put_change(changeset, :countries_fingerprint, countries_fingerprint) + end + + def link_fingerprint(changeset = %Ecto.Changeset{valid?: false}), do: changeset + + def nest(countries) when is_binary(countries) do + countries |> String.split(",") |> Enum.map(&%{"code" => String.trim(&1)}) + end + + def flatten(countries) when is_list(countries), do: Enum.map_join(countries, ", ", &get_code/1) + def flatten(countries), do: countries + + def get_code(%Country{code: code}), do: code + def get_code(%{"code" => code}), do: code + def get_code(%{code: code}), do: code end diff --git a/backend/lib/richard_burton/flat_publication.ex b/backend/lib/richard_burton/flat_publication.ex index de387f29..fdbaaaec 100644 --- a/backend/lib/richard_burton/flat_publication.ex +++ b/backend/lib/richard_burton/flat_publication.ex @@ -7,7 +7,7 @@ defmodule RichardBurton.FlatPublication do import Ecto.Query alias RichardBurton.FlatPublication - alias RichardBurton.Publication + alias RichardBurton.Publisher alias RichardBurton.Repo alias RichardBurton.TranslatedBook alias RichardBurton.Validation @@ -16,8 +16,8 @@ defmodule RichardBurton.FlatPublication do @external_attributes [ :title, :year, - :country, - :publisher, + :countries, + :publishers, :authors, :original_title, :original_authors @@ -27,27 +27,32 @@ defmodule RichardBurton.FlatPublication do schema "flat_publications" do field(:title, :string) field(:year, :integer) - field(:country, :string) + field(:countries, :string) field(:authors, :string) - field(:publisher, :string) + field(:publishers, :string) field(:original_title, :string) field(:original_authors, :string) + field(:countries_fingerprint, :string) field(:translated_book_fingerprint, :string) + field(:publishers_fingerprint, :string) end @doc false def changeset(flat_publication, attrs) do - %Publication{} - |> Publication.changeset(Publication.Codec.nest(attrs)) - flat_publication |> cast(attrs, @external_attributes) |> validate_required(@external_attributes) - |> Country.validate_country() + |> Country.validate_countries() + |> Country.link_fingerprint() + |> Publisher.link_fingerprint() |> TranslatedBook.link_fingerprint() end + def all() do + Repo.all(FlatPublication) + end + def validate(attrs) do %FlatPublication{} |> changeset(attrs) |> validate_changeset() end @@ -62,8 +67,8 @@ defmodule RichardBurton.FlatPublication do [ :title, :year, - :country, - :publisher, + :countries_fingerprint, + :publishers_fingerprint, :translated_book_fingerprint ], &{&1, get_field(changeset, &1)} diff --git a/backend/lib/richard_burton/publication.ex b/backend/lib/richard_burton/publication.ex index 429edf33..f688cef3 100644 --- a/backend/lib/richard_burton/publication.ex +++ b/backend/lib/richard_burton/publication.ex @@ -7,24 +7,28 @@ defmodule RichardBurton.Publication do require Ecto.Query + alias RichardBurton.Country alias RichardBurton.Publication + alias RichardBurton.Publisher alias RichardBurton.Repo alias RichardBurton.TranslatedBook alias RichardBurton.Validation - alias RichardBurton.Country - @external_attributes [:country, :publisher, :title, :year, :translated_book] + @external_attributes [:countries, :publishers, :title, :year, :translated_book] @derive {Jason.Encoder, only: @external_attributes} schema "publications" do - field(:country, :string) - field(:publisher, :string) field(:title, :string) field(:year, :integer) field(:translated_book_fingerprint, :string) + field(:countries_fingerprint, :string) + field(:publishers_fingerprint, :string) belongs_to(:translated_book, TranslatedBook) + many_to_many(:countries, Country, join_through: "publication_countries") + many_to_many(:publishers, Publisher, join_through: "publication_publishers") + timestamps() end @@ -39,15 +43,23 @@ defmodule RichardBurton.Publication do @doc false def changeset(publication, attrs) do publication - |> cast(attrs, [:title, :year, :country, :publisher]) + |> cast(attrs, [:title, :year]) |> cast_assoc(:translated_book, required: true) - |> validate_required([:title, :year, :country, :publisher]) - |> Country.validate_country() - |> TranslatedBook.link_fingerprint() + |> cast_assoc(:countries, required: true) + |> cast_assoc(:publishers, required: true) + |> validate_length(:countries, min: 1) + |> validate_required([:title, :year]) |> unique_constraint( - [:title, :year, :country, :publisher, :translated_book_fingerprint], + [ + :title, + :year, + :publishers_fingerprint, + :countries_fingerprint, + :translated_book_fingerprint + ], name: "publications_composite_key" ) + |> link_fingerprints() end def all do @@ -57,13 +69,17 @@ defmodule RichardBurton.Publication do end def preload(data) do - Repo.preload(data, translated_book: [:authors, original_book: [:authors]]) + Repo.preload(data, [ + :countries, + :publishers, + translated_book: [:authors, original_book: [:authors]] + ]) end def insert(attrs) do %Publication{} |> changeset(attrs) - |> TranslatedBook.link() + |> link_assocs() |> Repo.insert() |> case do {:ok, publication} -> @@ -75,7 +91,21 @@ defmodule RichardBurton.Publication do end def validate(attrs) do - Validation.validate(changeset(%Publication{}, attrs), &TranslatedBook.link/1) + Validation.validate(changeset(%Publication{}, attrs), &link_assocs/1) + end + + defp link_fingerprints(changeset) do + changeset + |> TranslatedBook.link_fingerprint() + |> Country.link_fingerprint() + |> Publisher.link_fingerprint() + end + + defp link_assocs(changeset) do + changeset + |> Country.link() + |> TranslatedBook.link() + |> Publisher.link() end def insert_all(attrs_list) do diff --git a/backend/lib/richard_burton/publication_codec.ex b/backend/lib/richard_burton/publication_codec.ex index 3a85f9a5..a92bdd4e 100644 --- a/backend/lib/richard_burton/publication_codec.ex +++ b/backend/lib/richard_burton/publication_codec.ex @@ -3,16 +3,19 @@ defmodule RichardBurton.Publication.Codec do Serialization and deserialization utilities for publications """ + alias RichardBurton.Author alias RichardBurton.Codec + alias RichardBurton.Country alias RichardBurton.Util alias RichardBurton.Publication + alias RichardBurton.Publisher alias RichardBurton.FlatPublication @empty_flat_attrs %{ "title" => "", "year" => "", - "country" => "", - "publisher" => "", + "countries" => "", + "publishers" => "", "authors" => "", "original_title" => "", "original_authors" => "" @@ -21,11 +24,11 @@ defmodule RichardBurton.Publication.Codec do @csv_headers [ "original_authors", "year", - "country", + "countries", "original_title", "title", "authors", - "publisher" + "publishers" ] def from_csv(path) do @@ -87,10 +90,16 @@ defmodule RichardBurton.Publication.Codec do end defp nest_entry({"authors", value}), - do: {"authors", nest_authors(value)} + do: {"authors", Author.nest(value)} defp nest_entry({"original_authors", value}), - do: {"original_authors", nest_authors(value)} + do: {"original_authors", Author.nest(value)} + + defp nest_entry({"countries", value}), + do: {"countries", Country.nest(value)} + + defp nest_entry({"publishers", value}), + do: {"publishers", Publisher.nest(value)} defp nest_entry({key, value}), do: {key, value} @@ -128,20 +137,11 @@ defmodule RichardBurton.Publication.Codec do publication_like_map |> Codec.flatten() |> Map.new(&(&1 |> rename_key |> flatten_entry)) end - defp flatten_entry({"authors", value}), - do: {"authors", flatten_authors(value)} - - defp flatten_entry({"original_authors", value}), - do: {"original_authors", flatten_authors(value)} - - defp flatten_entry({key, value}), - do: {key, value} - - defp flatten_authors(authors) when is_list(authors) do - Enum.map_join(authors, ", ", &(Map.get(&1, "name") || Map.get(&1, :name))) - end - - defp flatten_authors(authors), do: authors + defp flatten_entry({"authors", value}), do: {"authors", Author.flatten(value)} + defp flatten_entry({"original_authors", value}), do: {"original_authors", Author.flatten(value)} + defp flatten_entry({"countries", value}), do: {"countries", Country.flatten(value)} + defp flatten_entry({"publishers", value}), do: {"publishers", Publisher.flatten(value)} + defp flatten_entry({key, value}), do: {key, value} defp rename_key({"translated_book_authors", v}), do: {"authors", v} defp rename_key({"translated_book_original_book_title", v}), do: {"original_title", v} diff --git a/backend/lib/richard_burton/publisher.ex b/backend/lib/richard_burton/publisher.ex new file mode 100644 index 00000000..51e891cb --- /dev/null +++ b/backend/lib/richard_burton/publisher.ex @@ -0,0 +1,118 @@ +defmodule RichardBurton.Publisher do + @moduledoc """ + Schema for publishers + """ + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + alias RichardBurton.Publisher + alias RichardBurton.Repo + alias RichardBurton.Publication + alias RichardBurton.Util + + @derive {Jason.Encoder, only: [:name]} + schema "publishers" do + field(:name, :string) + + many_to_many(:publications, Publication, join_through: "publication_publishers") + + timestamps() + end + + @doc false + def changeset(publisher, attrs \\ %{}) + + @doc false + def changeset(publisher, attrs = %Publisher{}) do + changeset(publisher, Map.from_struct(attrs)) + end + + @doc false + def changeset(publisher, attrs) do + publisher + |> cast(attrs, [:name]) + |> validate_required([:name]) + |> unique_constraint(:name) + end + + @spec fingerprint(binary() | maybe_improper_list()) :: binary() + def fingerprint(publishers) when is_binary(publishers) do + publishers + |> nest() + |> Enum.map(fn %{"name" => name} -> %Publisher{name: name} end) + |> fingerprint() + end + + def fingerprint(publishers) when is_list(publishers) do + publishers + |> Enum.map(fn %Publisher{name: name} -> name end) + |> Enum.sort() + |> Enum.join() + |> Util.create_fingerprint() + end + + def maybe_insert!(attrs) do + %__MODULE__{} + |> changeset(attrs) + |> Repo.maybe_insert!([:name]) + end + + def all do + Repo.all(Publisher) + end + + def link(changeset = %{valid?: true}) do + publishers = + changeset + |> get_change(:publishers) + |> Enum.map(&apply_changes/1) + |> Enum.map(&maybe_insert!/1) + + put_assoc(changeset, :publishers, publishers) + end + + def link(changeset = %{valid?: false}), do: changeset + + def link_fingerprint(changeset = %Ecto.Changeset{valid?: true}) do + publishers_fingerprint = + changeset + |> get_field(:publishers) + |> fingerprint + + put_change(changeset, :publishers_fingerprint, publishers_fingerprint) + end + + def link_fingerprint(changeset = %Ecto.Changeset{valid?: false}), do: changeset + + @spec search(binary(), :fuzzy | :prefix) :: any() + def search(term, :prefix) when is_binary(term) do + from(p in Publisher, where: ilike(p.name, ^"#{term}%")) + |> Repo.all() + end + + def search(term, :fuzzy) when is_binary(term) do + from(p in Publisher, where: fragment("similarity((?), (?)) > 0.3", p.name, ^term)) + |> Repo.all() + end + + def search(term) when is_binary(term) do + case search(term, :prefix) do + [] -> search(term, :fuzzy) + keywords when is_list(keywords) -> keywords + end + end + + def nest(publishers) when is_binary(publishers) do + publishers |> String.split(",") |> Enum.map(&%{"name" => String.trim(&1)}) + end + + def flatten(publishers) when is_list(publishers), + do: Enum.map_join(publishers, ", ", &get_name/1) + + def flatten(publishers), do: publishers + + def get_name(%Publisher{name: name}), do: name + def get_name(%{"name" => name}), do: name + def get_name(%{name: name}), do: name +end diff --git a/backend/lib/richard_burton_web/controllers/publisher_controller.ex b/backend/lib/richard_burton_web/controllers/publisher_controller.ex new file mode 100644 index 00000000..bc5b42ec --- /dev/null +++ b/backend/lib/richard_burton_web/controllers/publisher_controller.ex @@ -0,0 +1,13 @@ +defmodule RichardBurtonWeb.PublisherController do + use RichardBurtonWeb, :controller + + alias RichardBurton.Publisher + + def index(conn, %{"search" => query}) do + json(conn, Enum.map(Publisher.search(query), &Map.get(&1, :name))) + end + + def index(conn, _params) do + json(conn, Enum.map(Publisher.all(), &Map.get(&1, :name))) + end +end diff --git a/backend/lib/richard_burton_web/router.ex b/backend/lib/richard_burton_web/router.ex index 8fb41e05..6200e60a 100644 --- a/backend/lib/richard_burton_web/router.ex +++ b/backend/lib/richard_burton_web/router.ex @@ -38,6 +38,7 @@ defmodule RichardBurtonWeb.Router do pipe_through(:authorize_admin) get("/authors", AuthorController, :index) + get("/publishers", PublisherController, :index) scope "/publications" do post("/bulk", PublicationController, :create_all) diff --git a/backend/priv/repo/migrations/20240620203719_create_countries.exs b/backend/priv/repo/migrations/20240620203719_create_countries.exs new file mode 100644 index 00000000..8501567c --- /dev/null +++ b/backend/priv/repo/migrations/20240620203719_create_countries.exs @@ -0,0 +1,415 @@ +defmodule RichardBurton.Repo.Migrations.CreateCountries do + use Ecto.Migration + + def change do + create table(:countries) do + add :code, :string + timestamps() + end + + create unique_index(:countries, :code) + + execute "-- Up + + INSERT INTO countries( + code, + inserted_at, + updated_at + ) + SELECT DISTINCT + btrim(unnest(string_to_array(country, ','))) AS country, + now(), + now() + FROM + publications; + ", + "-- Down + + DELETE FROM countries; + " + + create table(:publication_countries) do + add :publication_id, references(:publications) + add :country_id, references(:countries) + end + + create unique_index(:publication_countries, [:publication_id, :country_id]) + + execute "-- Up + + INSERT INTO publication_countries ( + publication_id, + country_id + ) + SELECT + publications.id AS publication_id, + countries.id AS country_id + FROM + publications + INNER JOIN + countries + ON + countries.code = ANY( + SELECT + btrim(unnest(string_to_array(publications.country, ','))) + FROM + publications AS aux + WHERE + publications.id = aux.id + ); + + ", + "-- Down + + DELETE FROM publication_countries; + " + + alter table("publications") do + add :countries_fingerprint, :string + end + + execute( + "--Up + UPDATE publications + SET countries_fingerprint = ( + SELECT + encode(sha256(decode(string_agg(countries.code, '' ORDER BY countries.code), 'escape')), 'hex') AS fingerprint + FROM + publication_countries + INNER JOIN + countries ON publication_countries.country_id = countries.id + WHERE + publication_countries.publication_id = publications.id + GROUP BY + publication_countries.publication_id + ) + ", + "--Down + " + ) + + drop( + unique_index( + :publications, + [ + :title, + :year, + :country, + :publisher, + :translated_book_fingerprint + ], + name: "publications_composite_key" + ) + ) + + execute "-- Up + ALTER TABLE publications + ALTER COLUMN countries_fingerprint SET NOT NULL; + ", + "-- Down + + ALTER TABLE publications + ALTER COLUMN countries_fingerprint DROP NOT NULL; + " + + create unique_index( + :publications, + [ + :title, + :year, + :publisher, + :translated_book_fingerprint, + :countries_fingerprint + ], + name: "publications_composite_key" + ) + + execute "--Up + + DROP MATERIALIZED VIEW search_documents; + ", + "--Down + + CREATE MATERIALIZED VIEW search_documents AS + SELECT + id, + to_tsvector('simple'::regconfig, title) || + to_tsvector('simple'::regconfig, country) || + to_tsvector('simple'::regconfig, publisher) || + to_tsvector('simple'::regconfig, year::text) || + to_tsvector('simple'::regconfig, authors) || + to_tsvector('simple'::regconfig, original_title) || + to_tsvector('simple'::regconfig, original_authors) AS document + FROM + flat_publications; + " + + execute "--Up + + DROP VIEW flat_publications + ", + "--Down + " + + execute "--Up + + CREATE OR REPLACE VIEW flat_publications AS + WITH + CTE_publications AS ( + SELECT + publications.id AS id, + publications.title AS title, + publications.year AS year, + publications.publisher AS publisher, + authors.name AS original_author, + translators.name AS translator, + original_books.title AS original_title, + publications.translated_book_fingerprint AS translated_book_fingerprint, + publications.countries_fingerprint AS countries_fingerprint, + countries.code AS country + FROM + translated_books + INNER JOIN + publications + ON publications.translated_book_id = translated_books.id + INNER JOIN + original_books + ON original_books.id = translated_books.original_book_id + INNER JOIN + original_book_authors + ON original_book_authors.original_book_id = original_books.id + INNER JOIN + authors + ON authors.id = original_book_authors.author_id + INNER JOIN + translated_book_authors + ON translated_book_authors.translated_book_id = translated_books.id + INNER JOIN + authors AS translators + ON translators.id = translated_book_authors.author_id + INNER JOIN + publication_countries + ON publication_countries.publication_id = publications.id + INNER JOIN + countries + ON countries.id = publication_countries.country_id + ), + CTE_authors AS ( + SELECT id, original_author + FROM CTE_publications + GROUP BY id, original_author + ), + CTE_authors_distinct AS ( + SELECT id, string_agg(original_author, ', ' ORDER BY original_author) AS original_authors + FROM CTE_authors + GROUP BY id + ), + CTE_translators AS ( + SELECT id, translator + FROM CTE_publications + GROUP BY id, translator + ), + CTE_translators_distinct AS ( + SELECT id, string_agg(translator, ', ' ORDER BY translator) AS translators + FROM CTE_translators + GROUP BY id + ), + CTE_countries AS ( + SELECT id, country + FROM CTE_publications + GROUP BY id, country + ), + CTE_countries_distinct AS ( + SELECT id, string_agg(country, ', ' ORDER BY country) AS countries + FROM CTE_countries + GROUP BY id + ) + SELECT + CTE_publications.id AS id, + CTE_publications.title AS title, + CTE_countries_distinct.countries AS countries, + CTE_publications.countries_fingerprint AS countries_fingerprint, + CTE_publications.year AS year, + CTE_publications.publisher AS publisher, + CTE_publications.original_title AS original_title, + CTE_authors_distinct.original_authors AS original_authors, + CTE_translators_distinct.translators AS authors, + CTE_publications.translated_book_fingerprint AS translated_book_fingerprint + FROM + CTE_publications + INNER JOIN + CTE_authors_distinct + ON CTE_publications.id = CTE_authors_distinct.id + INNER JOIN + CTE_translators_distinct + ON CTE_publications.id = CTE_translators_distinct.id + INNER JOIN + CTE_countries_distinct + ON CTE_publications.id = CTE_countries_distinct.id + GROUP BY + CTE_publications.id, + CTE_publications.title, + CTE_countries_distinct.countries, + CTE_publications.year, + CTE_publications.publisher, + CTE_publications.original_title, + CTE_authors_distinct.original_authors, + CTE_translators_distinct.translators, + CTE_publications.translated_book_fingerprint, + CTE_publications.countries_fingerprint; + ", + "-- Down + + CREATE OR REPLACE VIEW flat_publications AS + WITH + CTE_publications AS ( + SELECT + publications.id AS id, + publications.title AS title, + publications.country AS country, + publications.year AS year, + publications.publisher AS publisher, + authors.name AS original_author, + translators.name AS translator, + original_books.title AS original_title, + publications.translated_book_fingerprint AS translated_book_fingerprint + FROM + translated_books + INNER JOIN + publications + ON publications.translated_book_id = translated_books.id + INNER JOIN + original_books + ON original_books.id = translated_books.original_book_id + INNER JOIN + original_book_authors + ON original_book_authors.original_book_id = original_books.id + INNER JOIN + authors + ON authors.id = original_book_authors.author_id + INNER JOIN + translated_book_authors + ON translated_book_authors.translated_book_id = translated_books.id + INNER JOIN + authors AS translators + ON translators.id = translated_book_authors.author_id + ), + CTE_authors AS ( + SELECT id, original_author + FROM CTE_publications + GROUP BY id, original_author + ), + CTE_authors_distinct AS ( + SELECT id, string_agg(original_author, ', ' ORDER BY original_author) AS original_authors + FROM CTE_authors + GROUP BY id + ), + CTE_translators AS ( + SELECT id, translator + FROM CTE_publications + GROUP BY id, translator + ), + CTE_translators_distinct AS ( + SELECT id, string_agg(translator, ', ' ORDER BY translator) AS translators + FROM CTE_translators + GROUP BY id + ) + SELECT + CTE_publications.id AS id, + CTE_publications.title AS title, + CTE_publications.country AS country, + CTE_publications.year AS year, + CTE_publications.publisher AS publisher, + CTE_publications.original_title AS original_title, + CTE_authors_distinct.original_authors AS original_authors, + CTE_translators_distinct.translators AS authors, + CTE_publications.translated_book_fingerprint AS translated_book_fingerprint + FROM + CTE_publications + INNER JOIN + CTE_authors_distinct + ON CTE_publications.id = CTE_authors_distinct.id + INNER JOIN + CTE_translators_distinct + ON CTE_publications.id = CTE_translators_distinct.id + GROUP BY + CTE_publications.id, + CTE_publications.title, + CTE_publications.country, + CTE_publications.year, + CTE_publications.publisher, + CTE_publications.original_title, + CTE_authors_distinct.original_authors, + CTE_translators_distinct.translators, + CTE_publications.translated_book_fingerprint; + " + + execute "--Up + ", + "--Down + + DROP VIEW flat_publications + " + + execute "--Up + + CREATE MATERIALIZED VIEW search_documents AS + SELECT + id, + to_tsvector('simple'::regconfig, title) || + to_tsvector('simple'::regconfig, countries) || + to_tsvector('simple'::regconfig, publisher) || + to_tsvector('simple'::regconfig, year::text) || + to_tsvector('simple'::regconfig, authors) || + to_tsvector('simple'::regconfig, original_title) || + to_tsvector('simple'::regconfig, original_authors) AS document + FROM + flat_publications; + + ", + "--Down + + DROP MATERIALIZED VIEW search_documents; + " + + execute "-- Up + ", + "-- Down + + ALTER TABLE publications + ALTER COLUMN country SET NOT NULL; + " + + execute "-- Up + ", + "-- Down + + UPDATE publications + SET country = ( + SELECT + string_agg(countries.code, ', ' ORDER BY countries.code) + FROM + countries + INNER JOIN + publication_countries + ON + countries.id = publication_countries.country_id + WHERE + publication_countries.publication_id = publications.id + GROUP BY + publication_countries.publication_id + ); + " + + execute "-- Up + + ALTER TABLE publications + DROP COLUMN country; + ", + "-- Down + + ALTER TABLE publications + ADD country VARCHAR; + " + end +end diff --git a/backend/priv/repo/migrations/20240623200257_create_publishers.exs b/backend/priv/repo/migrations/20240623200257_create_publishers.exs new file mode 100644 index 00000000..e12ebc88 --- /dev/null +++ b/backend/priv/repo/migrations/20240623200257_create_publishers.exs @@ -0,0 +1,459 @@ +defmodule RichardBurton.Repo.Migrations.CreatePublishers do + use Ecto.Migration + + def change do + create table(:publishers) do + add :name, :string + timestamps() + end + + create unique_index(:publishers, :name) + + execute "-- Up + + INSERT INTO publishers( + name, + inserted_at, + updated_at + ) + SELECT DISTINCT + btrim(unnest(string_to_array(publisher, ','))) AS publisher, + now(), + now() + FROM + publications; + ", + "-- Down + + DELETE FROM publishers; + " + + create table(:publication_publishers) do + add :publication_id, references(:publications) + add :publisher_id, references(:publishers) + end + + create unique_index(:publication_publishers, [:publication_id, :publisher_id]) + + execute "-- Up + + INSERT INTO publication_publishers ( + publication_id, + publisher_id + ) + SELECT + publications.id AS publication_id, + publishers.id AS publisher_id + FROM + publications + INNER JOIN + publishers + ON + publishers.name = ANY( + SELECT + btrim(unnest(string_to_array(publications.publisher, ','))) + FROM + publications AS aux + WHERE + publications.id = aux.id + ); + + ", + "-- Down + + DELETE FROM publication_publishers; + " + + alter table("publications") do + add :publishers_fingerprint, :string + end + + execute( + "--Up + UPDATE publications + SET publishers_fingerprint = ( + SELECT + encode(sha256(decode(string_agg(publishers.name, '' ORDER BY publishers.name), 'escape')), 'hex') AS fingerprint + FROM + publication_publishers + INNER JOIN + publishers ON publication_publishers.publisher_id = publishers.id + WHERE + publication_publishers.publication_id = publications.id + GROUP BY + publication_publishers.publication_id + ) + ", + "--Down + " + ) + + drop( + unique_index( + :publications, + [ + :title, + :year, + :publisher, + :translated_book_fingerprint, + :countries_fingerprint + ], + name: "publications_composite_key" + ) + ) + + execute "-- Up + ALTER TABLE publications + ALTER COLUMN publishers_fingerprint SET NOT NULL; + ", + "-- Down + + ALTER TABLE publications + ALTER COLUMN publishers_fingerprint DROP NOT NULL; + " + + create unique_index( + :publications, + [ + :title, + :year, + :publishers_fingerprint, + :translated_book_fingerprint, + :countries_fingerprint + ], + name: "publications_composite_key" + ) + + execute "--Up + + DROP MATERIALIZED VIEW search_documents; + ", + "--Down + + CREATE MATERIALIZED VIEW search_documents AS + SELECT + id, + to_tsvector('simple'::regconfig, title) || + to_tsvector('simple'::regconfig, countries) || + to_tsvector('simple'::regconfig, publisher) || + to_tsvector('simple'::regconfig, year::text) || + to_tsvector('simple'::regconfig, authors) || + to_tsvector('simple'::regconfig, original_title) || + to_tsvector('simple'::regconfig, original_authors) AS document + FROM + flat_publications; + " + + execute "--Up + + DROP VIEW flat_publications + ", + "--Down + " + + execute "--Up + + CREATE OR REPLACE VIEW flat_publications AS + WITH + CTE_publications AS ( + SELECT + publications.id AS id, + publications.title AS title, + publications.year AS year, + authors.name AS original_author, + translators.name AS translator, + original_books.title AS original_title, + publications.translated_book_fingerprint AS translated_book_fingerprint, + publications.countries_fingerprint AS countries_fingerprint, + publications.publishers_fingerprint AS publishers_fingerprint, + countries.code AS country, + publishers.name AS publisher + FROM + translated_books + INNER JOIN + publications + ON publications.translated_book_id = translated_books.id + INNER JOIN + original_books + ON original_books.id = translated_books.original_book_id + INNER JOIN + original_book_authors + ON original_book_authors.original_book_id = original_books.id + INNER JOIN + authors + ON authors.id = original_book_authors.author_id + INNER JOIN + translated_book_authors + ON translated_book_authors.translated_book_id = translated_books.id + INNER JOIN + authors AS translators + ON translators.id = translated_book_authors.author_id + INNER JOIN + publication_countries + ON publication_countries.publication_id = publications.id + INNER JOIN + countries + ON countries.id = publication_countries.country_id + INNER JOIN + publication_publishers + ON publication_publishers.publication_id = publications.id + INNER JOIN + publishers + ON publishers.id = publication_publishers.publisher_id + ), + CTE_authors AS ( + SELECT id, original_author + FROM CTE_publications + GROUP BY id, original_author + ), + CTE_authors_distinct AS ( + SELECT id, string_agg(original_author, ', ' ORDER BY original_author) AS original_authors + FROM CTE_authors + GROUP BY id + ), + CTE_translators AS ( + SELECT id, translator + FROM CTE_publications + GROUP BY id, translator + ), + CTE_translators_distinct AS ( + SELECT id, string_agg(translator, ', ' ORDER BY translator) AS translators + FROM CTE_translators + GROUP BY id + ), + CTE_countries AS ( + SELECT id, country + FROM CTE_publications + GROUP BY id, country + ), + CTE_countries_distinct AS ( + SELECT id, string_agg(country, ', ' ORDER BY country) AS countries + FROM CTE_countries + GROUP BY id + ), + CTE_publishers AS ( + SELECT id, publisher + FROM CTE_publications + GROUP BY id, publisher + ), + CTE_publishers_distinct AS ( + SELECT id, string_agg(publisher, ', ' ORDER BY publisher) AS publishers + FROM CTE_publishers + GROUP BY id + ) + SELECT + CTE_publications.id AS id, + CTE_publications.title AS title, + CTE_countries_distinct.countries AS countries, + CTE_publications.countries_fingerprint AS countries_fingerprint, + CTE_publications.year AS year, + CTE_publishers_distinct.publishers AS publishers, + CTE_publications.publishers_fingerprint AS publishers_fingerprint, + CTE_publications.original_title AS original_title, + CTE_authors_distinct.original_authors AS original_authors, + CTE_translators_distinct.translators AS authors, + CTE_publications.translated_book_fingerprint AS translated_book_fingerprint + FROM + CTE_publications + INNER JOIN + CTE_authors_distinct + ON CTE_publications.id = CTE_authors_distinct.id + INNER JOIN + CTE_translators_distinct + ON CTE_publications.id = CTE_translators_distinct.id + INNER JOIN + CTE_countries_distinct + ON CTE_publications.id = CTE_countries_distinct.id + INNER JOIN + CTE_publishers_distinct + ON CTE_publications.id = CTE_publishers_distinct.id + GROUP BY + CTE_publications.id, + CTE_publications.title, + CTE_publications.year, + CTE_publications.original_title, + CTE_publications.translated_book_fingerprint, + CTE_publications.countries_fingerprint, + CTE_publications.publishers_fingerprint, + CTE_authors_distinct.original_authors, + CTE_translators_distinct.translators, + CTE_countries_distinct.countries, + CTE_publishers_distinct.publishers; + ", + "-- Down + + CREATE OR REPLACE VIEW flat_publications AS + WITH + CTE_publications AS ( + SELECT + publications.id AS id, + publications.title AS title, + publications.year AS year, + publications.publisher AS publisher, + authors.name AS original_author, + translators.name AS translator, + original_books.title AS original_title, + publications.translated_book_fingerprint AS translated_book_fingerprint, + publications.countries_fingerprint AS countries_fingerprint, + countries.code AS country + FROM + translated_books + INNER JOIN + publications + ON publications.translated_book_id = translated_books.id + INNER JOIN + original_books + ON original_books.id = translated_books.original_book_id + INNER JOIN + original_book_authors + ON original_book_authors.original_book_id = original_books.id + INNER JOIN + authors + ON authors.id = original_book_authors.author_id + INNER JOIN + translated_book_authors + ON translated_book_authors.translated_book_id = translated_books.id + INNER JOIN + authors AS translators + ON translators.id = translated_book_authors.author_id + INNER JOIN + publication_countries + ON publication_countries.publication_id = publications.id + INNER JOIN + countries + ON countries.id = publication_countries.country_id + ), + CTE_authors AS ( + SELECT id, original_author + FROM CTE_publications + GROUP BY id, original_author + ), + CTE_authors_distinct AS ( + SELECT id, string_agg(original_author, ', ' ORDER BY original_author) AS original_authors + FROM CTE_authors + GROUP BY id + ), + CTE_translators AS ( + SELECT id, translator + FROM CTE_publications + GROUP BY id, translator + ), + CTE_translators_distinct AS ( + SELECT id, string_agg(translator, ', ' ORDER BY translator) AS translators + FROM CTE_translators + GROUP BY id + ), + CTE_countries AS ( + SELECT id, country + FROM CTE_publications + GROUP BY id, country + ), + CTE_countries_distinct AS ( + SELECT id, string_agg(country, ', ' ORDER BY country) AS countries + FROM CTE_countries + GROUP BY id + ) + SELECT + CTE_publications.id AS id, + CTE_publications.title AS title, + CTE_countries_distinct.countries AS countries, + CTE_publications.countries_fingerprint AS countries_fingerprint, + CTE_publications.year AS year, + CTE_publications.publisher AS publisher, + CTE_publications.original_title AS original_title, + CTE_authors_distinct.original_authors AS original_authors, + CTE_translators_distinct.translators AS authors, + CTE_publications.translated_book_fingerprint AS translated_book_fingerprint + FROM + CTE_publications + INNER JOIN + CTE_authors_distinct + ON CTE_publications.id = CTE_authors_distinct.id + INNER JOIN + CTE_translators_distinct + ON CTE_publications.id = CTE_translators_distinct.id + INNER JOIN + CTE_countries_distinct + ON CTE_publications.id = CTE_countries_distinct.id + GROUP BY + CTE_publications.id, + CTE_publications.title, + CTE_countries_distinct.countries, + CTE_publications.year, + CTE_publications.publisher, + CTE_publications.original_title, + CTE_authors_distinct.original_authors, + CTE_translators_distinct.translators, + CTE_publications.translated_book_fingerprint, + CTE_publications.countries_fingerprint; + " + + execute "--Up + ", + "--Down + + DROP VIEW flat_publications + " + + execute "--Up + + CREATE MATERIALIZED VIEW search_documents AS + SELECT + id, + to_tsvector('simple'::regconfig, title) || + to_tsvector('simple'::regconfig, countries) || + to_tsvector('simple'::regconfig, publishers) || + to_tsvector('simple'::regconfig, year::text) || + to_tsvector('simple'::regconfig, authors) || + to_tsvector('simple'::regconfig, original_title) || + to_tsvector('simple'::regconfig, original_authors) AS document + FROM + flat_publications; + + ", + "--Down + + DROP MATERIALIZED VIEW search_documents; + " + + execute "-- Up + ", + "-- Down + + ALTER TABLE publications + ALTER COLUMN publisher SET NOT NULL; + " + + execute "-- Up + ", + "-- Down + + UPDATE publications + SET publisher = ( + SELECT + string_agg(publishers.name, ', ' ORDER BY publishers.name) + FROM + publishers + INNER JOIN + publication_publishers + ON + publishers.id = publication_publishers.publisher_id + WHERE + publication_publishers.publication_id = publications.id + GROUP BY + publication_publishers.publication_id + ); + " + + execute "-- Up + + ALTER TABLE publications + DROP COLUMN publisher; + ", + "-- Down + + ALTER TABLE publications + ADD publisher VARCHAR; + " + end +end diff --git a/backend/priv/repo/migrations/20240623224923_create_publishers_search_index.exs b/backend/priv/repo/migrations/20240623224923_create_publishers_search_index.exs new file mode 100644 index 00000000..b089df80 --- /dev/null +++ b/backend/priv/repo/migrations/20240623224923_create_publishers_search_index.exs @@ -0,0 +1,14 @@ +defmodule RichardBurton.Repo.Migrations.CreatePublishersSearchIndex do + use Ecto.Migration + + def change do + execute( + "--Up + CREATE INDEX publishers_name_trigram_index ON publishers USING gin(name gin_trgm_ops); + ", + "--Down + DROP INDEX publishers_name_trigram_index; + " + ) + end +end diff --git a/backend/test/fixtures/data_correct_with_errors.csv b/backend/test/fixtures/data_correct_with_errors.csv index bafcdc3c..23d1106b 100644 --- a/backend/test/fixtures/data_correct_with_errors.csv +++ b/backend/test/fixtures/data_correct_with_errors.csv @@ -1,4 +1,4 @@ José de Alencar;1886;GB;Iracema;Iraçéma the Honey-Lips: A Legend of Brazil;Isabel Burton, Richard Burton;Bickers & Son -José de Alencar;1922;US;Ubirajara;Ubirajara: A Legend of the Tupy Indians;J. T. W. Sadler;Ronald Massey +José de Alencar;1922;US, GB;Ubirajara;Ubirajara: A Legend of the Tupy Indians;J. T. W. Sadler;Ronald Massey José de Alencar;AAAA;GB;Iracema;;;Bickers & Son ;;;;Ubirajara: A Legend of the Tupy Indians;J. T. W. Sadler; \ No newline at end of file diff --git a/backend/test/richard_burton/author_test.exs b/backend/test/richard_burton/author_test.exs index 3a79add7..531e2aca 100644 --- a/backend/test/richard_burton/author_test.exs +++ b/backend/test/richard_burton/author_test.exs @@ -8,8 +8,6 @@ defmodule RichardBurton.AuthorTest do alias RichardBurton.Author alias RichardBurton.Validation - alias RichardBurton.OriginalBook - alias RichardBurton.TranslatedBook @valid_attrs %{"name" => "J. M. Pereira da Silva"} @@ -41,13 +39,13 @@ defmodule RichardBurton.AuthorTest do attrs |> changeset() |> Repo.insert!() end - defp linked(changeset = %Ecto.Changeset{}) do + defp get_authors(changeset = %Ecto.Changeset{}) do changeset |> get_change(:authors) |> Enum.map(&apply_changes/1) end - defp linked_fingerprint(changeset = %Ecto.Changeset{}) do + defp get_fingerprint(changeset = %Ecto.Changeset{}) do get_change(changeset, :authors_fingerprint) end @@ -114,140 +112,94 @@ defmodule RichardBurton.AuthorTest do end end - describe "link/1" do - @original_book_attrs %{ - "title" => "Title", - "authors" => [ - %{"name" => "Richard Burton"}, - %{"name" => "Isabel Burton"} - ] - } - - @translated_book_attrs %{ - "authors" => [ - %{"name" => "Richard Burton"}, - %{"name" => "Isabel Burton"} - ], - "original_book" => %{ - "title" => "Dom Casmurro", - "authors" => [%{"name" => "Machado de Assis"}] - } - } - - test "links existing authors to OriginalBook changeset" do - authors = Enum.map(@original_book_attrs["authors"], &insert!/1) + defmodule WithManyAuthors do + use Ecto.Schema + import Ecto.Changeset - changeset = - %OriginalBook{} - |> OriginalBook.changeset(@original_book_attrs) - |> Author.link() - - assert changeset.valid? - - assert authors == linked(changeset) + schema "with_many_authors" do + field(:authors_fingerprint, :string) + has_many :authors, Author end - test "links existing authors to TranslatedBook changeset" do - authors = Enum.map(@translated_book_attrs["authors"], &insert!/1) - - changeset = - %TranslatedBook{} - |> TranslatedBook.changeset(@translated_book_attrs) - |> Author.link() - - assert changeset.valid? - - assert authors == linked(changeset) + def changeset(attrs) do + %WithManyAuthors{} |> cast(attrs, []) |> cast_assoc(:authors) end + end - test "links non-existing authors to OriginalBook changeset, inserting them" do - changeset = - %OriginalBook{} - |> OriginalBook.changeset(@original_book_attrs) - |> Author.link() - - assert changeset.valid? + describe "link/1" do + test "links existing authors to changeset" do + attrs = %{ + "authors" => [ + %{"name" => "Richard Burton"}, + %{"name" => "Isabel Burton"} + ] + } - assert Author.all() == linked(changeset) - end + authors = Enum.map(attrs["authors"], &insert!/1) - test "links non-existing authors to TranslatedBook changeset, inserting them" do changeset = - %TranslatedBook{} - |> TranslatedBook.changeset(@translated_book_attrs) + attrs + |> WithManyAuthors.changeset() |> Author.link() assert changeset.valid? - - assert Author.all() == linked(changeset) + assert authors == get_authors(changeset) end - test "has no side effects when OriginalBook changeset is invalid" do + test "links non-existing authors to changeset, inserting them" do + attrs = %{ + "authors" => [ + %{"name" => "Richard Burton"}, + %{"name" => "Isabel Burton"} + ] + } + changeset = - %OriginalBook{} - |> OriginalBook.changeset(%{}) + attrs + |> WithManyAuthors.changeset() |> Author.link() - refute changeset.valid? - - assert Enum.empty?(Author.all()) + assert changeset.valid? + assert Author.all() == get_authors(changeset) end - test "has no side effects when TranslatedBook changeset is invalid" do + test "has no side effects when changeset is invalid" do changeset = - %TranslatedBook{} - |> TranslatedBook.changeset(%{}) + %{"authors" => [%{}]} + |> WithManyAuthors.changeset() |> Author.link() refute changeset.valid? - assert Enum.empty?(Author.all()) end end describe "link_fingerprint/1" do - test "links fingerprint using author names to OriginalBook changeset" do - changeset = - %OriginalBook{} - |> OriginalBook.changeset(@original_book_attrs) - |> Author.link_fingerprint() - - assert changeset.valid? - - assert Author.fingerprint(linked(changeset)) == linked_fingerprint(changeset) - end + test "links fingerprint using author names to changeset" do + attrs = %{ + "authors" => [ + %{"name" => "Richard Burton"}, + %{"name" => "Isabel Burton"} + ] + } - test "links fingerprint using author names to TranslatedBook changeset" do changeset = - %TranslatedBook{} - |> TranslatedBook.changeset(@translated_book_attrs) + attrs + |> WithManyAuthors.changeset() |> Author.link_fingerprint() assert changeset.valid? - - assert Author.fingerprint(linked(changeset)) == linked_fingerprint(changeset) - end - - test "does not link fingerprint to invalid OriginalBook changeset" do - changeset = - %OriginalBook{} - |> OriginalBook.changeset(%{}) - |> Author.link_fingerprint() - - refute changeset.valid? - - assert is_nil(linked_fingerprint(changeset)) + assert Author.fingerprint(get_authors(changeset)) == get_fingerprint(changeset) end - test "does not link fingerprint to invalid TranslatedBook changeset" do + test "does not link fingerprint to invalid changeset" do changeset = - %TranslatedBook{} - |> TranslatedBook.changeset(%{}) + %{"authors" => [%{}]} + |> WithManyAuthors.changeset() |> Author.link_fingerprint() refute changeset.valid? - - assert is_nil(linked_fingerprint(changeset)) + assert is_nil(get_fingerprint(changeset)) end end @@ -316,4 +268,41 @@ defmodule RichardBurton.AuthorTest do end end end + + describe "flatten/1" do + test "with a list of maps with string keys, returns a list of maps with code key" do + authors = [ + %{"name" => "Richard Burton"}, + %{"name" => "Isabel Burton"} + ] + + assert "Richard Burton, Isabel Burton" = Author.flatten(authors) + end + + test "with a list of maps with atom keys, returns a list of maps with code key" do + authors = [ + %{name: "Richard Burton"}, + %{name: "Isabel Burton"} + ] + + assert "Richard Burton, Isabel Burton" = Author.flatten(authors) + end + + test "with a list of Author structs, returns a list of maps with code key" do + authors = [ + %Author{name: "Richard Burton"}, + %Author{name: "Isabel Burton"} + ] + + assert "Richard Burton, Isabel Burton" = Author.flatten(authors) + end + end + + describe "nest/1" do + test "with a comma separated string, returns a list of maps with code key" do + authors = "Richard Burton, Isabel Burton" + + assert [%{"name" => "Richard Burton"}, %{"name" => "Isabel Burton"}] = Author.nest(authors) + end + end end diff --git a/backend/test/richard_burton/country_test.exs b/backend/test/richard_burton/country_test.exs new file mode 100644 index 00000000..872c29fa --- /dev/null +++ b/backend/test/richard_burton/country_test.exs @@ -0,0 +1,349 @@ +defmodule RichardBurton.CountryTest do + @moduledoc """ + Tests for the Country schema + """ + use RichardBurton.DataCase + + alias RichardBurton.Country + alias RichardBurton.Validation + alias RichardBurton.Util + + defmodule WithStringCountries do + use Ecto.Schema + import Ecto.Changeset + + schema "with_countries" do + field(:countries, :string) + end + + def changeset(attrs = %{}) do + %WithStringCountries{} + |> cast(attrs, [:countries]) + end + end + + defmodule WithManyCountries do + use Ecto.Schema + import Ecto.Changeset + + schema "with_many_countries" do + field(:countries_fingerprint, :string) + has_many :countries, Country + end + + def changeset(attrs) do + %WithManyCountries{} |> cast(attrs, []) |> cast_assoc(:countries) + end + end + + @valid_attrs %{ + "code" => "GB" + } + + defp changeset(attrs = %{}) do + Country.changeset(%Country{}, attrs) + end + + defp change_valid(attrs = %{}) do + changeset(Util.deep_merge_maps(@valid_attrs, attrs)) + end + + defp insert(attrs) do + %Country{} |> Country.changeset(attrs) |> Repo.insert() + end + + defp insert!(attrs) do + attrs |> changeset() |> Repo.insert!() + end + + defp get_countries(changeset = %Ecto.Changeset{}) do + changeset + |> get_change(:countries) + |> Enum.map(&apply_changes/1) + end + + defp get_fingerprint(changeset = %Ecto.Changeset{}) do + get_change(changeset, :countries_fingerprint) + end + + describe "changeset/2" do + test "when valid attributes are provided, is valid" do + assert changeset(@valid_attrs).valid? + end + + test "when code is blank, is invalid" do + refute change_valid(%{"code" => ""}).valid? + end + + test "when code is nil, is invalid" do + refute change_valid(%{"code" => nil}).valid? + end + + test "when code is valid alpha3 code, is invalid" do + refute change_valid(%{"code" => "USA"}).valid? + end + + test "when code is invalid 3 digit code, is invalid" do + refute change_valid(%{"code" => "EUA"}).valid? + end + + test "when code is invalid 2 digit code, is invalid" do + refute change_valid(%{"code" => "XX"}).valid? + end + + test "when a country with the provided attributes already exists, is invalid" do + {:ok, _} = insert(@valid_attrs) + {:error, changeset} = insert(@valid_attrs) + + refute changeset.valid? + assert :conflict == Validation.get_errors(changeset) + end + end + + describe "validate_countries/1" do + test "when countries is valid alpha2 code, is valid" do + %Ecto.Changeset{errors: errors, valid?: valid} = + %{"countries" => "GB"} + |> WithStringCountries.changeset() + |> Country.validate_countries() + + assert valid + assert errors == [] + end + + test "when countries is multiple, comma separated, valid codes, is valid" do + %Ecto.Changeset{errors: errors, valid?: valid} = + %{"countries" => "GB,US"} + |> WithStringCountries.changeset() + |> Country.validate_countries() + + assert valid + assert errors == [] + end + + test "when countries is blank, is invalid" do + %Ecto.Changeset{errors: errors, valid?: valid} = + %{"countries" => ""} + |> WithStringCountries.changeset() + |> Country.validate_countries() + + refute valid + assert errors == [countries: {"can't be blank", [validation: :required]}] + end + + test "when countries is nil, is invalid" do + %Ecto.Changeset{errors: errors, valid?: valid} = + %{"countries" => nil} + |> WithStringCountries.changeset() + |> Country.validate_countries() + + refute valid + assert errors == [countries: {"can't be blank", [validation: :required]}] + end + + test "when countries is valid alpha3 code, is invalid" do + %Ecto.Changeset{errors: errors, valid?: valid} = + %{"countries" => "USA"} + |> WithStringCountries.changeset() + |> Country.validate_countries() + + refute valid + assert errors == [countries: {"Invalid countries: USA", [validation: :alpha2]}] + end + + test "when countries is invalid 3 digit code, is invalid" do + %Ecto.Changeset{errors: errors, valid?: valid} = + %{"countries" => "EUA"} + |> WithStringCountries.changeset() + |> Country.validate_countries() + + refute valid + assert errors == [countries: {"Invalid countries: EUA", [validation: :alpha2]}] + end + + test "when countries is multiple, comma separated, invalid codes, is invalid" do + %Ecto.Changeset{errors: errors, valid?: valid} = + %{"countries" => "USA,GBR"} + |> WithStringCountries.changeset() + |> Country.validate_countries() + + refute valid + assert errors == [countries: {"Invalid countries: USA, GBR", [validation: :alpha2]}] + end + + test "when countries has at least one invalid code, is invalid" do + %Ecto.Changeset{errors: errors, valid?: valid} = + %{"countries" => "USA,GB"} + |> WithStringCountries.changeset() + |> Country.validate_countries() + + refute valid + assert errors == [countries: {"Invalid countries: USA", [validation: :alpha2]}] + end + + test "when countries is invalid 2 digit code, is invalid" do + %Ecto.Changeset{errors: errors, valid?: valid} = + %{"countries" => "XX"} + |> WithStringCountries.changeset() + |> Country.validate_countries() + + refute valid + assert errors == [countries: {"Invalid countries: XX", [validation: :alpha2]}] + end + end + + describe "maybe_insert/1" do + test "when there is no country with the provided name, inserts it" do + country = Country.maybe_insert!(@valid_attrs) + + assert [country] == Country.all() + end + + test "when there is a country with the provided name, returns the pre-existent one" do + insert(@valid_attrs) + assert [preexistent_country] = Country.all() + + country = Country.maybe_insert!(@valid_attrs) + + assert preexistent_country == country + assert [country] == Country.all() + end + end + + describe "fingerprint/1" do + test "given two different lists of countries, generates different fingerprints" do + countries1 = [%Country{code: "GB"}, %Country{code: "US"}] + countries2 = [%Country{code: "US"}, %Country{code: "IE"}] + + refute Country.fingerprint(countries1) == Country.fingerprint(countries2) + end + + test "given two lists of countries with the same names, generates the same fingerprints" do + countries1 = [%Country{code: "GB"}, %Country{code: "US"}] + countries2 = [%Country{code: "GB"}, %Country{code: "US"}] + + assert Country.fingerprint(countries1) == Country.fingerprint(countries2) + end + + test "given two lists of countries with the same and different order, generates the same fingerprints" do + countries1 = [%Country{code: "GB"}, %Country{code: "US"}] + countries2 = [%Country{code: "US"}, %Country{code: "GB"}] + + assert Country.fingerprint(countries1) == Country.fingerprint(countries2) + end + end + + describe "link/1" do + test "links existing countries to changeset" do + attrs = %{ + "countries" => [ + %{"code" => "GB"}, + %{"code" => "US"} + ] + } + + countries = Enum.map(attrs["countries"], &insert!/1) + + changeset = + attrs + |> WithManyCountries.changeset() + |> Country.link() + + assert changeset.valid? + assert countries == get_countries(changeset) + end + + test "links non-existing countries to changeset, inserting them" do + attrs = %{ + "countries" => [ + %{"code" => "GB"}, + %{"code" => "US"} + ] + } + + changeset = + attrs + |> WithManyCountries.changeset() + |> Country.link() + + assert changeset.valid? + assert Country.all() == get_countries(changeset) + end + + test "has no side effects when changeset is invalid" do + changeset = + %{"countries" => [%{}]} + |> WithManyCountries.changeset() + |> Country.link() + + refute changeset.valid? + assert Enum.empty?(Country.all()) + end + end + + describe "link_fingerprint/1" do + test "links fingerprint using country names to changeset" do + attrs = %{ + "countries" => [ + %{"code" => "GB"}, + %{"code" => "US"} + ] + } + + changeset = + attrs + |> WithManyCountries.changeset() + |> Country.link_fingerprint() + + assert changeset.valid? + assert Country.fingerprint(get_countries(changeset)) == get_fingerprint(changeset) + end + + test "does not link fingerprint to invalid changeset" do + changeset = + %{"countries" => [%{}]} + |> WithManyCountries.changeset() + |> Country.link_fingerprint() + + refute changeset.valid? + assert is_nil(get_fingerprint(changeset)) + end + end + + describe "flatten/1" do + test "with a list of maps with string keys, returns a list of maps with code key" do + countries = [ + %{"code" => "GB"}, + %{"code" => "US"} + ] + + assert "GB, US" = Country.flatten(countries) + end + + test "with a list of maps with atom keys, returns a list of maps with code key" do + countries = [ + %{code: "GB"}, + %{code: "US"} + ] + + assert "GB, US" = Country.flatten(countries) + end + + test "with a list of Country structs, returns a list of maps with code key" do + countries = [ + %Country{code: "GB"}, + %Country{code: "US"} + ] + + assert "GB, US" = Country.flatten(countries) + end + end + + describe "nest/1" do + test "with a comma separated string, returns a list of maps with code key" do + countries = "GB, US" + + assert [%{"code" => "GB"}, %{"code" => "US"}] = Country.nest(countries) + end + end +end diff --git a/backend/test/richard_burton/flat_publication_test.exs b/backend/test/richard_burton/flat_publication_test.exs index 4041a505..1f2339a3 100644 --- a/backend/test/richard_burton/flat_publication_test.exs +++ b/backend/test/richard_burton/flat_publication_test.exs @@ -11,9 +11,9 @@ defmodule RichardBurton.FlatPublicationTest do @valid_attrs %{ "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", - "country" => "GB", + "countries" => "GB", "year" => 1886, - "publisher" => "Bickers & Son", + "publishers" => "Bickers & Son", "authors" => "Richard Burton, Isabel Burton", "original_authors" => "J. M. Pereira da Silva", "original_title" => "Manuel de Moraes: crônica do século XVII" @@ -33,9 +33,9 @@ defmodule RichardBurton.FlatPublicationTest do @empty_attrs_error_map %{ title: :required, - country: :required, + countries: :required, year: :required, - publisher: :required, + publishers: :required, authors: :required, original_authors: :required, original_title: :required @@ -66,32 +66,12 @@ defmodule RichardBurton.FlatPublicationTest do refute change_valid(%{"title" => nil}).valid? end - test "when country is blank, is invalid" do - refute change_valid(%{"country" => ""}).valid? + test "when publishers is blank, is invalid" do + refute change_valid(%{"publishers" => ""}).valid? end - test "when country is nil, is invalid" do - refute change_valid(%{"country" => nil}).valid? - end - - test "when country is valid alpha3 code, is invalid" do - refute change_valid(%{"country" => "USA"}).valid? - end - - test "when country is invalid 3 digit code, is invalid" do - refute change_valid(%{"country" => "EUA"}).valid? - end - - test "when country is invalid 2 digit code, is invalid" do - refute change_valid(%{"country" => "XX"}).valid? - end - - test "when publisher is blank, is invalid" do - refute change_valid(%{"publisher" => ""}).valid? - end - - test "when publisher is nil, is invalid" do - refute change_valid(%{"publisher" => nil}).valid? + test "when publishers is nil, is invalid" do + refute change_valid(%{"publishers" => nil}).valid? end test "when year is nil, is invalid" do diff --git a/backend/test/richard_burton/publication_codec_test.exs b/backend/test/richard_burton/publication_codec_test.exs index 59851aa1..41f36af9 100644 --- a/backend/test/richard_burton/publication_codec_test.exs +++ b/backend/test/richard_burton/publication_codec_test.exs @@ -6,7 +6,9 @@ defmodule RichardBurton.Publication.CodecTest do use RichardBurton.DataCase alias RichardBurton.Author + alias RichardBurton.Country alias RichardBurton.Publication + alias RichardBurton.Publisher alias RichardBurton.FlatPublication alias RichardBurton.TranslatedBook alias RichardBurton.OriginalBook @@ -18,37 +20,37 @@ defmodule RichardBurton.Publication.CodecTest do output = [ %{ "authors" => "Isabel Burton, Richard Burton", - "country" => "GB", + "countries" => "GB", "original_authors" => "José de Alencar", "original_title" => "Iracema", - "publisher" => "Bickers & Son", + "publishers" => "Bickers & Son", "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886" }, %{ "authors" => "J. T. W. Sadler", - "country" => "US", + "countries" => "US, GB", "original_authors" => "José de Alencar", "original_title" => "Ubirajara", - "publisher" => "Ronald Massey", + "publishers" => "Ronald Massey", "title" => "Ubirajara: A Legend of the Tupy Indians", "year" => "1922" }, %{ "authors" => "", - "country" => "GB", + "countries" => "GB", "original_authors" => "José de Alencar", "original_title" => "Iracema", - "publisher" => "Bickers & Son", + "publishers" => "Bickers & Son", "title" => "", "year" => "AAAA" }, %{ "authors" => "J. T. W. Sadler", - "country" => "", + "countries" => "", "original_authors" => "", "original_title" => "", - "publisher" => "", + "publishers" => "", "title" => "Ubirajara: A Legend of the Tupy Indians", "year" => "" } @@ -65,29 +67,29 @@ defmodule RichardBurton.Publication.CodecTest do output = [ %{ "authors" => "Isabel Burton", - "country" => "GB", + "countries" => "GB", "original_authors" => "José de Alencar", "original_title" => "Iracema", - "publisher" => "", + "publishers" => "", "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886" }, %{ "authors" => "Ronald Massey", - "country" => "Ubirajara", + "countries" => "Ubirajara", "original_authors" => "", "original_title" => "Ubirajara: A Legend of the Tupy Indians", - "publisher" => "", + "publishers" => "", "title" => "J. T. W. Sadler", "year" => "GB" }, %{ "authors" => "", - "country" => "", + "countries" => "", "original_authors" => "José de Alencar,1886,GB,Iracema,Iraçéma the Honey-Lips: A Legend of Brazil,Isabel Burton,Bickers & Son", "original_title" => "", - "publisher" => "", + "publishers" => "", "title" => "", "year" => "" } @@ -111,8 +113,8 @@ defmodule RichardBurton.Publication.CodecTest do @output %{ "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => "GB", + "publishers" => "Bickers & Son, Noonday Press", "authors" => "Isabel Burton", "original_authors" => "José de Alencar", "original_title" => "Iracema" @@ -121,8 +123,10 @@ defmodule RichardBurton.Publication.CodecTest do @output_struct %FlatPublication{ title: "Iraçéma the Honey-Lips: A Legend of Brazil", year: 1886, - country: "GB", - publisher: "Bickers & Son", + countries: "GB", + countries_fingerprint: "B4043B0B8297E379BC559AB33B6AE9C7A9B4EF6519D3BAEE53270F0C0DD3D960", + publishers: "Bickers & Son, Noonday Press", + publishers_fingerprint: "74A2D52E4AC165134F6A85A9A109A3459B37B557C2D3939D33F02821A8D8A97D", authors: "Isabel Burton", original_authors: "José de Alencar", original_title: "Iracema", @@ -134,8 +138,8 @@ defmodule RichardBurton.Publication.CodecTest do input = %{ "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => "GB", + "publishers" => [%{"name" => "Bickers & Son"}, %{"name" => "Noonday Press"}], "translated_book" => %{ "authors" => [ %{"name" => "Isabel Burton"} @@ -156,8 +160,8 @@ defmodule RichardBurton.Publication.CodecTest do input = %{ title: "Iraçéma the Honey-Lips: A Legend of Brazil", year: "1886", - country: "GB", - publisher: "Bickers & Son", + countries: [%{code: "GB"}], + publishers: [%{name: "Bickers & Son"}, %{name: "Noonday Press"}], translated_book: %{ authors: [ %{name: "Isabel Burton"} @@ -178,16 +182,12 @@ defmodule RichardBurton.Publication.CodecTest do input = %Publication{ title: "Iraçéma the Honey-Lips: A Legend of Brazil", year: "1886", - country: "GB", - publisher: "Bickers & Son", + countries: [%Country{code: "GB"}], + publishers: [%Publisher{name: "Bickers & Son"}, %Publisher{name: "Noonday Press"}], translated_book: %TranslatedBook{ - authors: [ - %Author{name: "Isabel Burton"} - ], + authors: [%Author{name: "Isabel Burton"}], original_book: %OriginalBook{ - authors: [ - %Author{name: "José de Alencar"} - ], + authors: [%Author{name: "José de Alencar"}], title: "Iracema" } } @@ -201,8 +201,8 @@ defmodule RichardBurton.Publication.CodecTest do %{ "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => [%{"code" => "GB"}], + "publishers" => [%{"name" => "Bickers & Son"}, %{"name" => "Noonday Press"}], "translated_book" => %{ "authors" => [ %{"name" => "Isabel Burton"} @@ -218,8 +218,8 @@ defmodule RichardBurton.Publication.CodecTest do %{ title: "Iraçéma the Honey-Lips: A Legend of Brazil", year: "1886", - country: "GB", - publisher: "Bickers & Son", + countries: [%{code: "GB"}], + publishers: [%{name: "Bickers & Son"}, %{name: "Noonday Press"}], translated_book: %{ authors: [ %{name: "Isabel Burton"} @@ -235,8 +235,8 @@ defmodule RichardBurton.Publication.CodecTest do %Publication{ title: "Iraçéma the Honey-Lips: A Legend of Brazil", year: "1886", - country: "GB", - publisher: "Bickers & Son", + countries: [%Country{code: "GB"}], + publishers: [%Publisher{name: "Bickers & Son"}, %Publisher{name: "Noonday Press"}], translated_book: %TranslatedBook{ authors: [ %Author{name: "Isabel Burton"} @@ -259,8 +259,8 @@ defmodule RichardBurton.Publication.CodecTest do @output_publication %{ "title" => "Ubirajara: A Legend of the Tupy Indians", "year" => "", - "country" => "", - "publisher" => "", + "countries" => "", + "publishers" => "", "authors" => "J. T. W. Sadler", "original_authors" => "", "original_title" => "" @@ -270,8 +270,8 @@ defmodule RichardBurton.Publication.CodecTest do input_publication = %{ "title" => "Ubirajara: A Legend of the Tupy Indians", "year" => "", - "country" => "", - "publisher" => "", + "countries" => "", + "publishers" => "", "translated_book" => %{ "authors" => [ %{"name" => "J. T. W. Sadler"} @@ -290,8 +290,8 @@ defmodule RichardBurton.Publication.CodecTest do publication: input_publication, errors: %{ year: "required", - country: "required", - publisher: "required", + countries: "required", + publishers: "required", translated_book: %{ original_book: %{ authors: [%{name: "required"}], @@ -315,8 +315,8 @@ defmodule RichardBurton.Publication.CodecTest do "publication" => @output_publication, "errors" => %{ "year" => "required", - "country" => "required", - "publisher" => "required", + "countries" => "required", + "publishers" => "required", "original_authors" => "required", "original_title" => "required" } @@ -339,8 +339,8 @@ defmodule RichardBurton.Publication.CodecTest do @output %{ "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => [%{"code" => "GB"}], + "publishers" => [%{"name" => "Bickers & Son"}, %{"name" => "Noonday Press"}], "translated_book" => %{ "authors" => [ %{"name" => "Isabel Burton"} @@ -357,8 +357,10 @@ defmodule RichardBurton.Publication.CodecTest do @output_struct %Publication{ title: "Iraçéma the Honey-Lips: A Legend of Brazil", year: 1886, - country: "GB", - publisher: "Bickers & Son", + countries: [%Country{code: "GB"}], + countries_fingerprint: "B4043B0B8297E379BC559AB33B6AE9C7A9B4EF6519D3BAEE53270F0C0DD3D960", + publishers: [%Publisher{name: "Bickers & Son"}, %Publisher{name: "Noonday Press"}], + publishers_fingerprint: "74A2D52E4AC165134F6A85A9A109A3459B37B557C2D3939D33F02821A8D8A97D", translated_book: %TranslatedBook{ authors: [ %Author{name: "Isabel Burton"} @@ -378,12 +380,12 @@ defmodule RichardBurton.Publication.CodecTest do "954F4C8E5EB33960B733BADB84134970AF5D970879260138C8C214B66DDBEF1F" } - test "on a nested publication-like with string keys, returns the flattened representation with string keys" do + test "on a flat-publication-like with string keys, returns the nested representation with string keys" do input = %{ "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => "GB", + "publishers" => "Bickers & Son, Noonday Press", "authors" => "Isabel Burton", "original_authors" => "José de Alencar", "original_title" => "Iracema" @@ -392,12 +394,12 @@ defmodule RichardBurton.Publication.CodecTest do assert @output == Publication.Codec.nest(input) end - test "on a nested publication-like with atom keys, returns the flattened representation with string keys" do + test "on a flat-publication-like with atom keys, returns the nested representation with string keys" do input = %{ title: "Iraçéma the Honey-Lips: A Legend of Brazil", year: "1886", - country: "GB", - publisher: "Bickers & Son", + countries: "GB", + publishers: "Bickers & Son, Noonday Press", authors: "Isabel Burton", original_authors: "José de Alencar", original_title: "Iracema" @@ -406,12 +408,12 @@ defmodule RichardBurton.Publication.CodecTest do assert @output == Publication.Codec.nest(input) end - test "on a Publication struct, returns the flattened representation with string keys" do + test "on a FlatPublication struct, returns the nested representation with string keys" do input = %FlatPublication{ title: "Iraçéma the Honey-Lips: A Legend of Brazil", year: "1886", - country: "GB", - publisher: "Bickers & Son", + countries: "GB", + publishers: "Bickers & Son, Noonday Press", authors: "Isabel Burton", original_authors: "José de Alencar", original_title: "Iracema" @@ -420,13 +422,13 @@ defmodule RichardBurton.Publication.CodecTest do assert @output_struct == Publication.Codec.nest(input) end - test "on a list, returns the flattened representation of its items, with string keys" do + test "on a list, returns the nested representation of its items, with string keys" do input = [ %{ "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => "GB", + "publishers" => "Bickers & Son, Noonday Press", "authors" => "Isabel Burton", "original_authors" => "José de Alencar", "original_title" => "Iracema" @@ -434,8 +436,8 @@ defmodule RichardBurton.Publication.CodecTest do %{ title: "Iraçéma the Honey-Lips: A Legend of Brazil", year: "1886", - country: "GB", - publisher: "Bickers & Son", + countries: "GB", + publishers: "Bickers & Son, Noonday Press", authors: "Isabel Burton", original_authors: "José de Alencar", original_title: "Iracema" @@ -443,8 +445,8 @@ defmodule RichardBurton.Publication.CodecTest do %FlatPublication{ title: "Iraçéma the Honey-Lips: A Legend of Brazil", year: "1886", - country: "GB", - publisher: "Bickers & Son", + countries: "GB", + publishers: "Bickers & Son, Noonday Press", authors: "Isabel Burton", original_authors: "José de Alencar", original_title: "Iracema" diff --git a/backend/test/richard_burton/publication_index_test.exs b/backend/test/richard_burton/publication_index_test.exs index 184c9b01..2e1806f2 100644 --- a/backend/test/richard_burton/publication_index_test.exs +++ b/backend/test/richard_burton/publication_index_test.exs @@ -12,154 +12,188 @@ defmodule RichardBurton.Publication.IndexTest do @publications [ %FlatPublication{ authors: "Arthur Brakel", - country: "CA", + countries: "CA", + countries_fingerprint: "4B650E5C4785025DEE7BD65E3C5C527356717D7A1C0BFEF5B4ADA8CA1E9CBE17", original_authors: "Cyro dos Anjos", original_title: "O amanuense Belmiro", - publisher: "Fairleigh Dickinson University Press", + publishers: "Fairleigh Dickinson University Press", + publishers_fingerprint: "BDBBE0C6ACE0F5D7CDAC2301CBD7DDE19808618AF03AB6B6546FF30A82F4FA5E", title: "Diary of a Civil Servant", year: 1986 }, %FlatPublication{ authors: "Arthur Brakel", - country: "GB", + countries: "GB", + countries_fingerprint: "B4043B0B8297E379BC559AB33B6AE9C7A9B4EF6519D3BAEE53270F0C0DD3D960", original_authors: "Cyro dos Anjos", original_title: "O amanuense Belmiro", - publisher: "Associated University Presses", + publishers: "Associated University Presses", + publishers_fingerprint: "FA1B59EB992D97EB10B6219661EA4C9C740D509048CC0DF9A86EB3BC8EB8E45B", title: "Diary of a Civil Servant", year: 1988 }, %FlatPublication{ authors: "Dorothy Scott Loos", - country: "US", + countries: "GB, US", + countries_fingerprint: "F060274D35CC0709781F13A9331376B035C9A04546FE43381BC5749F1362C8BF", original_authors: "Rachel de Queiroz", original_title: "Dora Doralina", - publisher: "Dutton", + publishers: "Dutton", + publishers_fingerprint: "289485905D12E66D52118BCFECB6C911B1A8E4379477DD98ED30F2ED795E260C", title: "Dora Doralina", year: 1984 }, %FlatPublication{ authors: "E. Percy Ellis", - country: "BR", + countries: "BR", + countries_fingerprint: "BBAF8352442730E92C16C5EA6B0FF7CC595C24E02D8E8BFC5FEA5A4E0BB0B46B", original_authors: "Machado de Assis", original_title: "Memórias póstumas de Brás Cubas", - publisher: "Instituto Nacional do Livro", + publishers: "Instituto Nacional do Livro", + publishers_fingerprint: "CFC153F1AB2F32958A66F3F4B36EECFFDF8A28C48F202DE09FFEFF6BE98F1027", title: "Posthumous Reminiscences of Brás Cubas", year: 1955 }, %FlatPublication{ authors: "Fred P. Ellison", - country: "US", + countries: "US", + countries_fingerprint: "9B202ECBC6D45C6D8901D989A918878397A3EB9D00E8F48022FC051B19D21A1D", original_authors: "Rachel de Queiroz", original_title: "As três Marias", - publisher: "University of Texas Press", + publishers: "University of Texas Press", + publishers_fingerprint: "2F6FE554F3CF1014B2345ADE7C06166EA58D929FBEE633D4A782126F5C4331EA", title: "The Three Marias", year: 1963 }, %FlatPublication{ authors: "Gregory Rabassa", - country: "US", + countries: "US", + countries_fingerprint: "9B202ECBC6D45C6D8901D989A918878397A3EB9D00E8F48022FC051B19D21A1D", original_authors: "Machado de Assis", original_title: "Memórias póstumas de Brás Cubas", - publisher: "Oxford University Press", + publishers: "Oxford University Press", + publishers_fingerprint: "27E4CE2B302408251962F38DD2928A99EB212A7BB09088BBBE6F77944A11A90D", title: "Posthumous Memoirs of Bras Cubas", year: 1997 }, %FlatPublication{ authors: "Jean Neel Karnoff", - country: "GB", + countries: "GB", + countries_fingerprint: "B4043B0B8297E379BC559AB33B6AE9C7A9B4EF6519D3BAEE53270F0C0DD3D960", original_authors: "Erico Verissimo", original_title: "Olhai os lírios do campo", - publisher: "Greenwood", + publishers: "Greenwood", + publishers_fingerprint: "37AA9A83218BF5F4A5F6EABA530C07E64316BA03788B42D0A2A419719B8B12BC", title: "Consider the Lilies of the Field", year: 1969 }, %FlatPublication{ authors: "L. C. Kaplan", - country: "US", + countries: "US", + countries_fingerprint: "9B202ECBC6D45C6D8901D989A918878397A3EB9D00E8F48022FC051B19D21A1D", original_authors: "Graciliano Ramos", original_title: "Angústia", - publisher: "Alfred A. Knopf", + publishers: "Alfred A. Knopf", + publishers_fingerprint: "A4AFA4682BC9F658DD5DAD7649822F925C9A4FB0A72459F631FA32D07CC405D4", title: "Anguish", year: 1946 }, %FlatPublication{ authors: "Linton Lemos Barrett", - country: "GB", + countries: "GB", + countries_fingerprint: "B4043B0B8297E379BC559AB33B6AE9C7A9B4EF6519D3BAEE53270F0C0DD3D960", original_authors: "Erico Verissimo", original_title: "O tempo e o vento", - publisher: "Arco Publications", + publishers: "Arco Publications", + publishers_fingerprint: "DD6D4A5F8B8C4DD9BB9E5AD5634BB98CC3568E943729417FD69846D75C07B802", title: "Time and the Wind", year: 1954 }, %FlatPublication{ authors: "Linton Lemos Barrett", - country: "GB", + countries: "GB", + countries_fingerprint: "B4043B0B8297E379BC559AB33B6AE9C7A9B4EF6519D3BAEE53270F0C0DD3D960", original_authors: "Erico Verissimo", original_title: "Noite", - publisher: "Arco Publications", + publishers: "Arco Publications", + publishers_fingerprint: "DD6D4A5F8B8C4DD9BB9E5AD5634BB98CC3568E943729417FD69846D75C07B802", title: "Night", year: 1956 }, %FlatPublication{ authors: "Linton Lemos Barrett", - country: "US", + countries: "US", + countries_fingerprint: "9B202ECBC6D45C6D8901D989A918878397A3EB9D00E8F48022FC051B19D21A1D", original_authors: "Erico Verissimo", original_title: "O tempo e o vento", - publisher: "Macmillan", + publishers: "Macmillan", + publishers_fingerprint: "873D23F97EEB8B04973339EC8A202DC8AEC0B33298D2E194301E223ECD7E9C05", title: "Time and the Wind", year: 1951 }, %FlatPublication{ authors: "Linton Lemos Barrett", - country: "US", + countries: "US", + countries_fingerprint: "9B202ECBC6D45C6D8901D989A918878397A3EB9D00E8F48022FC051B19D21A1D", original_authors: "Erico Verissimo", original_title: "Noite", - publisher: "Macmillan", + publishers: "Macmillan", + publishers_fingerprint: "873D23F97EEB8B04973339EC8A202DC8AEC0B33298D2E194301E223ECD7E9C05", title: "Night", year: 1956 }, %FlatPublication{ authors: "Linton Lemos Barrett, Marie Barrett", - country: "US", + countries: "US", + countries_fingerprint: "9B202ECBC6D45C6D8901D989A918878397A3EB9D00E8F48022FC051B19D21A1D", original_authors: "Erico Verissimo", original_title: "O senhor embaixador", - publisher: "Macmillan", + publishers: "Macmillan", + publishers_fingerprint: "873D23F97EEB8B04973339EC8A202DC8AEC0B33298D2E194301E223ECD7E9C05", title: "His Excellency, the Ambassador", year: 1967 }, %FlatPublication{ authors: "Margaret Richardson Hollingsworth", - country: "US", + countries: "US", + countries_fingerprint: "9B202ECBC6D45C6D8901D989A918878397A3EB9D00E8F48022FC051B19D21A1D", original_authors: "Mário de Andrade", original_title: "Amar verbo intransitivo", - publisher: "MacCaulay", + publishers: "MacCaulay", + publishers_fingerprint: "A092A747DE2B957ADC822F5FEE63B2078F4CEE237438789BBF9A6D10F9F104E1", title: "Fraulein", year: 1933 }, %FlatPublication{ authors: "Thomas Colchie", - country: "US", + countries: "US", + countries_fingerprint: "9B202ECBC6D45C6D8901D989A918878397A3EB9D00E8F48022FC051B19D21A1D", original_authors: "Graciliano Ramos", original_title: "Memórias do cárcere", - publisher: "Evans", + publishers: "Evans", + publishers_fingerprint: "8658EBF1EDF525094102102EB55229187C236F9147950C775273B2D33AF516F0", title: "Jail Prison Memoirs", year: 1974 }, %FlatPublication{ authors: "William L. Grossman", - country: "GB", + countries: "GB", + countries_fingerprint: "B4043B0B8297E379BC559AB33B6AE9C7A9B4EF6519D3BAEE53270F0C0DD3D960", original_authors: "Machado de Assis", original_title: "Memórias póstumas de Brás Cubas", - publisher: "W.H. Allen", + publishers: "W.H. Allen", + publishers_fingerprint: "0AE69A42F21F227103D46FF569A689AC1A139BD3F036C74DEA48E8C86FF93326", title: "Epitaph of a Small Winner", year: 1953 }, %FlatPublication{ authors: "William L. Grossman", - country: "US", + countries: "US", + countries_fingerprint: "9B202ECBC6D45C6D8901D989A918878397A3EB9D00E8F48022FC051B19D21A1D", original_authors: "Machado de Assis", original_title: "Memórias póstumas de Brás Cubas", - publisher: "Noonday Press", + publishers: "Noonday Press", + publishers_fingerprint: "3444E1379BFB654A280E4E86B4BD0916534828F1AA529FFB8714D315E203F166", title: "Epitaph of a Small Winner", year: 1952 } @@ -331,7 +365,7 @@ defmodule RichardBurton.Publication.IndexTest do assert_search_results( publications, expect: [ - country: expected_countries + countries: expected_countries ] ) end @@ -361,7 +395,7 @@ defmodule RichardBurton.Publication.IndexTest do assert_search_results( publications, expect: [ - publisher: expected_publishers + publishers: expected_publishers ] ) end diff --git a/backend/test/richard_burton/publication_test.exs b/backend/test/richard_burton/publication_test.exs index 9da1eb6b..8a35520d 100644 --- a/backend/test/richard_burton/publication_test.exs +++ b/backend/test/richard_burton/publication_test.exs @@ -12,9 +12,9 @@ defmodule RichardBurton.PublicationTest do @valid_attrs %{ "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", - "country" => "GB", + "countries" => [%{"code" => "GB"}], "year" => 1886, - "publisher" => "Bickers & Son", + "publishers" => [%{"name" => "Bickers & Son"}], "translated_book" => %{ "authors" => [ %{"name" => "Richard Burton"}, @@ -34,17 +34,17 @@ defmodule RichardBurton.PublicationTest do @empty_attrs_error_map %{ title: :required, - country: :required, + countries: :required, year: :required, - publisher: :required, + publishers: :required, translated_book: :required } @skeleton_attrs_error_map %{ title: :required, - country: :required, + countries: :required, year: :required, - publisher: :required, + publishers: :required, translated_book: %{ authors: :required, original_book: %{authors: :required, title: :required} @@ -76,32 +76,32 @@ defmodule RichardBurton.PublicationTest do refute change_valid(%{"title" => nil}).valid? end - test "when country is blank, is invalid" do - refute change_valid(%{"country" => ""}).valid? + test "when countries is blank, is invalid" do + refute change_valid(%{"countries" => ""}).valid? end - test "when country is nil, is invalid" do - refute change_valid(%{"country" => nil}).valid? + test "when countries is nil, is invalid" do + refute change_valid(%{"countries" => nil}).valid? end - test "when country is valid alpha3 code, is invalid" do - refute change_valid(%{"country" => "USA"}).valid? + test "when countries is valid alpha3 code, is invalid" do + refute change_valid(%{"countries" => "USA"}).valid? end - test "when country is invalid 3 digit code, is invalid" do - refute change_valid(%{"country" => "EUA"}).valid? + test "when countries is invalid 3 digit code, is invalid" do + refute change_valid(%{"countries" => "EUA"}).valid? end - test "when country is invalid 2 digit code, is invalid" do - refute change_valid(%{"country" => "XX"}).valid? + test "when countries is invalid 2 digit code, is invalid" do + refute change_valid(%{"countries" => "XX"}).valid? end - test "when publisher is blank, is invalid" do - refute change_valid(%{"publisher" => ""}).valid? + test "when publishers is blank, is invalid" do + refute change_valid(%{"publishers" => ""}).valid? end - test "when publisher is nil, is invalid" do - refute change_valid(%{"publisher" => nil}).valid? + test "when publishers is nil, is invalid" do + refute change_valid(%{"publishers" => nil}).valid? end test "when year is nil, is invalid" do diff --git a/backend/test/richard_burton/publisher_test.exs b/backend/test/richard_burton/publisher_test.exs new file mode 100644 index 00000000..82652450 --- /dev/null +++ b/backend/test/richard_burton/publisher_test.exs @@ -0,0 +1,318 @@ +defmodule RichardBurton.PublisherTest do + @moduledoc """ + Tests for the Publisher schema + """ + use RichardBurton.DataCase + + alias RichardBurton.Publisher + alias RichardBurton.Validation + alias RichardBurton.Util + + defmodule WithManyPublishers do + use Ecto.Schema + import Ecto.Changeset + + schema "with_many_publishers" do + field(:publishers_fingerprint, :string) + has_many :publishers, Publisher + end + + def changeset(attrs) do + %WithManyPublishers{} |> cast(attrs, []) |> cast_assoc(:publishers) + end + end + + @valid_attrs %{ + "name" => "Noonday Press" + } + + @publishers [ + %{"name" => "Random House"}, + %{"name" => "Bantam Books"}, + %{"name" => "Dutton"}, + %{"name" => "UMass Dartmouth"}, + %{"name" => "University Press of Kentucky"} + ] + + defp changeset(attrs = %{}) do + Publisher.changeset(%Publisher{}, attrs) + end + + defp change_valid(attrs = %{}) do + changeset(Util.deep_merge_maps(@valid_attrs, attrs)) + end + + defp insert(attrs) do + %Publisher{} |> Publisher.changeset(attrs) |> Repo.insert() + end + + defp insert!(attrs) do + attrs |> changeset() |> Repo.insert!() + end + + defp get_publishers(changeset = %Ecto.Changeset{}) do + changeset + |> get_change(:publishers) + |> Enum.map(&apply_changes/1) + end + + defp get_fingerprint(changeset = %Ecto.Changeset{}) do + get_change(changeset, :publishers_fingerprint) + end + + def search_fixture(_) do + @publishers + |> Enum.map(&Publisher.changeset(%Publisher{}, &1)) + |> Enum.each(&Repo.insert!/1) + + [] + end + + describe "changeset/2" do + test "when valid attributes are provided, is valid" do + assert changeset(@valid_attrs).valid? + end + + test "when name is blank, is invalid" do + refute change_valid(%{"name" => ""}).valid? + end + + test "when name is nil, is invalid" do + refute change_valid(%{"name" => nil}).valid? + end + + test "when name is a non-blank string, is valid" do + assert change_valid(%{"name" => "Bickers & SonA"}).valid? + end + + test "when a publisher with the provided attributes already exists, is invalid" do + {:ok, _} = insert(@valid_attrs) + {:error, changeset} = insert(@valid_attrs) + + refute changeset.valid? + assert :conflict == Validation.get_errors(changeset) + end + end + + describe "maybe_insert/1" do + test "when there is no publisher with the provided name, inserts it" do + publisher = Publisher.maybe_insert!(@valid_attrs) + + assert [publisher] == Publisher.all() + end + + test "when there is a publisher with the provided name, returns the pre-existent one" do + insert(@valid_attrs) + assert [preexistent_publisher] = Publisher.all() + + publisher = Publisher.maybe_insert!(@valid_attrs) + + assert preexistent_publisher == publisher + assert [publisher] == Publisher.all() + end + end + + describe "fingerprint/1" do + test "given two different lists of publishers, generates different fingerprints" do + publishers1 = [%Publisher{name: "Noonday Press"}, %Publisher{name: "Bickers & Son"}] + publishers2 = [%Publisher{name: "Bickers & Son"}, %Publisher{name: "IE"}] + + refute Publisher.fingerprint(publishers1) == Publisher.fingerprint(publishers2) + end + + test "given two lists of publishers with the same names, generates the same fingerprints" do + publishers1 = [%Publisher{name: "Noonday Press"}, %Publisher{name: "Bickers & Son"}] + publishers2 = [%Publisher{name: "Noonday Press"}, %Publisher{name: "Bickers & Son"}] + + assert Publisher.fingerprint(publishers1) == Publisher.fingerprint(publishers2) + end + + test "given two lists of publishers with the same and different order, generates the same fingerprints" do + publishers1 = [%Publisher{name: "Noonday Press"}, %Publisher{name: "Bickers & Son"}] + publishers2 = [%Publisher{name: "Bickers & Son"}, %Publisher{name: "Noonday Press"}] + + assert Publisher.fingerprint(publishers1) == Publisher.fingerprint(publishers2) + end + end + + describe "link/1" do + test "links existing publishers to changeset" do + attrs = %{ + "publishers" => [ + %{"name" => "Noonday Press"}, + %{"name" => "Bickers & Son"} + ] + } + + publishers = Enum.map(attrs["publishers"], &insert!/1) + + changeset = + attrs + |> WithManyPublishers.changeset() + |> Publisher.link() + + assert changeset.valid? + assert publishers == get_publishers(changeset) + end + + test "links non-existing publishers to changeset, inserting them" do + attrs = %{ + "publishers" => [ + %{"name" => "Noonday Press"}, + %{"name" => "Bickers & Son"} + ] + } + + changeset = + attrs + |> WithManyPublishers.changeset() + |> Publisher.link() + + assert changeset.valid? + assert Publisher.all() == get_publishers(changeset) + end + + test "has no side effects when changeset is invalid" do + changeset = + %{"publishers" => [%{}]} + |> WithManyPublishers.changeset() + |> Publisher.link() + + refute changeset.valid? + assert Enum.empty?(Publisher.all()) + end + end + + describe "link_fingerprint/1" do + test "links fingerprint Bickers & Soning publisher names to changeset" do + attrs = %{ + "publishers" => [ + %{"name" => "Noonday Press"}, + %{"name" => "Bickers & Son"} + ] + } + + changeset = + attrs + |> WithManyPublishers.changeset() + |> Publisher.link_fingerprint() + + assert changeset.valid? + assert Publisher.fingerprint(get_publishers(changeset)) == get_fingerprint(changeset) + end + + test "does not link fingerprint to invalid changeset" do + changeset = + %{"publishers" => [%{}]} + |> WithManyPublishers.changeset() + |> Publisher.link_fingerprint() + + refute changeset.valid? + assert is_nil(get_fingerprint(changeset)) + end + end + + describe "search/2 when second argument is :prefix" do + setup [:search_fixture] + + test "retrieves publishers by full name" do + term = "University Press of Kentucky" + + for %Publisher{name: name} <- Publisher.search(term, :prefix) do + assert name == term + end + end + + test "retrieves publishers by prefix" do + term = "U" + + for %Publisher{name: name} <- Publisher.search(term, :prefix) do + assert name in ["UMass Dartmouth", "University Press of Kentucky"] + end + end + end + + describe "search/2 when second argument is :fuzzy" do + setup [:search_fixture] + + test "retrieves publishers by full name" do + term = "University Press of Kentucky" + + for %Publisher{name: name} <- Publisher.search(term, :fuzzy) do + assert name == term + end + end + + test "retrieves publishers by similarity" do + for %Publisher{name: name} <- Publisher.search("Universiti Press of Kentucky", :fuzzy) do + assert name == "University Press of Kentucky" + end + + for %Publisher{name: name} <- Publisher.search("Tan", :fuzzy) do + assert name in ["Random House", "Bantam Books"] + end + + for %Publisher{name: name} <- Publisher.search("Dutan", :fuzzy) do + assert name in ["Dutton"] + end + end + end + + describe "search/1" do + setup [:search_fixture] + + test "searches by prefix first" do + for %Publisher{name: name} <- Publisher.search("Ran") do + assert name == "Random House" + end + end + + test "searches by similarity if prefix search renders no results" do + term = "Rant" + + assert [] == Publisher.search(term, :prefix) + + for %Publisher{name: name} <- Publisher.search(term) do + assert name in ["Random House", "Bantam Books"] + end + end + end + + describe "flatten/1" do + test "with a list of maps with string keys, returns a list of maps with name key" do + publishers = [ + %{"name" => "Noonday Press"}, + %{"name" => "Bickers & Son"} + ] + + assert "Noonday Press, Bickers & Son" = Publisher.flatten(publishers) + end + + test "with a list of maps with atom keys, returns a list of maps with name key" do + publishers = [ + %{name: "Noonday Press"}, + %{name: "Bickers & Son"} + ] + + assert "Noonday Press, Bickers & Son" = Publisher.flatten(publishers) + end + + test "with a list of Publisher structs, returns a list of maps with name key" do + publishers = [ + %Publisher{name: "Noonday Press"}, + %Publisher{name: "Bickers & Son"} + ] + + assert "Noonday Press, Bickers & Son" = Publisher.flatten(publishers) + end + end + + describe "nest/1" do + test "with a comma separated string, returns a list of maps with name key" do + publishers = "Noonday Press, Bickers & Son" + + assert [%{"name" => "Noonday Press"}, %{"name" => "Bickers & Son"}] = + Publisher.nest(publishers) + end + end +end diff --git a/backend/test/richard_burton/translated_book_test.exs b/backend/test/richard_burton/translated_book_test.exs index 2df67dc8..f7f72ff6 100644 --- a/backend/test/richard_burton/translated_book_test.exs +++ b/backend/test/richard_burton/translated_book_test.exs @@ -187,9 +187,9 @@ defmodule RichardBurton.TranslatedBookTest do describe "link/1" do @publication_attrs %{ "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", - "country" => "GB", + "countries" => [%{"code" => "GB"}], "year" => 1886, - "publisher" => "Bickers & Son", + "publishers" => [%{"name" => "Bickers & Son"}], "translated_book" => %{ "authors" => [ %{"name" => "Richard Burton"}, diff --git a/backend/test/richard_burton_web/controllers/publication_controller_test.exs b/backend/test/richard_burton_web/controllers/publication_controller_test.exs index b718536d..5061986c 100644 --- a/backend/test/richard_burton_web/controllers/publication_controller_test.exs +++ b/backend/test/richard_burton_web/controllers/publication_controller_test.exs @@ -2,16 +2,19 @@ defmodule RichardBurtonWeb.PublicationControllerTest do @moduledoc """ Tests for the Publication controller """ + alias RichardBurton.FlatPublication use RichardBurtonWeb.ConnCase import Routes, only: [publication_path: 2] + alias RichardBurton.Country alias RichardBurton.Publication + alias RichardBurton.Publisher @publication_attrs %{ "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => "GB", + "publishers" => "Bickers & Son", "authors" => "Isabel Burton", "original_authors" => "José de Alencar", "original_title" => "Iracema" @@ -33,78 +36,282 @@ defmodule RichardBurtonWeb.PublicationControllerTest do end describe "POST /publications/bulk" do - @valid_input_1 %{ - "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", - "country" => "GB", - "year" => 1886, - "publisher" => "Bickers & Son", - "authors" => "Richard Burton, Isabel Burton", - "original_authors" => "J. M. Pereira da Silva", - "original_title" => "Manuel de Moraes: crônica do século XVII" - } + test "returns 201 and the created publications when all the publications are valid", meta do + expect_auth_authorize_admin() - @valid_input_2 %{ - "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", - "year" => 1886, - "country" => "GB", - "publisher" => "Bickers & Son", - "authors" => "Isabel Burton", - "original_authors" => "José de Alencar", - "original_title" => "Iracema" - } + publications = [ + %{ + "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", + "countries" => "GB", + "year" => 1886, + "publishers" => "Bickers & Son", + "authors" => "Richard Burton, Isabel Burton", + "original_authors" => "J. M. Pereira da Silva", + "original_title" => "Manuel de Moraes: crônica do século XVII" + }, + %{ + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => 1886, + "countries" => "GB", + "publishers" => "Bickers & Son", + "authors" => "Isabel Burton", + "original_authors" => "José de Alencar", + "original_title" => "Iracema" + } + ] - @invalid_input %{ - "title" => "", - "year" => "1886", - "country" => "GB", - "publisher" => "Bickers & Son", - "authors" => "", - "original_authors" => "José de Alencar", - "original_title" => "Iracema" - } + input = %{"_json" => publications} - @invalid_input_errors %{ - "title" => "required", - "authors" => "required" - } + result = + meta.conn + |> post(publication_path(meta.conn, :create_all), input) + |> json_response(201) + + assert publications == result + end - test "on success, returns 201 and the created publications", meta do + test "returns 201 and inserts publications with several countries", meta do expect_auth_authorize_admin() - publications = [@valid_input_1, @valid_input_2] + + publications = [ + %{ + "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", + "countries" => "GB, US", + "year" => 1886, + "publishers" => "Bickers & Son", + "authors" => "Richard Burton, Isabel Burton", + "original_authors" => "J. M. Pereira da Silva", + "original_title" => "Manuel de Moraes: crônica do século XVII" + }, + %{ + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => 1886, + "countries" => "GB,US", + "publishers" => "Bickers & Son", + "authors" => "Isabel Burton", + "original_authors" => "José de Alencar", + "original_title" => "Iracema" + }, + %{ + "authors" => "Isabel Burton, Richard Burton", + "countries" => "GB, BR,US", + "original_authors" => "José de Alencar", + "original_title" => "Iracema", + "publishers" => "Bickers & Son", + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => "1886" + } + ] input = %{"_json" => publications} - assert publications == - meta.conn - |> post(publication_path(meta.conn, :create_all), input) - |> json_response(201) + result = + meta.conn + |> post(publication_path(meta.conn, :create_all), input) + |> json_response(201) + + output = [ + %{ + "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", + "countries" => "GB, US", + "year" => 1886, + "publishers" => "Bickers & Son", + "authors" => "Richard Burton, Isabel Burton", + "original_authors" => "J. M. Pereira da Silva", + "original_title" => "Manuel de Moraes: crônica do século XVII" + }, + %{ + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => 1886, + "countries" => "GB, US", + "publishers" => "Bickers & Son", + "authors" => "Isabel Burton", + "original_authors" => "José de Alencar", + "original_title" => "Iracema" + }, + %{ + "authors" => "Isabel Burton, Richard Burton", + "countries" => "GB, BR, US", + "original_authors" => "José de Alencar", + "original_title" => "Iracema", + "publishers" => "Bickers & Son", + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => 1886 + } + ] + + assert 3 == FlatPublication.all() |> length() + assert ["GB", "US", "BR"] == Country.all() |> Enum.map(&Country.get_code/1) + assert output == result end - test "on conflict, returns 409 and the conflictive publication", meta do + test "returns 201 and inserts publications with several publishers", meta do expect_auth_authorize_admin() - publications = [@valid_input_1, @valid_input_2, @valid_input_2] + + publications = [ + %{ + "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", + "countries" => "GB", + "year" => 1886, + "publishers" => "Bickers & Son,Noonday Press", + "authors" => "Richard Burton, Isabel Burton", + "original_authors" => "J. M. Pereira da Silva", + "original_title" => "Manuel de Moraes: crônica do século XVII" + }, + %{ + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => 1886, + "countries" => "GB", + "publishers" => "Bickers & Son, Noonday Press", + "authors" => "Isabel Burton", + "original_authors" => "José de Alencar", + "original_title" => "Iracema" + }, + %{ + "authors" => "Isabel Burton, Richard Burton", + "countries" => "GB", + "original_authors" => "José de Alencar", + "original_title" => "Iracema", + "publishers" => "Bickers & Son, Noonday Press,Ronald Massey", + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => "1886" + } + ] input = %{"_json" => publications} - assert @valid_input_2 == - meta.conn - |> post(publication_path(meta.conn, :create_all), input) - |> json_response(409) + result = + meta.conn + |> post(publication_path(meta.conn, :create_all), input) + |> json_response(201) + + output = [ + %{ + "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", + "countries" => "GB", + "year" => 1886, + "publishers" => "Bickers & Son, Noonday Press", + "authors" => "Richard Burton, Isabel Burton", + "original_authors" => "J. M. Pereira da Silva", + "original_title" => "Manuel de Moraes: crônica do século XVII" + }, + %{ + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => 1886, + "countries" => "GB", + "publishers" => "Bickers & Son, Noonday Press", + "authors" => "Isabel Burton", + "original_authors" => "José de Alencar", + "original_title" => "Iracema" + }, + %{ + "authors" => "Isabel Burton, Richard Burton", + "countries" => "GB", + "original_authors" => "José de Alencar", + "original_title" => "Iracema", + "publishers" => "Bickers & Son, Noonday Press, Ronald Massey", + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => 1886 + } + ] + + publishers = ["Bickers & Son", "Noonday Press", "Ronald Massey"] + + assert 3 == FlatPublication.all() |> length() + assert publishers == Publisher.all() |> Enum.map(&Publisher.get_name/1) + assert output == result end - test "on validation error, returns 409, the invalid publication and the errors", meta do + test "returns 409 when publications are repeated, and returns the first repeated one", meta do expect_auth_authorize_admin() - input = %{"_json" => [@valid_input_1, @invalid_input, @valid_input_2]} + + repeated_publication = %{ + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => 1886, + "countries" => "GB", + "publishers" => "Bickers & Son", + "authors" => "Isabel Burton", + "original_authors" => "José de Alencar", + "original_title" => "Iracema" + } + + publications = [ + %{ + "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", + "countries" => "GB", + "year" => 1886, + "publishers" => "Bickers & Son", + "authors" => "Richard Burton, Isabel Burton", + "original_authors" => "J. M. Pereira da Silva", + "original_title" => "Manuel de Moraes: crônica do século XVII" + }, + repeated_publication, + repeated_publication + ] + + input = %{"_json" => publications} + + result = + meta.conn + |> post(publication_path(meta.conn, :create_all), input) + |> json_response(409) + + assert repeated_publication == result + end + + test "returns 400 when a publication is invalid, and returns it with its errors", meta do + expect_auth_authorize_admin() + + invalid_publication = %{ + "title" => "", + "year" => 1886, + "countries" => "GB", + "publishers" => "Bickers & Son", + "authors" => "", + "original_authors" => "José de Alencar", + "original_title" => "Iracema" + } + + invalid_publication_errors = %{ + "title" => "required", + "authors" => "required" + } + + publications = [ + %{ + "title" => "Manuel de Moraes: A Chronicle of the Seventeenth Century", + "countries" => "GB", + "year" => 1886, + "publishers" => "Bickers & Son", + "authors" => "Richard Burton, Isabel Burton", + "original_authors" => "J. M. Pereira da Silva", + "original_title" => "Manuel de Moraes: crônica do século XVII" + }, + invalid_publication, + %{ + "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", + "year" => 1886, + "countries" => "GB", + "publishers" => "Bickers & Son", + "authors" => "Isabel Burton", + "original_authors" => "José de Alencar", + "original_title" => "Iracema" + } + ] + + input = %{"_json" => publications} output = %{ - "publication" => @invalid_input, - "errors" => @invalid_input_errors + "publication" => invalid_publication, + "errors" => invalid_publication_errors } - assert output == - meta.conn - |> post(publication_path(meta.conn, :create_all), input) - |> json_response(400) + result = + meta.conn + |> post(publication_path(meta.conn, :create_all), input) + |> json_response(400) + + assert output == result end end @@ -112,8 +319,8 @@ defmodule RichardBurtonWeb.PublicationControllerTest do @correct_input_1 %{ "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => "GB", + "publishers" => "Bickers & Son", "authors" => "Isabel Burton, Richard Burton", "original_authors" => "José de Alencar", "original_title" => "Iracema" @@ -121,8 +328,8 @@ defmodule RichardBurtonWeb.PublicationControllerTest do @correct_input_2 %{ "title" => "Ubirajara: A Legend of the Tupy Indians", "year" => "1922", - "country" => "US", - "publisher" => "Ronald Massey", + "countries" => "US", + "publishers" => "Ronald Massey", "authors" => "J. T. W. Sadler", "original_authors" => "José de Alencar", "original_title" => "Ubirajara" @@ -130,8 +337,8 @@ defmodule RichardBurtonWeb.PublicationControllerTest do @correct_input_3 %{ "title" => "", "year" => "AAAA", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => "GB", + "publishers" => "Bickers & Son", "authors" => "", "original_authors" => "José de Alencar", "original_title" => "Iracema" @@ -139,8 +346,8 @@ defmodule RichardBurtonWeb.PublicationControllerTest do @correct_input_4 %{ "title" => "Ubirajara: A Legend of the Tupy Indians", "year" => "", - "country" => "", - "publisher" => "", + "countries" => "", + "publishers" => "", "authors" => "J. T. W. Sadler", "original_authors" => "", "original_title" => "" @@ -148,8 +355,8 @@ defmodule RichardBurtonWeb.PublicationControllerTest do @correct_input_5 %{ "title" => "Ubirajara: A Legend of the Tupy Indians", "year" => "", - "country" => "USA", - "publisher" => "", + "countries" => "USA", + "publishers" => "", "authors" => "J. T. W. Sadler", "original_authors" => "", "original_title" => "" @@ -184,8 +391,8 @@ defmodule RichardBurtonWeb.PublicationControllerTest do "publication" => @correct_input_4, "errors" => %{ "year" => "required", - "country" => "required", - "publisher" => "required", + "countries" => "required", + "publishers" => "required", "original_authors" => "required", "original_title" => "required" } @@ -194,8 +401,8 @@ defmodule RichardBurtonWeb.PublicationControllerTest do "publication" => @correct_input_5, "errors" => %{ "year" => "required", - "country" => "alpha2", - "publisher" => "required", + "countries" => "alpha2", + "publishers" => "required", "original_authors" => "required", "original_title" => "required" } @@ -218,8 +425,8 @@ defmodule RichardBurtonWeb.PublicationControllerTest do "publication" => %{ "title" => "Iraçéma the Honey-Lips: A Legend of Brazil", "year" => "1886", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => "GB", + "publishers" => "Bickers & Son", "authors" => "Isabel Burton, Richard Burton", "original_authors" => "José de Alencar", "original_title" => "Iracema" @@ -230,8 +437,8 @@ defmodule RichardBurtonWeb.PublicationControllerTest do "publication" => %{ "title" => "Ubirajara: A Legend of the Tupy Indians", "year" => "1922", - "country" => "US", - "publisher" => "Ronald Massey", + "countries" => "US, GB", + "publishers" => "Ronald Massey", "authors" => "J. T. W. Sadler", "original_authors" => "José de Alencar", "original_title" => "Ubirajara" @@ -242,8 +449,8 @@ defmodule RichardBurtonWeb.PublicationControllerTest do "publication" => %{ "title" => "", "year" => "AAAA", - "country" => "GB", - "publisher" => "Bickers & Son", + "countries" => "GB", + "publishers" => "Bickers & Son", "authors" => "", "original_authors" => "José de Alencar", "original_title" => "Iracema" @@ -258,16 +465,16 @@ defmodule RichardBurtonWeb.PublicationControllerTest do "publication" => %{ "title" => "Ubirajara: A Legend of the Tupy Indians", "year" => "", - "country" => "", - "publisher" => "", + "countries" => "", + "publishers" => "", "authors" => "J. T. W. Sadler", "original_authors" => "", "original_title" => "" }, "errors" => %{ "year" => "required", - "country" => "required", - "publisher" => "required", + "countries" => "required", + "publishers" => "required", "original_authors" => "required", "original_title" => "required" } @@ -312,7 +519,7 @@ defmodule RichardBurtonWeb.PublicationControllerTest do conn = get(meta.conn, publication_path(meta.conn, :export)) expected_data = - "authors;country;original_authors;original_title;publisher;title;year\nIsabel Burton;GB;José de Alencar;Iracema;Bickers & Son;Iraçéma the Honey-Lips: A Legend of Brazil;1886\n" + "authors;countries;original_authors;original_title;publishers;title;year\nIsabel Burton;GB;José de Alencar;Iracema;Bickers & Son;Iraçéma the Honey-Lips: A Legend of Brazil;1886\n" expected_filename = "publications.csv" expected_content_disposition = ["attachment; filename=\"#{expected_filename}\""] @@ -342,7 +549,7 @@ defmodule RichardBurtonWeb.PublicationControllerTest do conn = get(meta.conn, path) expected_data = - "authors;country;original_authors;original_title;publisher;title;year\nIsabel Burton;GB;José de Alencar;Iracema;Bickers & Son;Iraçéma the Honey-Lips: A Legend of Brazil;1886\n" + "authors;countries;original_authors;original_title;publishers;title;year\nIsabel Burton;GB;José de Alencar;Iracema;Bickers & Son;Iraçéma the Honey-Lips: A Legend of Brazil;1886\n" expected_filename = "publications-#{search}.csv" expected_content_disposition = ["attachment; filename=\"#{expected_filename}\""] diff --git a/backend/test/richard_burton_web/controllers/publisher_controller_test.exs b/backend/test/richard_burton_web/controllers/publisher_controller_test.exs new file mode 100644 index 00000000..556aa520 --- /dev/null +++ b/backend/test/richard_burton_web/controllers/publisher_controller_test.exs @@ -0,0 +1,45 @@ +defmodule RichardBurtonWeb.PublisherControllerTest do + @moduledoc """ + Tests for the Publisher controller + """ + use RichardBurtonWeb.ConnCase + import Routes, only: [publisher_path: 2] + + alias RichardBurton.Publisher + alias RichardBurton.Repo + + @publisher_names [ + "Random House", + "Bantam Books", + "Dutton", + "UMass Dartmouth", + "University Press of Kentucky" + ] + + def search_fixture(_) do + @publisher_names + |> Enum.map(&%{"name" => &1}) + |> Enum.map(&Publisher.changeset(%Publisher{}, &1)) + |> Enum.each(&Repo.insert!/1) + + [] + end + + describe "GET /publishers" do + setup [:search_fixture] + + test "returns 200 and all the publishers' names when no search param is provided", + %{conn: conn} do + expect_auth_authorize_admin() + conn = get(conn, publisher_path(conn, :index)) + assert @publisher_names == json_response(conn, 200) + end + + test "returns 200 and the relevant publishers' names a search param is provided", + %{conn: conn} do + expect_auth_authorize_admin() + conn = get(conn, publisher_path(conn, :index), %{"search" => "U"}) + assert ["UMass Dartmouth", "University Press of Kentucky"] == json_response(conn, 200) + end + end +end diff --git a/frontend/components/DataInput.tsx b/frontend/components/DataInput.tsx index f6931ae7..e9daced9 100644 --- a/frontend/components/DataInput.tsx +++ b/frontend/components/DataInput.tsx @@ -1,27 +1,29 @@ +import { + Publication, + PublicationId, + PublicationKey, + PublicationKeyType, +} from "modules/publication"; import { FC, FocusEvent, - forwardRef, HTMLProps, Ref, + forwardRef, useEffect, useState, } from "react"; -import TextDataInput from "./TextDataInput"; import TextArrayDataInput from "./TextArrayDataInput"; -import { - Publication, - PublicationId, - PublicationKey, - PublicationKeyType, -} from "modules/publication"; -import Tooltip from "./Tooltip"; +import TextDataInput from "./TextDataInput"; +import TextEnumArrayDataInput from "./TextEnumArrayDataInput"; import TextEnumDataInput from "./TextEnumDataInput"; import TextNumberDataInput from "./TextNumberDataInput"; +import Tooltip from "./Tooltip"; const COMPONENTS_PER_TYPE: Record> = { text: TextDataInput, enum: TextEnumDataInput, + enumArray: TextEnumArrayDataInput, number: TextNumberDataInput, array: TextArrayDataInput, }; diff --git a/frontend/components/Multicombobox.tsx b/frontend/components/Multicombobox.tsx index eba7c243..e74b45ca 100644 --- a/frontend/components/Multicombobox.tsx +++ b/frontend/components/Multicombobox.tsx @@ -1,26 +1,43 @@ -import { forwardRef, HTMLProps, KeyboardEvent, useRef, useState } from "react"; +import { ForwardedRef, KeyboardEvent, useRef, useState } from "react"; import Pill from "./Pill"; -import MenuProvider from "./MenuProvider"; import { Key } from "app"; +import { isString } from "lodash"; +import { z } from "zod"; +import MenuProvider from "./MenuProvider"; import TextInput from "./TextInput"; -type Item = string; +type Item = { id: string; label: string }; -type Props = Omit, "value" | "onChange"> & { - value: Item[]; +type Props = { + placeholder?: string; + value: ItemType[]; error?: string; - onChange: (value: Item[]) => void; - getOptions: (search: string) => Promise | Item[]; + onKeyDown?: (event: KeyboardEvent) => void; + onChange: (value: ItemType[]) => void; + getOptions: (search: string) => Promise | ItemType[]; + forwardedRef?: ForwardedRef; }; -export default forwardRef(function Multicombobox( - { value, placeholder, getOptions, onChange, onKeyDown, error, ...props }, - ref, -) { +function isStringArray(value: unknown): value is string[] { + return z.string().array().safeParse(value).success; +} + +export default function Multicombobox({ + value, + placeholder, + getOptions, + onChange, + onKeyDown, + error, + forwardedRef, + ...props +}: Props) { const [inputValue, setInputValue] = useState(""); const [activeIndex, setActiveIndex] = useState(null); - const [options, setOptions] = useState([]); + const [options, setOptions] = useState([]); + + const isEnum = isStringArray(value) && isStringArray(options); const [isOpen, setIsOpen] = useState(false); @@ -28,7 +45,7 @@ export default forwardRef(function Multicombobox( onChange(value.filter((_, i) => i !== index)); } - function select(item: Item) { + function select(item: ItemType) { if (!value.includes(item)) onChange([...value, item]); } @@ -38,10 +55,14 @@ export default forwardRef(function Multicombobox( if (event.key === Key.COMMA) { event.preventDefault(); + if (isEnum) { + setOptions([]); + } + if (!isInputValueBlank) { setInputValue(""); setOptions([]); - select(inputValue); + select(inputValue as ItemType); } } @@ -56,7 +77,7 @@ export default forwardRef(function Multicombobox( if (activeIndex != null && options[activeIndex]) { select(options[activeIndex]); } else { - select(inputValue); + select(inputValue as ItemType); } } @@ -81,7 +102,7 @@ export default forwardRef(function Multicombobox( } } - function handleOptionSelect(option: string) { + function handleOptionSelect(option: ItemType) { select(option); setInputValue(""); inputRef.current?.focus(); @@ -90,7 +111,7 @@ export default forwardRef(function Multicombobox( const inputRef = useRef(null); return ( - options={options} isOpen={isOpen} activeIndex={activeIndex} @@ -100,7 +121,7 @@ export default forwardRef(function Multicombobox( > (function Multicombobox( {value.map((item, index) => ( unselect(index)} /> ))} @@ -123,4 +144,4 @@ export default forwardRef(function Multicombobox( /> ); -}); +} diff --git a/frontend/components/PublicationIndexList.tsx b/frontend/components/PublicationIndexList.tsx index 764975ab..1f17d995 100644 --- a/frontend/components/PublicationIndexList.tsx +++ b/frontend/components/PublicationIndexList.tsx @@ -22,13 +22,15 @@ const PublicationItem: FC<{ id: number }> = ({ id }) => { {publication.originalAuthors} , published by{" "} - {publication.publisher} + {publication.publishers}
{publication.year}
-
{publication.country}
+
+ {publication.countries} +
) diff --git a/frontend/components/PublicationModal.tsx b/frontend/components/PublicationModal.tsx index 3683d323..29935920 100644 --- a/frontend/components/PublicationModal.tsx +++ b/frontend/components/PublicationModal.tsx @@ -1,4 +1,4 @@ -import { COUNTRIES, Publication } from "modules/publication"; +import { Publication, PublicationKey } from "modules/publication"; import Link from "next/link"; import { FC } from "react"; import { z } from "zod"; @@ -27,6 +27,21 @@ const Searchable: FC<{ label: string; value?: string }> = ({ ); }; +const SearchableList: FC<{ items: { label: string; value?: string }[] }> = ({ + items, +}) => ( +
    + {items.map((item, index) => ( +
  • + {index != 0 && index === items.length - 1 && " and "} + + {index < items.length - 2 && ", "} + {index === items.length - 1 && " "} +
  • + ))} +
+); + const PublicationHeading: FC<{ publication: Publication }> = ({ publication, }) => ( @@ -45,20 +60,31 @@ const PublicationHeading: FC<{ publication: Publication }> = ({ ); const PublicationDescription: FC<{ publication: Publication }> = ({ - publication, -}) => ( -

- is a translation of{" "} - , by{" "} - . It was written by{" "} - and published in{" "} - {" "} - in {publication?.year} by . -

-); + publication: p, +}) => { + function getSearchableItems(p: Publication, key: PublicationKey) { + return p[key] + .split(",") + .map((id) => id.trim()) + .map((id) => ({ + id, + label: Publication.describeValue(id, key), + })); + } + + return ( +

+ is a translation of{" "} + , by{" "} + . It + was written by {" "} + and published in{" "} + + in {p.year} by{" "} + . +

+ ); +}; const PublicationModal: FC = () => { const { value, ...modal } = useURLQueryModal(PUBLICATION_MODAL_KEY); diff --git a/frontend/components/TextArrayDataInput.tsx b/frontend/components/TextArrayDataInput.tsx index 7c3d28e5..4a542669 100644 --- a/frontend/components/TextArrayDataInput.tsx +++ b/frontend/components/TextArrayDataInput.tsx @@ -1,11 +1,15 @@ +import { Publication } from "modules/publication"; +import pDebounce from "p-debounce"; import { FC, forwardRef, useCallback, useMemo } from "react"; import { DataInputProps } from "./DataInput"; -import { Publication } from "modules/publication"; import Multicombobox from "./Multicombobox"; -import pDebounce from "p-debounce"; export default forwardRef( - function TextArrayDataInput({ colId, value, onChange, ...props }, ref) { + function TextArrayDataInput( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + { colId, value, onChange, ...props }, + ref, + ) { const items = useMemo( () => (value === "" ? [] : value.split(",")), [value], @@ -25,9 +29,9 @@ export default forwardRef( ); return ( - {...props} - ref={ref} + forwardedRef={ref} value={items} onChange={handleChange} getOptions={getOptions} diff --git a/frontend/components/TextEnumArrayDataInput.tsx b/frontend/components/TextEnumArrayDataInput.tsx new file mode 100644 index 00000000..929f7e4c --- /dev/null +++ b/frontend/components/TextEnumArrayDataInput.tsx @@ -0,0 +1,52 @@ +import { Publication } from "modules/publication"; +import pDebounce from "p-debounce"; +import { FC, forwardRef, useCallback, useMemo } from "react"; +import { DataInputProps } from "./DataInput"; +import Multicombobox from "./Multicombobox"; + +type Enum = { id: string; label: string }; + +export default forwardRef( + function TextEnumArrayDataInput( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + { colId, value, onChange, ...props }, + ref, + ) { + const toEnum = useCallback( + (id: string): Enum => { + return { id, label: Publication.describeValue(id, colId) }; + }, + [colId], + ); + + const items = useMemo( + () => (value === "" ? [] : value.split(",").map(toEnum)), + [value, toEnum], + ); + + console.log({ value, items }); + + function handleChange(value: Enum[]) { + onChange?.(value.map(({ id }) => id).join(",")); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + const getOptions = useCallback( + pDebounce( + (search: string) => Publication.autocomplete(search, colId), + 350, + ), + [colId], + ); + + return ( + + {...props} + forwardedRef={ref} + value={items} + onChange={handleChange} + getOptions={getOptions} + /> + ); + }, +) as FC; diff --git a/frontend/modules/publication.ts b/frontend/modules/publication.ts index 46b34259..9904cc08 100644 --- a/frontend/modules/publication.ts +++ b/frontend/modules/publication.ts @@ -21,12 +21,13 @@ import { import useDebounce from "utils/useDebounce"; import { Author } from "./author"; import { COUNTRIES, Country } from "./country"; +import { Publisher } from "./publisher"; type Publication = { title: string; - country: string; + countries: string; year: string; - publisher: string; + publishers: string; authors: string; originalTitle: string; originalAuthors: string; @@ -181,9 +182,9 @@ const TOTAL_PUBLICATION_COUNT = selector({ const DEFAULT_ATTRIBUTE_VISIBILITY: Record = { title: true, - country: true, + countries: true, year: true, - publisher: true, + publishers: true, authors: true, originalTitle: true, originalAuthors: true, @@ -273,7 +274,7 @@ const ERROR_MESSAGES: Record = { alpha2: "This field should be a valid ISO 3166-1 alpha 2 country code", }; -type PublicationKeyType = "array" | "text" | "enum" | "number"; +type PublicationKeyType = "array" | "text" | "enum" | "enumArray" | "number"; interface PublicationModule { ATTRIBUTES: PublicationKey[]; @@ -364,9 +365,10 @@ interface PublicationModule { useValidate(): (ids: PublicationId[]) => Promise; }; - autocomplete(value: string, attribute: "country"): Promise; + autocomplete(value: string, attribute: "countries"): Promise; autocomplete(value: string, attribute: "originalAuthors"): Promise; autocomplete(value: string, attribute: "authors"): Promise; + autocomplete(value: string, attribute: "publishers"): Promise<[]>; autocomplete(value: string, attribute: string): Promise<[]>; define(attribute: PublicationKey): Record; @@ -383,15 +385,15 @@ const Publication: PublicationModule = { "title", "authors", "year", - "country", - "publisher", + "countries", + "publishers", ], ATTRIBUTE_LABELS: { authors: "Translators", originalAuthors: "Original Authors", originalTitle: "Original Title", - country: "Country", - publisher: "Publisher", + countries: "Countries", + publishers: "Publishers", title: "Title", year: "Year", }, @@ -400,8 +402,8 @@ const Publication: PublicationModule = { authors: "array", originalAuthors: "array", originalTitle: "text", - country: "enum", - publisher: "text", + countries: "enumArray", + publishers: "array", title: "text", year: "number", }, @@ -410,8 +412,8 @@ const Publication: PublicationModule = { authors: true, originalAuthors: true, originalTitle: false, - country: true, - publisher: true, + countries: true, + publishers: true, title: false, year: true, }, @@ -833,13 +835,16 @@ const Publication: PublicationModule = { }, }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any autocomplete(value, attribute): Promise { switch (attribute) { case "authors": case "originalAuthors": return Author.REMOTE.search(value); + case "publishers": + return Publisher.REMOTE.search(value); - case "country": { + case "countries": { const all = Object.values(COUNTRIES); const countries = value @@ -863,10 +868,13 @@ const Publication: PublicationModule = { }, describeValue(value, attribute) { - if (attribute === "country") { - const country = COUNTRIES[value]; - if (country) { - return COUNTRIES[value].label; + if (attribute === "countries") { + const countries = COUNTRIES[value]; + if (countries) { + return value + .split(",") + .map((v) => COUNTRIES[v.trim()].label) + .join(", "); } else { console.warn("Unknown country code: ", value); return value; @@ -896,10 +904,10 @@ const Publication: PublicationModule = { empty() { return { authors: "", - country: "", + countries: "", originalAuthors: "", originalTitle: "", - publisher: "", + publishers: "", title: "", year: "", }; diff --git a/frontend/modules/publisher.ts b/frontend/modules/publisher.ts new file mode 100644 index 00000000..2435f124 --- /dev/null +++ b/frontend/modules/publisher.ts @@ -0,0 +1,25 @@ +import { request } from "app"; + +type Publisher = string; + +interface PublisherModule { + REMOTE: { + search(term: string): Promise; + }; +} + +const Publisher: PublisherModule = { + REMOTE: { + search(term) { + return request(async (http) => { + const { data } = await http.get("/publishers", { + params: { search: term }, + }); + + return data; + }); + }, + }, +}; + +export { Publisher };