Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ae6da9a..0d42059 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -18,9 +18,10 @@ add_executable(
card_collection
src/app.cpp
src/data.cpp
- src/data_fake.cpp
+ src/data_sqlite.cpp
src/main.cpp
src/public_id.cpp
+ src/startup.cpp
src/url_builder.cpp
)
@@ -138,4 +139,29 @@ if(CARD_COLLECTION_BUILD_TESTS)
mw::mw
)
gtest_discover_tests(data_fake_test)
+
+ add_executable(
+ data_sqlite_test
+ src/data.cpp
+ src/data_sqlite.cpp
+ src/startup.cpp
+ tests/data_sqlite_test.cpp
+ )
+ target_compile_features(data_sqlite_test PRIVATE cxx_std_23)
+ set_target_properties(data_sqlite_test PROPERTIES CXX_EXTENSIONS OFF)
+ target_include_directories(
+ data_sqlite_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ data_sqlite_test
+ PRIVATE
+ GTest::gtest_main
+ mw::mw
+ mw::sqlite
+ spdlog::spdlog
+ )
+ gtest_discover_tests(data_sqlite_test)
endif()
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
new file mode 100644
index 0000000..1120bb7
--- /dev/null
+++ b/src/data_sqlite.cpp
@@ -0,0 +1,323 @@
+#include "data_sqlite.h"
+
+#include <array>
+#include <cstdint>
+#include <limits>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <tuple>
+#include <utility>
+#include <vector>
+
+#include <spdlog/spdlog.h>
+
+namespace
+{
+
+mw::Error notImplemented(std::string_view operation)
+{
+ return mw::runtimeError(
+ "DataSourceSQLite has not implemented " +
+ std::string(operation) + ".");
+}
+
+mw::E<void> rollbackWithError(
+ mw::SQLite& connection,
+ mw::Error error)
+{
+ auto rollback = connection.execute("ROLLBACK;");
+ if(!rollback)
+ {
+ spdlog::error(
+ "Failed to roll back schema migration: {}",
+ rollback.error().msg());
+ }
+ return std::unexpected(std::move(error));
+}
+
+const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
+ R"sql(
+ CREATE TABLE cards (
+ id INTEGER PRIMARY KEY,
+ game_short_name TEXT,
+ card_number INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ short_description TEXT,
+ long_description TEXT,
+ rarity INTEGER NOT NULL DEFAULT 0 CHECK(rarity >= 0),
+ front_extension TEXT NOT NULL
+ CHECK(front_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
+ foil_extension TEXT
+ CHECK(foil_extension IN ('webp', 'avif')),
+ thumbnail_extension TEXT NOT NULL
+ CHECK(thumbnail_extension IN
+ ('jpg', 'jpeg', 'webp', 'avif')),
+ revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
+ CHECK(
+ (game_short_name IS NULL
+ AND card_number >= 0
+ AND card_number <= 4294967295)
+ OR
+ (game_short_name IS NOT NULL AND card_number >= 1)
+ )
+ );
+ )sql",
+ R"sql(
+ CREATE UNIQUE INDEX cards_game_number_unique
+ ON cards(game_short_name, card_number)
+ WHERE game_short_name IS NOT NULL;
+ )sql",
+ R"sql(
+ CREATE UNIQUE INDEX cards_loose_number_unique
+ ON cards(card_number)
+ WHERE game_short_name IS NULL;
+ )sql",
+ R"sql(
+ CREATE TABLE game_sequences (
+ game_short_name TEXT PRIMARY KEY,
+ last_number INTEGER NOT NULL CHECK(last_number >= 0)
+ );
+ )sql",
+ R"sql(
+ CREATE TABLE series (
+ id INTEGER PRIMARY KEY,
+ game_short_name TEXT NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ UNIQUE(game_short_name, name)
+ );
+ )sql",
+ R"sql(
+ CREATE TABLE card_series (
+ card_id INTEGER NOT NULL
+ REFERENCES cards(id) ON DELETE CASCADE,
+ series_id INTEGER NOT NULL
+ REFERENCES series(id) ON DELETE CASCADE,
+ PRIMARY KEY(card_id, series_id)
+ );
+ )sql",
+ R"sql(
+ CREATE TRIGGER card_series_same_game_insert
+ BEFORE INSERT ON card_series
+ BEGIN
+ SELECT CASE
+ WHEN (SELECT game_short_name
+ FROM cards
+ WHERE id = NEW.card_id) IS NULL
+ THEN RAISE(ABORT, 'loose card cannot belong to a series')
+ WHEN (SELECT game_short_name
+ FROM cards
+ WHERE id = NEW.card_id)
+ !=
+ (SELECT game_short_name
+ FROM series
+ WHERE id = NEW.series_id)
+ THEN RAISE(ABORT, 'card and series games differ')
+ END;
+ END;
+ )sql",
+};
+
+} // namespace
+
+DataSourceSQLite::DataSourceSQLite(
+ std::unique_ptr<mw::SQLite> connection)
+ : connection_(std::move(connection))
+{}
+
+mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::fromFile(
+ const std::filesystem::path& database_path)
+{
+ auto connection = mw::SQLite::connectFile(database_path.string());
+ if(!connection)
+ {
+ return std::unexpected(std::move(connection.error()));
+ }
+ auto synchronous_result =
+ (*connection)->execute("PRAGMA synchronous = NORMAL;");
+ if(!synchronous_result)
+ {
+ return std::unexpected(std::move(synchronous_result.error()));
+ }
+ return std::unique_ptr<DataSourceSQLite>(
+ new DataSourceSQLite(std::move(*connection)));
+}
+
+mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
+{
+ std::lock_guard lock(mutex_);
+ return connection_->evalToValue<std::int64_t>("PRAGMA user_version;");
+}
+
+mw::E<void> DataSourceSQLite::migrateSchema0To1(
+ [[maybe_unused]] const GameRegistry& games)
+{
+ std::lock_guard lock(mutex_);
+ auto version = connection_->evalToValue<std::int64_t>(
+ "PRAGMA user_version;");
+ if(!version)
+ {
+ return std::unexpected(std::move(version.error()));
+ }
+ if(*version != 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Schema version 0 to 1 migration requires version 0"));
+ }
+
+ auto begin = connection_->execute("BEGIN IMMEDIATE;");
+ if(!begin)
+ {
+ return std::unexpected(std::move(begin.error()));
+ }
+ for(std::string_view statement : SCHEMA_VERSION_1_STATEMENTS)
+ {
+ auto result = connection_->execute(std::string(statement));
+ if(!result)
+ {
+ return rollbackWithError(
+ *connection_, std::move(result.error()));
+ }
+ }
+
+ auto set_version = connection_->execute("PRAGMA user_version = 1;");
+ if(!set_version)
+ {
+ return rollbackWithError(
+ *connection_, std::move(set_version.error()));
+ }
+ auto commit = connection_->execute("COMMIT;");
+ if(!commit)
+ {
+ return rollbackWithError(*connection_, std::move(commit.error()));
+ }
+ return {};
+}
+
+mw::E<std::unique_ptr<DataSourceTransactionInterface>>
+DataSourceSQLite::beginTransaction()
+{
+ return std::unexpected(notImplemented("transactions"));
+}
+
+mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
+{
+ std::lock_guard lock(mutex_);
+ auto rows = connection_->eval<
+ std::int64_t,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::string,
+ std::int64_t>(
+ "SELECT id, game_short_name, card_number, name, "
+ "short_description, long_description, rarity, "
+ "front_extension, foil_extension, thumbnail_extension, "
+ "revision FROM cards ORDER BY id;");
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+
+ std::vector<Card> cards;
+ cards.reserve(rows->size());
+ for(auto& row : *rows)
+ {
+ auto& [
+ id,
+ game_short_name,
+ card_number,
+ name,
+ short_description,
+ long_description,
+ rarity,
+ front_extension,
+ foil_extension,
+ thumbnail_extension,
+ revision] = row;
+ const bool invalid_loose_number =
+ !game_short_name &&
+ card_number > std::numeric_limits<std::uint32_t>::max();
+ const bool invalid_game_number =
+ game_short_name &&
+ (game_short_name->empty() || card_number == 0);
+ if(card_number < 0 || invalid_loose_number || invalid_game_number ||
+ rarity < 0 || revision < 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid card record"));
+ }
+
+ cards.push_back({
+ id,
+ {std::move(game_short_name),
+ static_cast<std::uint64_t>(card_number)},
+ std::move(name),
+ std::move(short_description),
+ std::move(long_description),
+ rarity,
+ std::move(front_extension),
+ std::move(foil_extension),
+ std::move(thumbnail_extension),
+ revision,
+ });
+ }
+ return cards;
+}
+
+mw::E<std::optional<Card>> DataSourceSQLite::getCard(
+ [[maybe_unused]] const CardIdentity& identity) const
+{
+ return std::unexpected(notImplemented("card reads"));
+}
+
+mw::E<std::vector<DisplayField>>
+DataSourceSQLite::getGameDisplayFields(
+ [[maybe_unused]] const GameDefinition& game,
+ [[maybe_unused]] std::int64_t card_id) const
+{
+ return std::unexpected(notImplemented("game display-field reads"));
+}
+
+mw::E<std::vector<Series>> DataSourceSQLite::getSeries() const
+{
+ return std::unexpected(notImplemented("series index reads"));
+}
+
+mw::E<std::optional<Series>> DataSourceSQLite::getSeries(
+ [[maybe_unused]] std::int64_t series_id) const
+{
+ return std::unexpected(notImplemented("series reads"));
+}
+
+mw::E<std::vector<std::int64_t>> DataSourceSQLite::getCardSeries(
+ [[maybe_unused]] std::int64_t card_id) const
+{
+ return std::unexpected(notImplemented("card-series reads"));
+}
+
+mw::E<std::vector<std::string>>
+DataSourceSQLite::getPersistedGameNames() const
+{
+ return std::unexpected(notImplemented("persisted-game reads"));
+}
+
+mw::E<void> DataSourceSQLite::setSchemaVersion(
+ std::int64_t version)
+{
+ if(version < 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database schema version cannot be negative"));
+ }
+ std::lock_guard lock(mutex_);
+ return connection_->execute(
+ "PRAGMA user_version = " + std::to_string(version) + ";");
+}
diff --git a/src/data_sqlite.h b/src/data_sqlite.h
new file mode 100644
index 0000000..9b97850
--- /dev/null
+++ b/src/data_sqlite.h
@@ -0,0 +1,75 @@
+#pragma once
+
+#include <filesystem>
+#include <memory>
+#include <mutex>
+
+#include <mw/database.hpp>
+
+#include "data.h"
+
+/// SQLite-backed implementation of the application persistence boundary.
+class DataSourceSQLite final : public DataSourceInterface
+{
+public:
+ /// Destroy the data source after all transactions have ended.
+ ~DataSourceSQLite() override = default;
+
+ /// Prevent copying a data source and its single owned connection.
+ DataSourceSQLite(const DataSourceSQLite&) = delete;
+
+ /// Prevent copy assignment of a data source connection.
+ DataSourceSQLite& operator=(const DataSourceSQLite&) = delete;
+
+ /// Open a SQLite database file without applying migrations.
+ static mw::E<std::unique_ptr<DataSourceSQLite>>
+ fromFile(const std::filesystem::path& database_path);
+
+ /// Return the stored schema version.
+ mw::E<std::int64_t> getSchemaVersion() const override;
+
+ /// Create schema version 1 from an empty version-0 database.
+ mw::E<void>
+ migrateSchema0To1(const GameRegistry& games) override;
+
+ /// Start an immediate transaction with exclusive mutation ownership.
+ mw::E<std::unique_ptr<DataSourceTransactionInterface>>
+ beginTransaction() override;
+
+ /// Return all cards for the unpaginated index.
+ mw::E<std::vector<Card>> getCards() const override;
+
+ /// Return a card by its parsed identity.
+ mw::E<std::optional<Card>>
+ getCard(const CardIdentity& identity) const override;
+
+ /// Return a card's game-owned display fields.
+ mw::E<std::vector<DisplayField>> getGameDisplayFields(
+ const GameDefinition& game,
+ std::int64_t card_id) const override;
+
+ /// Return all series, ordered by game and name.
+ mw::E<std::vector<Series>> getSeries() const override;
+
+ /// Return one series by internal ID.
+ mw::E<std::optional<Series>>
+ getSeries(std::int64_t series_id) const override;
+
+ /// Return the series memberships for one card.
+ mw::E<std::vector<std::int64_t>>
+ getCardSeries(std::int64_t card_id) const override;
+
+ /// Return every persisted game name used for startup reconciliation.
+ mw::E<std::vector<std::string>>
+ getPersistedGameNames() const override;
+
+protected:
+ /// Set the schema version inside a concrete migration transaction.
+ mw::E<void> setSchemaVersion(std::int64_t version) override;
+
+private:
+ explicit DataSourceSQLite(std::unique_ptr<mw::SQLite> connection);
+
+ std::unique_ptr<mw::SQLite> connection_;
+ mutable std::mutex mutex_;
+};
diff --git a/src/game_registry.h b/src/game_registry.h
new file mode 100644
index 0000000..5dd744b
--- /dev/null
+++ b/src/game_registry.h
@@ -0,0 +1,12 @@
+#pragma once
+
+/// Immutable collection of compiled game definitions.
+///
+/// Production currently registers no games. Lookup and schema-extension
+/// behavior will be added with the first compiled game definition.
+class GameRegistry
+{
+public:
+ /// Construct the empty production registry.
+ GameRegistry() = default;
+};
diff --git a/src/main.cpp b/src/main.cpp
index 0fc1186..a145721 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,18 +1,15 @@
#include <chrono>
#include <csignal>
-#include <cstdint>
#include <filesystem>
-#include <memory>
-#include <optional>
#include <string>
#include <thread>
#include <utility>
-#include <vector>
#include <spdlog/spdlog.h>
#include "app.h"
-#include "data_fake.h"
+#include "game_registry.h"
+#include "startup.h"
namespace
{
@@ -28,38 +25,6 @@ void handleSignal([[maybe_unused]] int signal)
STOP_REQUESTED = 1;
}
-Card makeCard(
- std::int64_t id,
- std::uint32_t number,
- std::string name,
- std::int64_t rarity)
-{
- return {
- id,
- {std::nullopt, number},
- std::move(name),
- std::nullopt,
- std::nullopt,
- rarity,
- "avif",
- std::nullopt,
- "avif",
- 1,
- };
-}
-
-std::vector<Card> sampleCards()
-{
- return {
- makeCard(1, 2, "Frost Garden", 1),
- makeCard(2, 10, "Midnight Relay", 3),
- makeCard(3, 42, "Glass Comet", 2),
- makeCard(4, 128, "Signal Bloom", 0),
- makeCard(5, 1024, "Quiet Orbit", 4),
- makeCard(6, 65535, "Last Light", 5),
- };
-}
-
mw::E<Config> makeDevelopmentConfig()
{
const std::string base_url_text =
@@ -95,7 +60,7 @@ mw::E<Config> makeDevelopmentConfig()
} // namespace
-/// Start the Card Collection development server with deterministic fake data.
+/// Start the Card Collection development server with its SQLite database.
int main()
{
auto config = makeDevelopmentConfig();
@@ -107,9 +72,17 @@ int main()
return 1;
}
- App app(
- *config,
- std::make_unique<DataSourceFake>(sampleCards()));
+ GameRegistry games;
+ auto data_source = prepareDataSource(config->database_path, games);
+ if(!data_source)
+ {
+ spdlog::error(
+ "Failed to prepare the card database: {}",
+ data_source.error().msg());
+ return 1;
+ }
+
+ App app(*config, std::move(*data_source));
std::signal(SIGINT, handleSignal);
std::signal(SIGTERM, handleSignal);
diff --git a/src/startup.cpp b/src/startup.cpp
new file mode 100644
index 0000000..8646751
--- /dev/null
+++ b/src/startup.cpp
@@ -0,0 +1,22 @@
+#include "startup.h"
+
+#include <utility>
+
+#include "data_sqlite.h"
+
+mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
+ const std::filesystem::path& database_path,
+ const GameRegistry& games)
+{
+ auto data_source = DataSourceSQLite::fromFile(database_path);
+ if(!data_source)
+ {
+ return std::unexpected(std::move(data_source.error()));
+ }
+ auto migration_result = (*data_source)->migrateToLatest(games);
+ if(!migration_result)
+ {
+ return std::unexpected(std::move(migration_result.error()));
+ }
+ return std::move(*data_source);
+}
diff --git a/src/startup.h b/src/startup.h
new file mode 100644
index 0000000..d6d46cb
--- /dev/null
+++ b/src/startup.h
@@ -0,0 +1,13 @@
+#pragma once
+
+#include <filesystem>
+#include <memory>
+
+#include <mw/error.hpp>
+
+#include "data.h"
+
+/// Open and migrate the application data source before server startup.
+mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
+ const std::filesystem::path& database_path,
+ const GameRegistry& games);
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
new file mode 100644
index 0000000..4990556
--- /dev/null
+++ b/tests/data_sqlite_test.cpp
@@ -0,0 +1,168 @@
+#include "data_sqlite.h"
+#include "game_registry.h"
+#include "startup.h"
+
+#include <chrono>
+#include <filesystem>
+#include <memory>
+#include <string>
+
+#include <gtest/gtest.h>
+
+namespace
+{
+
+class TemporaryDatabase
+{
+public:
+ /// Allocate a unique temporary database path for one test.
+ TemporaryDatabase()
+ : path_(
+ std::filesystem::path(testing::TempDir()) /
+ ("card_collection_" + std::to_string(
+ std::chrono::steady_clock::now()
+ .time_since_epoch().count()) + ".sqlite3"))
+ {}
+
+ /// Remove the test database and its SQLite sidecar files.
+ ~TemporaryDatabase()
+ {
+ std::error_code error;
+ std::filesystem::remove(path_, error);
+ std::filesystem::remove(path_.string() + "-shm", error);
+ std::filesystem::remove(path_.string() + "-wal", error);
+ }
+
+ /// Return the allocated database path.
+ const std::filesystem::path& path() const
+ {
+ return path_;
+ }
+
+private:
+ std::filesystem::path path_;
+};
+
+} // namespace
+
+/// Verify the SQLite factory owns an opened connection.
+TEST(DataSourceSQLiteTest, OpensDatabase)
+{
+ auto data_source = DataSourceSQLite::fromFile(":memory:");
+
+ ASSERT_TRUE(data_source);
+ EXPECT_NE(*data_source, nullptr);
+}
+
+/// Verify unfinished persistence operations report explicit errors.
+TEST(DataSourceSQLiteTest, ReportsPlaceholderOperations)
+{
+ auto data_source_result = DataSourceSQLite::fromFile(":memory:");
+ ASSERT_TRUE(data_source_result);
+ std::unique_ptr<DataSourceSQLite> data_source =
+ std::move(*data_source_result);
+
+ EXPECT_FALSE(data_source->beginTransaction());
+ EXPECT_FALSE(data_source->getCard({std::nullopt, 1}));
+ EXPECT_FALSE(data_source->getSeries());
+ EXPECT_FALSE(data_source->getSeries(1));
+ EXPECT_FALSE(data_source->getCardSeries(1));
+ EXPECT_FALSE(data_source->getPersistedGameNames());
+}
+
+/// Verify startup prepares a fresh database for its first card-index read.
+TEST(DataSourceSQLiteTest, PreparesFreshDatabaseForStartup)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+
+ auto version = (*data_source)->getSchemaVersion();
+ ASSERT_TRUE(version);
+ EXPECT_EQ(*version, DB_SCHEMA_VERSION);
+ auto cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ EXPECT_TRUE(cards->empty());
+}
+
+/// Verify card index reads preserve common and nullable card fields.
+TEST(DataSourceSQLiteTest, ReturnsCards)
+{
+ TemporaryDatabase database;
+ auto connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ ASSERT_TRUE((*connection)->execute(
+ "CREATE TABLE cards ("
+ "id INTEGER PRIMARY KEY, "
+ "game_short_name TEXT, "
+ "card_number INTEGER NOT NULL, "
+ "name TEXT NOT NULL, "
+ "short_description TEXT, "
+ "long_description TEXT, "
+ "rarity INTEGER NOT NULL, "
+ "front_extension TEXT NOT NULL, "
+ "foil_extension TEXT, "
+ "thumbnail_extension TEXT NOT NULL, "
+ "revision INTEGER NOT NULL);"));
+ ASSERT_TRUE((*connection)->execute(
+ "INSERT INTO cards VALUES (2, 'pkm', 7, 'Moon card', "
+ "'Short', 'Long', 4, 'avif', 'webp', 'avif', 3);"));
+ ASSERT_TRUE((*connection)->execute(
+ "INSERT INTO cards VALUES (1, NULL, 35, 'Loose card', "
+ "NULL, NULL, 0, 'jpg', NULL, 'webp', 1);"));
+ connection->reset();
+
+ auto data_source = DataSourceSQLite::fromFile(database.path());
+ ASSERT_TRUE(data_source);
+ auto cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ ASSERT_EQ(cards->size(), 2);
+
+ EXPECT_EQ((*cards)[0].id, 1);
+ EXPECT_EQ((*cards)[0].identity.game_short_name, std::nullopt);
+ EXPECT_EQ((*cards)[0].identity.card_number, 35);
+ EXPECT_EQ((*cards)[0].name, "Loose card");
+ EXPECT_EQ((*cards)[0].short_description, std::nullopt);
+ EXPECT_EQ((*cards)[0].long_description, std::nullopt);
+ EXPECT_EQ((*cards)[0].rarity, 0);
+ EXPECT_EQ((*cards)[0].front_extension, "jpg");
+ EXPECT_EQ((*cards)[0].foil_extension, std::nullopt);
+ EXPECT_EQ((*cards)[0].thumbnail_extension, "webp");
+ EXPECT_EQ((*cards)[0].revision, 1);
+
+ EXPECT_EQ((*cards)[1].id, 2);
+ EXPECT_EQ((*cards)[1].identity.game_short_name, "pkm");
+ EXPECT_EQ((*cards)[1].identity.card_number, 7);
+ EXPECT_EQ((*cards)[1].name, "Moon card");
+ EXPECT_EQ((*cards)[1].short_description, "Short");
+ EXPECT_EQ((*cards)[1].long_description, "Long");
+ EXPECT_EQ((*cards)[1].rarity, 4);
+ EXPECT_EQ((*cards)[1].front_extension, "avif");
+ EXPECT_EQ((*cards)[1].foil_extension, "webp");
+ EXPECT_EQ((*cards)[1].thumbnail_extension, "avif");
+ EXPECT_EQ((*cards)[1].revision, 3);
+}
+
+/// Verify invalid persisted card identities fail the complete read.
+TEST(DataSourceSQLiteTest, RejectsInvalidCards)
+{
+ TemporaryDatabase database;
+ auto connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ ASSERT_TRUE((*connection)->execute(
+ "CREATE TABLE cards ("
+ "id INTEGER, game_short_name TEXT, card_number INTEGER, "
+ "name TEXT, short_description TEXT, long_description TEXT, "
+ "rarity INTEGER, front_extension TEXT, foil_extension TEXT, "
+ "thumbnail_extension TEXT, revision INTEGER);"));
+ ASSERT_TRUE((*connection)->execute(
+ "INSERT INTO cards VALUES (1, NULL, -1, 'Broken', NULL, NULL, "
+ "0, 'jpg', NULL, 'jpg', 1);"));
+ connection->reset();
+
+ auto data_source = DataSourceSQLite::fromFile(database.path());
+ ASSERT_TRUE(data_source);
+ EXPECT_FALSE((*data_source)->getCards());
+}