feat: first database migration

This commit is contained in:
2026-04-23 12:14:54 +02:00
parent 5c1399a061
commit 28141deb8b
2 changed files with 728 additions and 0 deletions

View File

@@ -0,0 +1,381 @@
defmodule BDS.Repo.SchemaMigrationTest do
use ExUnit.Case, async: false
alias Ecto.Adapters.SQL
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
end
test "creates the persisted tables and columns declared by the schema spec" do
expected_columns = %{
"projects" => [
"id",
"name",
"slug",
"description",
"data_path",
"created_at",
"updated_at",
"is_active"
],
"posts" => [
"project_id",
"id",
"title",
"slug",
"excerpt",
"content",
"status",
"author",
"created_at",
"updated_at",
"published_at",
"file_path",
"checksum",
"tags",
"categories",
"template_slug",
"language",
"do_not_translate",
"published_title",
"published_content",
"published_tags",
"published_categories",
"published_excerpt"
],
"post_translations" => [
"id",
"project_id",
"translation_for",
"language",
"title",
"excerpt",
"content",
"status",
"created_at",
"updated_at",
"published_at",
"file_path",
"checksum"
],
"media" => [
"project_id",
"id",
"filename",
"original_name",
"mime_type",
"size",
"width",
"height",
"title",
"alt",
"caption",
"author",
"file_path",
"sidecar_path",
"created_at",
"updated_at",
"checksum",
"tags",
"language"
],
"media_translations" => [
"id",
"project_id",
"translation_for",
"language",
"title",
"alt",
"caption",
"created_at",
"updated_at"
],
"tags" => [
"id",
"project_id",
"name",
"color",
"post_template_slug",
"created_at",
"updated_at"
],
"templates" => [
"id",
"project_id",
"slug",
"title",
"kind",
"enabled",
"version",
"file_path",
"status",
"content",
"created_at",
"updated_at"
],
"scripts" => [
"id",
"project_id",
"slug",
"title",
"kind",
"entrypoint",
"enabled",
"version",
"file_path",
"status",
"content",
"created_at",
"updated_at"
],
"post_links" => ["id", "source_post_id", "target_post_id", "link_text", "created_at"],
"post_media" => ["id", "project_id", "post_id", "media_id", "sort_order", "created_at"],
"settings" => ["key", "value", "updated_at"],
"generated_file_hashes" => ["project_id", "relative_path", "content_hash", "updated_at"],
"chat_conversations" => [
"id",
"title",
"model",
"copilot_session_id",
"created_at",
"updated_at"
],
"chat_messages" => [
"id",
"conversation_id",
"role",
"content",
"tool_call_id",
"tool_calls",
"created_at"
],
"ai_providers" => ["id", "name", "env", "package_ref", "api", "doc", "updated_at"],
"ai_models" => [
"provider",
"model_id",
"name",
"family",
"attachment",
"reasoning",
"tool_call",
"structured_output",
"temperature",
"knowledge",
"release_date",
"last_updated_date",
"open_weights",
"input_price",
"output_price",
"cache_read_price",
"cache_write_price",
"context_window",
"max_input_tokens",
"max_output_tokens",
"interleaved",
"status",
"provider_package_ref",
"updated_at"
],
"ai_model_modalities" => ["provider", "model_id", "direction", "modality"],
"ai_catalog_meta" => ["key", "value"],
"embedding_keys" => ["label", "post_id", "project_id", "content_hash", "vector"],
"dismissed_duplicate_pairs" => ["id", "project_id", "post_id_a", "post_id_b", "dismissed_at"],
"import_definitions" => [
"id",
"project_id",
"name",
"wxr_file_path",
"uploads_folder_path",
"last_analysis_result",
"created_at",
"updated_at"
],
"db_notifications" => [
"id",
"entity_type",
"entity_id",
"action",
"from_cli",
"seen_at",
"created_at"
]
}
actual_tables =
query_rows("SELECT name FROM sqlite_master WHERE type = 'table'")
|> Enum.map(&hd/1)
|> MapSet.new()
for {table, columns} <- expected_columns do
assert MapSet.member?(actual_tables, table), "expected table #{table} to exist"
assert MapSet.new(table_columns(table)) == MapSet.new(columns)
end
end
test "creates the unique indexes and primary keys declared by the schema spec" do
assert has_unique_index?("projects", ["slug"])
assert unique_index_columns("posts", "posts_project_slug_idx") == ["project_id", "slug"]
assert unique_index_columns(
"post_translations",
"post_translations_translation_language_idx"
) == ["translation_for", "language"]
assert unique_index_columns(
"media_translations",
"media_translations_translation_language_idx"
) == ["translation_for", "language"]
assert unique_index_columns("tags", "tags_project_name_idx") == ["project_id", "name"]
assert unique_index_columns("scripts", "scripts_project_slug_idx") == ["project_id", "slug"]
assert unique_index_columns("templates", "templates_project_slug_idx") == ["project_id", "slug"]
assert unique_index_columns("post_media", "post_media_post_media_idx") == ["post_id", "media_id"]
assert unique_index_columns(
"generated_file_hashes",
"generated_file_hashes_project_path_idx"
) == ["project_id", "relative_path"]
assert unique_index_columns("dismissed_duplicate_pairs", "dismissed_pairs_idx") == [
"project_id",
"post_id_a",
"post_id_b"
]
assert primary_key_columns("ai_models") == ["provider", "model_id"]
assert primary_key_columns("ai_model_modalities") == [
"provider",
"model_id",
"direction",
"modality"
]
end
test "creates standalone FTS5 virtual tables for post and media search" do
posts_fts_sql = table_sql("posts_fts")
media_fts_sql = table_sql("media_fts")
assert posts_fts_sql =~ "CREATE VIRTUAL TABLE posts_fts USING fts5"
assert posts_fts_sql =~ "post_id UNINDEXED"
assert posts_fts_sql =~ "title"
assert posts_fts_sql =~ "excerpt"
assert posts_fts_sql =~ "content"
assert posts_fts_sql =~ "tags"
assert posts_fts_sql =~ "categories"
refute posts_fts_sql =~ "content="
assert media_fts_sql =~ "CREATE VIRTUAL TABLE media_fts USING fts5"
assert media_fts_sql =~ "media_id UNINDEXED"
assert media_fts_sql =~ "title"
assert media_fts_sql =~ "alt"
assert media_fts_sql =~ "caption"
assert media_fts_sql =~ "original_name"
assert media_fts_sql =~ "tags"
refute media_fts_sql =~ "content="
end
test "applies key defaults and foreign keys from the persistence contract" do
assert column_metadata("projects", "is_active")[:default] == "false"
assert column_metadata("posts", "status")[:default] == "draft"
assert column_metadata("posts", "file_path")[:default] == ""
assert column_metadata("posts", "do_not_translate")[:default] == "false"
assert column_metadata("post_media", "sort_order")[:default] == "0"
assert column_metadata("db_notifications", "from_cli")[:default] == "true"
assert foreign_keys("posts") == [%{from: "project_id", table: "projects", to: "id"}]
assert Enum.sort_by(foreign_keys("post_media"), & &1.from) == [
%{from: "media_id", table: "media", to: "id"},
%{from: "post_id", table: "posts", to: "id"},
%{from: "project_id", table: "projects", to: "id"}
]
assert Enum.sort_by(foreign_keys("post_translations"), & &1.from) == [
%{from: "project_id", table: "projects", to: "id"},
%{from: "translation_for", table: "posts", to: "id"}
]
assert Enum.sort_by(foreign_keys("media_translations"), & &1.from) == [
%{from: "project_id", table: "projects", to: "id"},
%{from: "translation_for", table: "media", to: "id"}
]
assert foreign_keys("chat_messages") == [
%{from: "conversation_id", table: "chat_conversations", to: "id"}
]
assert foreign_keys("ai_models") == [%{from: "provider", table: "ai_providers", to: "id"}]
end
defp table_columns(table) do
query_rows("PRAGMA table_info(#{table})")
|> Enum.map(fn [_cid, name, _type, _notnull, _default, _pk] -> name end)
end
defp column_metadata(table, column_name) do
query_rows("PRAGMA table_info(#{table})")
|> Enum.find_value(fn [_cid, name, type, notnull, default, pk] ->
if name == column_name do
%{
type: type,
notnull: notnull,
default: normalize_default(default),
pk: pk
}
end
end)
end
defp foreign_keys(table) do
query_rows("PRAGMA foreign_key_list(#{table})")
|> Enum.map(fn [_id, _seq, foreign_table, from, to, _on_update, _on_delete, _match] ->
%{from: from, table: foreign_table, to: to}
end)
end
defp primary_key_columns(table) do
query_rows("PRAGMA table_info(#{table})")
|> Enum.filter(fn [_cid, _name, _type, _notnull, _default, pk] -> pk > 0 end)
|> Enum.sort_by(fn [_cid, _name, _type, _notnull, _default, pk] -> pk end)
|> Enum.map(fn [_cid, name, _type, _notnull, _default, _pk] -> name end)
end
defp unique_index_columns(table, index_name) do
indexes = query_rows("PRAGMA index_list(#{table})")
assert Enum.any?(indexes, fn [_seq, name, unique | _rest] -> name == index_name and unique == 1 end),
"expected unique index #{index_name} on #{table}"
query_rows("PRAGMA index_info(#{index_name})")
|> Enum.sort_by(fn [seqno | _rest] -> seqno end)
|> Enum.map(fn [_seqno, _cid, name] -> name end)
end
defp has_unique_index?(table, columns) do
query_rows("PRAGMA index_list(#{table})")
|> Enum.filter(fn [_seq, _name, unique | _rest] -> unique == 1 end)
|> Enum.any?(fn [_seq, name, _unique | _rest] ->
unique_index_columns(table, name) == columns
end)
end
defp table_sql(table) do
[[sql]] = query_rows("SELECT sql FROM sqlite_master WHERE name = '#{table}'")
sql
end
defp query_rows(statement) do
%{rows: rows} = SQL.query!(BDS.Repo, statement, [])
rows
end
defp normalize_default(nil), do: nil
defp normalize_default(default) do
default
|> String.trim_leading("'")
|> String.trim_trailing("'")
end
end