diff --git a/CMakeLists.txt b/CMakeLists.txt index efecafc4..592cc53a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -492,9 +492,19 @@ function(load_third_party) message("Third Party Build Path: ${TP_BUILD_PATH}") ensure_third_party_checkout() + # EVEngine's database module uses POCO Data and its bundled SQLite connector. + # The third-party aggregate disables these by default to reduce its footprint; + # enable the two targets in the checked-out aggregate before configuring it. + set(_eve_tp_cmake "${CMAKE_CURRENT_SOURCE_DIR}/third-party/CMakeLists.txt") + file(READ "${_eve_tp_cmake}" _eve_tp_contents) + string(REPLACE "set(ENABLE_DATA OFF CACHE BOOL \"\" FORCE)" + "set(ENABLE_DATA ON CACHE BOOL \"\" FORCE)" _eve_tp_contents "${_eve_tp_contents}") + string(REPLACE "set(ENABLE_DATA_SQLITE OFF CACHE BOOL \"\" FORCE)" + "set(ENABLE_DATA_SQLITE ON CACHE BOOL \"\" FORCE)" _eve_tp_contents "${_eve_tp_contents}") + file(WRITE "${_eve_tp_cmake}" "${_eve_tp_contents}") check_third_party_project(third-party "https://github.com/EVEngine/third-party") ADD_CUSTOM_TARGET(deps DEPENDS third-party) endfunction() -main() \ No newline at end of file +main() diff --git a/docs/数据库模块设计.md b/docs/数据库模块设计.md new file mode 100644 index 00000000..a668c250 --- /dev/null +++ b/docs/数据库模块设计.md @@ -0,0 +1,152 @@ +# Database 数据库模块 + +`database` 模块使用 third-party 中的 **POCO Data**。当前内置并注册 SQLite Connector;`connect(connector, connectionString)` 保留 POCO Connector 名称,因此在链接相应 POCO Connector 后可使用 MySQL、PostgreSQL、ODBC 等后端。 + +## C++ API + +```cpp +auto *database = eve::database::Database::create(); +std::unique_ptr db(database->connectSQLite("save/game.db")); +db->execute("CREATE TABLE IF NOT EXISTS player (id INTEGER, name TEXT, hp REAL)"); +db->insert("player", {{"id", int64_t(1)}, {"name", "Ada"}, {"hp", 100.0}}); +auto rows = db->query("SELECT * FROM player"); +db->update("player", {{"hp", 80.0}}, "id=1"); +db->remove("player", "id=1"); +``` + +`Value` 支持 NULL、64 位整数、浮点、字符串及布尔值。表名和列名始终由模块引用;`whereClause` 与 `execute/query` 是原始 SQL,只应由可信代码构造。 + +### ORM + +`Mapping` 明确描述表名、SQL 类型、主键和字段读写函数,避免运行时反射。`Repository` 在映射之上提供建表以及类型安全的 insert/find/all/update/remove,才是模块的完整 ORM 层: + +```cpp +struct Player { int64_t id; std::string name; }; +eve::database::Mapping players{"player", { + {"id", [](const Player& p) -> Value { return p.id; }, + [](Player& p, const Value& v) { p.id = std::get(v); }, "INTEGER", true}, + {"name", [](const Player& p) -> Value { return p.name; }, + [](Player& p, const Value& v) { p.name = std::get(v); }} +}}; +eve::database::Repository repo(*db, players); +repo.createTable(); +repo.insert(Player{1, "Ada"}); +Player loaded; +repo.find(int64_t(1), loaded); +loaded.name = "Grace"; +repo.update(loaded); +auto all = repo.all(); +repo.remove(int64_t(1)); +``` + +### ECS 导出 + +`exportECS` 接收任意可迭代 ECS View,以及把 View 元素转换为 `Row` 的 mapper。所有行处于同一事务中;失败时自动回滚。这种适配方式不要求 ECS 组件具备反射,也能直接用于 `ecs::View`。 + +```cpp +auto view = ecs::View(); +db->exportECS("player", view, [](const auto& item) { + auto [stats] = item; + return Row{{"id", int64_t(stats->id)}, {"hp", double(stats->hp)}}; +}); +``` + +## Squirrel API + +模块入口为 `eve.Database()`,连接对象类型为 `DatabaseConnection`。脚本使用 JSON 对象传递动态列值,查询也返回 JSON,避免丢失 SQL 的动态列结构。 + +```squirrel +database <- eve.Database(); +db <- database.connectSQLite("save/game.db"); +db.execute("CREATE TABLE IF NOT EXISTS player (id INTEGER, name TEXT, hp REAL)"); +db.insertJson("player", @"{""id"":1,""name"":""Ada"",""hp"":100}"); +db.updateJson("player", @"{""hp"":80}", "id=1"); +local json = db.queryJson("SELECT * FROM player ORDER BY id"); +db.remove("player", "id=1"); +db.close(); +``` + +脚本也可创建以表名和主键为身份的运行时 ORM Model,之后不必在每次操作时重复传表名或拼接 where: + +```squirrel +players <- db.model("player", "id"); +players.insertJson(@"{""id"":2,""name"":""Lin"",""hp"":90}"); +local player = players.findJson("2"); // id 参数是 JSON 标量,也可传 @"""player-2""" +players.updateJson("2", @"{""hp"":95}"); +local allPlayers = players.allJson(); +players.remove("2"); +``` + +### 排序、过滤与分页 + +对于大量人物数据,不能把全表读入内存后再用脚本排序。`DatabaseQuery` 会组合 SQL 过滤、排序和分页,并校验运算符、引用列名和值: + +```squirrel +// 统率 >= 80,按统率降序、年龄升序,只取第 21~40 名。 +local q = officers.query(); +q.where("leadership", ">=", "80"); +q.orderBy("leadership", false); +q.orderBy("age", true); +q.limit(20); +q.offset(20); +local page = q.allJson(); +local total = q.count(); // count 忽略分页,返回满足过滤条件的总数 +``` + +C++ 的 `Repository::select` 使用相同查询器并返回类型化实体: + +```cpp +auto candidates = officerRepo.select([](Query& q) { + q.where("force_id", "=", int64_t(3)) + ->where("loyalty", "<", int64_t(70)) + ->orderBy("leadership", false) + ->limit(50); +}); +``` + +## 面向《三国志 10》规模数据的设计评估 + +仅有最初的 `Row + insert/update/query` **不满足**此类游戏:它会迫使脚本拼 SQL、全量加载后排序,而且无法表达稳定主键和实体生命周期。本模块现在将 API 分为三层: + +1. `Connection`:迁移、复杂联表和调试所需的底层 SQL 逃生舱。 +2. `Repository`:C++ 游戏系统使用的类型化实体 CRUD、建表和查询。 +3. `DatabaseModel + DatabaseQuery`:脚本使用的运行时模型、组合过滤、复合排序、count 和分页。 + +推荐把数据正规化,而不是为每个人物建立独立表。例如: + +```sql +CREATE TABLE officers ( + id INTEGER PRIMARY KEY, name TEXT NOT NULL, force_id INTEGER, + leadership INTEGER, war INTEGER, intelligence INTEGER, + politics INTEGER, charisma INTEGER, loyalty INTEGER, age INTEGER +); +CREATE TABLE officer_skills (officer_id INTEGER, skill_id TEXT, + level INTEGER, PRIMARY KEY(officer_id, skill_id)); +CREATE TABLE relationships (from_id INTEGER, to_id INTEGER, + kind TEXT, affinity INTEGER, PRIMARY KEY(from_id, to_id, kind)); +CREATE INDEX idx_officer_force_leadership + ON officers(force_id, leadership DESC); +CREATE INDEX idx_relationship_target ON relationships(to_id, kind); +``` + +具体任务与执行位置: + +- “列出曹操势力统率最高的 20 人”:`force_id = ?` + `leadership DESC` + `limit(20)`,由数据库索引完成。 +- “找忠诚低于 70 且智力高于 80 的可登用人物”:组合两个 `where`,不把数千人物加载进 ECS。 +- “显示人物关系”:关系属于多对多表,应使用联表 SQL或专用 Repository,而不是塞进人物 JSON 字段。 +- “进入战斗场景”:数据库负责持久化;只把参战人物投影到 ECS,战斗结束后在事务中回写变化。ECS 不应成为全世界人物数据库的常驻副本。 + +当前边界也需要明确:查询器目前支持单表 `= != < <= > >= LIKE`、复合排序和分页;复杂 JOIN、聚合报表仍通过 `Connection::query` 使用明确 SQL。生产项目下一阶段应增加迁移版本、外键声明、批量 upsert、事务/Unit of Work、后台只读连接池与领域缓存失效机制,而不是把这些隐式塞进 ORM。 + +导出的脚本 API: + +- `Database.connect(connector, connectionString)` / `connectSQLite(path)` +- `DatabaseConnection.isConnected()` / `close()` +- `execute(sql)` / `queryJson(sql)` +- `insertJson(table, objectJson)` / `updateJson(table, objectJson, whereClause)` / `remove(table, whereClause)` +- `DatabaseConnection.model(table, primaryKey)` → `DatabaseModel` +- `DatabaseConnection.from(table)` → `DatabaseQuery` +- `DatabaseModel.insertJson(objectJson)` / `findJson(idJson)` / `allJson()` / `updateJson(idJson, objectJson)` / `remove(idJson)` +- `DatabaseModel.query()` → `DatabaseQuery` +- `DatabaseQuery.where(column, op, valueJson)` / `orderBy(column, ascending)` / `limit(count)` / `offset(count)` +- `DatabaseQuery.allJson()` / `firstJson()` / `count()` diff --git a/docs/模块设计.md b/docs/模块设计.md index 6aaa6821..69916d30 100644 --- a/docs/模块设计.md +++ b/docs/模块设计.md @@ -273,11 +273,12 @@ solver.solve(sk) ### 16. 数据库 — `database` -通过 Poco 连接 SQLite;实体设定后支持 ORM,便于游戏数据更新与查询。 +通过 third-party POCO Data 连接 SQLite;实体设定后支持 ORM,便于游戏数据更新与查询。完整用法见[数据库模块设计](./数据库模块设计.md)。 -- [ ] SQLite 连接与基础 CRUD -- [ ] ORM 映射 -- [ ] 与游戏数据库 / 模型同步 +- [x] SQLite 连接、原始 SQL 与基础 CRUD +- [x] 显式 `Mapping` ORM 映射 +- [x] ECS View 批量事务导出 +- [x] Squirrel JSON 行接口 - [ ] 热重载时的数据一致性策略 ### 17. 数学库 — `math` @@ -401,7 +402,7 @@ solver.solve(sk) | 动画 | `animation` | [x] 补间 | | 地图 | `map` | [x] 可用(Tiled JSON 子集 + 多投影 + 对象层 + 统一深度队列) | | 程序化生成 | `procgen` | [x] Phase A 地图 + Phase B 贴图([程序化生成模块设计.md](./程序化生成模块设计.md)) | -| 数据库 | `database` | [ ] | +| 数据库 | `database` | [x] SQLite + CRUD/ORM + ECS 导出 + 脚本 API | | 数学 | `math` | [x] 可用(Vec2/Vec3/Mat4 + noise + bezier + random) | | 张量 | `tensor` / `TF` | [x] TF2 风格(eager + `func` 图;`CompiledFunction` / 大张量 reduce 经 `gpgpu` 走 GPU,回退 CPU) | | 实体 | `entity` | [x] ECS.hpp + 脚本 Entity/Component/System/view | diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index 9ee273bf..d2cee480 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -97,14 +97,14 @@ if(WIN32 AND (${CMAKE_BUILD_TYPE} STREQUAL "Debug")) medialoader_imagemdd medialoader_modelmdd medialoader_soundmdd assimpmdd zlibstaticd physfsmdd lz4mdd Box2Dmdd vorbis-staticmdd vorbisfile-staticmdd ogg-staticmdd modplug-staticmdd mpg123mdd OpenAL32mdd freetyped - PocoFoundationmdd PocoNetmdd PocoJSONmdd PocoXMLmdd + PocoDataSQLitemdd PocoDatamdd PocoFoundationmdd PocoNetmdd PocoJSONmdd PocoXMLmdd ${SystemLibraries}) elseif(WIN32) set(ThirdParty ${ThirdParty} simplesquirrel_staticmd sqstdlib_staticmd squirrel_staticmd SDL2 SDL2main medialoader_imagemd medialoader_modelmd medialoader_soundmd assimpmd zlibstatic physfsmd lz4md Box2Dmd vorbis-staticmd vorbisfile-staticmd ogg-staticmd modplug-staticmd mpg123md OpenAL32md freetypemd - PocoFoundationmd PocoNetmd PocoJSONmd PocoXMLmd + PocoDataSQLitemd PocoDatamd PocoFoundationmd PocoNetmd PocoJSONmd PocoXMLmd ${SystemLibraries}) elseif(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) # SDL2 is shared (loaded by Java); other deps are static where possible. @@ -114,28 +114,28 @@ elseif(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) medialoader_image medialoader_model medialoader_sound assimp z physfs lz4 Box2D vorbisfile-static vorbis-static ogg-static modplug-static mpg123 openal freetype - PocoNet PocoXML PocoJSON PocoFoundation + PocoDataSQLite PocoData PocoNet PocoXML PocoJSON PocoFoundation ${SystemLibraries}) elseif(CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS OR APPLE) set(ThirdParty ${ThirdParty} simplesquirrel_static sqstdlib_static squirrel_static SDL2 SDL2main medialoader_image medialoader_model medialoader_sound assimp z physfs lz4 Box2D vorbisfile-static vorbis-static ogg-static modplug-static mpg123 openal freetype - PocoNet PocoXML PocoJSON PocoFoundation + PocoDataSQLite PocoData PocoNet PocoXML PocoJSON PocoFoundation ${SystemLibraries}) else() set(ThirdParty ${ThirdParty} simplesquirrel_static sqstdlib_static squirrel_static SDL2 SDL2main medialoader_image medialoader_model medialoader_sound assimp z physfs lz4 Box2D vorbisfile-static vorbis-static ogg-static modplug-static mpg123 openal freetype - PocoNet PocoXML PocoJSON PocoFoundation + PocoDataSQLite PocoData PocoNet PocoXML PocoJSON PocoFoundation ${SystemLibraries}) endif() if(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android") OR CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS) - set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVSystem EVAnimation EVIK EVMath EVTensor EVTouch EVCmdLine EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVScene EVParticles EVMap EVGpgpu EVModel3D EVFont EVThread EVPlugins EVPhysics EVRPG EVProcgen) + set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVSystem EVAnimation EVIK EVMath EVTensor EVTouch EVCmdLine EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVDatabase EVData EVSound EVAudio EVUI eve_imgui EVScene EVParticles EVMap EVGpgpu EVModel3D EVFont EVThread EVPlugins EVPhysics EVRPG EVProcgen) else() - set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVSystem EVAnimation EVIK EVMath EVTensor EVTouch EVCmdLine EVDevTools EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVScene EVParticles EVMap EVGpgpu EVModel3D EVFont EVThread EVPlugins EVPhysics EVRPG EVProcgen) + set(EVELIBS EVWindow EVMouse EVKeyboard EVJoystick EVEvent EVTimer EVSystem EVAnimation EVIK EVMath EVTensor EVTouch EVCmdLine EVDevTools EVScripts EVCommon EVGraphics EVImage EVFileSystem EVNetwork EVDatabase EVData EVSound EVAudio EVUI eve_imgui EVScene EVParticles EVMap EVGpgpu EVModel3D EVFont EVThread EVPlugins EVPhysics EVRPG EVProcgen) endif() if(EVENGINE_BUILD_DEMO) diff --git a/src/modules/CMakeLists.txt b/src/modules/CMakeLists.txt index 26725c41..ae0d19b1 100644 --- a/src/modules/CMakeLists.txt +++ b/src/modules/CMakeLists.txt @@ -20,6 +20,8 @@ target_compile_definitions(EVData PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) create_module(EVImage image) create_module(EVNetwork network) target_compile_definitions(EVNetwork PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) +create_module(EVDatabase database) +target_compile_definitions(EVDatabase PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) create_module(EVSound sound) target_include_directories(EVSound PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/sound/include_shim diff --git a/src/modules/database/Database.cpp b/src/modules/database/Database.cpp new file mode 100644 index 00000000..61631a95 --- /dev/null +++ b/src/modules/database/Database.cpp @@ -0,0 +1,303 @@ +#include "database/Database.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace eve::database { +namespace { + +std::string quoteIdentifier(const std::string& name) { + if (name.empty()) throw std::invalid_argument("database identifier must not be empty"); + std::string quoted = "\""; + for (char c : name) { + if (c == '\0') throw std::invalid_argument("database identifier contains NUL"); + quoted += c == '"' ? "\"\"" : std::string(1, c); + } + return quoted + "\""; +} + +std::string literal(const Value& value) { + if (std::holds_alternative(value)) return "NULL"; + if (auto p = std::get_if(&value)) return std::to_string(*p); + if (auto p = std::get_if(&value)) return std::to_string(*p); + if (auto p = std::get_if(&value)) return *p ? "1" : "0"; + std::string escaped; + for (char c : std::get(value)) escaped += c == '\'' ? "''" : std::string(1, c); + return "'" + escaped + "'"; +} + +Value fromDynamic(const Poco::Dynamic::Var& value) { + if (value.isEmpty()) return std::monostate{}; + if (value.isBoolean()) return value.convert(); + if (value.isInteger()) return value.convert(); + if (value.isNumeric()) return value.convert(); + return value.convert(); +} + +Row parseObject(const std::string& json) { + auto object = Poco::JSON::Parser().parse(json).extract(); + Row row; + for (const auto& name : object->getNames()) row[name] = fromDynamic(object->get(name)); + return row; +} + +Poco::Dynamic::Var toDynamic(const Value& value); +Value parseScalar(const std::string& json) { + auto wrapper = Poco::JSON::Parser().parse("[" + json + "]").extract(); + if (wrapper->size() != 1) throw std::invalid_argument("ORM id must be one JSON scalar"); + auto value = wrapper->get(0); + if (!value.isEmpty() && !value.isBoolean() && !value.isNumeric() && !value.isString()) + throw std::invalid_argument("ORM id must be a JSON scalar"); + return fromDynamic(value); +} + +std::string rowsToJson(const std::vector& source) { + Poco::JSON::Array rows; + for (const auto& row : source) { + Poco::JSON::Object object; + for (const auto& [key, value] : row) object.set(key, toDynamic(value)); + rows.add(object); + } + std::ostringstream output; + Poco::JSON::Stringifier::stringify(rows, output); + return output.str(); +} + +Poco::Dynamic::Var toDynamic(const Value& value) { + if (std::holds_alternative(value)) return {}; + if (auto p = std::get_if(&value)) return Poco::Dynamic::Var(Poco::Int64(*p)); + if (auto p = std::get_if(&value)) return Poco::Dynamic::Var(*p); + if (auto p = std::get_if(&value)) return Poco::Dynamic::Var(*p); + return Poco::Dynamic::Var(std::get(value)); +} + +} // namespace + +Connection::Connection(std::string connector, std::string connectionString) { + Poco::Data::SQLite::Connector::registerConnector(); + session_ = std::make_unique(connector, connectionString); +} + +Connection::~Connection() { close(); } +bool Connection::isConnected() const { return session_ && session_->isConnected(); } +void Connection::close() { + if (session_ && session_->isConnected()) session_->close(); +} + +int Connection::execute(const std::string& sql) { + if (!isConnected()) throw std::runtime_error("database connection is closed"); + return static_cast((*session_ << sql).execute()); +} + +std::vector Connection::query(const std::string& sql) { + if (!isConnected()) throw std::runtime_error("database connection is closed"); + Poco::Data::Statement statement(*session_); + statement << sql; + statement.execute(); + Poco::Data::RecordSet records(statement); + std::vector result; + for (std::size_t rowIndex = 0; rowIndex < records.rowCount(); ++rowIndex) { + Row row; + for (std::size_t column = 0; column < records.columnCount(); ++column) + row[records.columnName(column)] = fromDynamic(records.value(column, rowIndex)); + result.push_back(std::move(row)); + } + return result; +} + +std::string Connection::queryJson(const std::string& sql) { return rowsToJson(query(sql)); } + +int Connection::insert(const std::string& table, const Row& values) { + if (values.empty()) throw std::invalid_argument("insert values must not be empty"); + std::string columns, data; + for (const auto& [key, value] : values) { + if (!columns.empty()) { + columns += ','; + data += ','; + } + columns += quoteIdentifier(key); + data += literal(value); + } + return execute("INSERT INTO " + quoteIdentifier(table) + " (" + columns + ") VALUES (" + data + ")"); +} + +int Connection::update(const std::string& table, const Row& values, const std::string& whereClause) { + if (values.empty()) throw std::invalid_argument("update values must not be empty"); + std::string assignments; + for (const auto& [key, value] : values) { + if (!assignments.empty()) assignments += ','; + assignments += quoteIdentifier(key) + '=' + literal(value); + } + return execute("UPDATE " + quoteIdentifier(table) + " SET " + assignments + + (whereClause.empty() ? "" : " WHERE " + whereClause)); +} + +int Connection::remove(const std::string& table, const std::string& whereClause) { + return execute("DELETE FROM " + quoteIdentifier(table) + (whereClause.empty() ? "" : " WHERE " + whereClause)); +} + +int Connection::insertJson(const std::string& table, const std::string& json) { + return insert(table, parseObject(json)); +} +int Connection::updateJson(const std::string& table, const std::string& json, const std::string& whereClause) { + return update(table, parseObject(json), whereClause); +} + +int Connection::updateByKey(const std::string& table, const Row& values, const std::string& key, const Value& id) { + return update(table, values, quoteIdentifier(key) + "=" + literal(id)); +} + +int Connection::removeByKey(const std::string& table, const std::string& key, const Value& id) { + return remove(table, quoteIdentifier(key) + "=" + literal(id)); +} + +std::vector Connection::queryByKey(const std::string& table, const std::string& key, const Value& id) { + return query("SELECT * FROM " + quoteIdentifier(table) + " WHERE " + quoteIdentifier(key) + "=" + literal(id) + + " LIMIT 1"); +} + +Model* Connection::model(std::string table, std::string primaryKey) { + return new Model(this, std::move(table), std::move(primaryKey)); +} +Query* Connection::from(std::string table) { return new Query(this, std::move(table)); } + +Query::Query(Connection* connection, std::string table) : connection_(connection), table_(std::move(table)) { + if (!connection_) throw std::invalid_argument("query requires a connection"); +} + +Query* Query::where(const std::string& column, const std::string& op, Value value) { + static const std::vector allowed{"=", "!=", "<", "<=", ">", ">=", "LIKE"}; + if (std::find(allowed.begin(), allowed.end(), op) == allowed.end()) + throw std::invalid_argument("unsupported query operator: " + op); + predicates_.push_back(quoteIdentifier(column) + " " + op + " " + literal(value)); + return this; +} + +Query* Query::whereJson(const std::string& column, const std::string& op, const std::string& valueJson) { + return where(column, op, parseScalar(valueJson)); +} + +Query* Query::orderBy(const std::string& column, bool ascending) { + ordering_.push_back(quoteIdentifier(column) + (ascending ? " ASC" : " DESC")); + return this; +} + +Query* Query::limit(int count) { + if (count < 0) throw std::invalid_argument("query limit must not be negative"); + limit_ = count; + return this; +} + +Query* Query::offset(int count) { + if (count < 0) throw std::invalid_argument("query offset must not be negative"); + offset_ = count; + return this; +} + +std::string Query::selectSql(bool countOnly, int forcedLimit) const { + std::string sql = countOnly ? "SELECT COUNT(*) AS count FROM " : "SELECT * FROM "; + sql += quoteIdentifier(table_); + for (std::size_t i = 0; i < predicates_.size(); ++i) sql += (i == 0 ? " WHERE " : " AND ") + predicates_[i]; + if (!countOnly && !ordering_.empty()) { + sql += " ORDER BY "; + for (std::size_t i = 0; i < ordering_.size(); ++i) sql += (i ? "," : "") + ordering_[i]; + } + int effectiveLimit = forcedLimit >= 0 ? forcedLimit : limit_; + if (!countOnly && effectiveLimit >= 0) + sql += " LIMIT " + std::to_string(effectiveLimit); + else if (!countOnly && offset_ > 0) + sql += " LIMIT -1"; + if (!countOnly && offset_ > 0) sql += " OFFSET " + std::to_string(offset_); + return sql; +} + +std::vector Query::all() { return connection_->query(selectSql(false)); } +std::string Query::allJson() { return rowsToJson(all()); } +std::string Query::firstJson() { return rowsToJson(connection_->query(selectSql(false, 1))); } +int Query::count() { + auto rows = connection_->query(selectSql(true)); + if (rows.empty()) return 0; + const Value& value = rows.front().at("count"); + if (auto integer = std::get_if(&value)) return static_cast(*integer); + if (auto number = std::get_if(&value)) return static_cast(*number); + if (auto text = std::get_if(&value)) return std::stoi(*text); + throw std::runtime_error("database COUNT returned a non-numeric value"); +} + +Model::Model(Connection* connection, std::string table, std::string primaryKey) + : connection_(connection), table_(std::move(table)), primaryKey_(std::move(primaryKey)) { + if (!connection_) throw std::invalid_argument("ORM model requires a connection"); +} + +int Model::insertJson(const std::string& json) { return connection_->insertJson(table_, json); } +std::string Model::allJson() { return connection_->queryJson("SELECT * FROM " + quoteIdentifier(table_)); } +std::string Model::findJson(const std::string& idJson) { + return rowsToJson(connection_->queryByKey(table_, primaryKey_, parseScalar(idJson))); +} +int Model::updateJson(const std::string& idJson, const std::string& json) { + return connection_->updateByKey(table_, parseObject(json), primaryKey_, parseScalar(idJson)); +} +int Model::remove(const std::string& idJson) { + return connection_->removeByKey(table_, primaryKey_, parseScalar(idJson)); +} +Query* Model::query() { return new Query(connection_, table_); } + +Module_IMPL(Database, new Database()); +Connection* Database::connect(std::string connector, std::string connectionString) { + return new Connection(std::move(connector), std::move(connectionString)); +} +Connection* Database::connectSQLite(std::string path) { return connect("SQLite", std::move(path)); } + +void Database::expose(ssq::Table& table) { + auto module = table.addClass(name, Database::create, false); + expose(module); + auto connection = table.addClass( + "DatabaseConnection", std::function([] { return new Connection("SQLite", ":memory:"); }), true); + connection.addFunc("isConnected", &Connection::isConnected); + connection.addFunc("close", &Connection::close); + connection.addFunc("execute", &Connection::execute); + connection.addFunc("queryJson", &Connection::queryJson); + connection.addFunc("insertJson", &Connection::insertJson); + connection.addFunc("updateJson", &Connection::updateJson); + connection.addFunc("remove", &Connection::remove); + connection.addFunc("model", &Connection::model); + connection.addFunc("from", &Connection::from); + + auto query = + table.addClass("DatabaseQuery", std::function([]() -> Query* { return nullptr; }), true); + query.addFunc("where", &Query::whereJson); + query.addFunc("orderBy", &Query::orderBy); + query.addFunc("limit", &Query::limit); + query.addFunc("offset", &Query::offset); + query.addFunc("allJson", &Query::allJson); + query.addFunc("firstJson", &Query::firstJson); + query.addFunc("count", &Query::count); + + auto model = + table.addClass("DatabaseModel", std::function([]() -> Model* { return nullptr; }), true); + model.addFunc("insertJson", &Model::insertJson); + model.addFunc("allJson", &Model::allJson); + model.addFunc("findJson", &Model::findJson); + model.addFunc("updateJson", &Model::updateJson); + model.addFunc("remove", &Model::remove); + model.addFunc("query", &Model::query); +} + +void Database::expose(ssq::Class& cls) { + cls.addFunc("getName", &Database::getName); + cls.addFunc("connect", &Database::connect); + cls.addFunc("connectSQLite", &Database::connectSQLite); +} + +} // namespace eve::database diff --git a/src/modules/database/Database.h b/src/modules/database/Database.h new file mode 100644 index 00000000..68913269 --- /dev/null +++ b/src/modules/database/Database.h @@ -0,0 +1,227 @@ +#pragma once + +#include "common/Module.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Poco::Data { +class Session; +} + +namespace eve::database { + +class Model; +class Query; + +using Value = std::variant; +using Row = std::unordered_map; + +/** A named field used by the small, explicit ORM mapping layer. */ +template +struct Field { + std::string name; + std::function read; + std::function write; + std::string sqlType = "TEXT"; + bool primaryKey = false; +}; + +template +struct Mapping { + std::string table; + std::vector> fields; + + Row toRow(const T& object) const { + Row row; + for (const auto& field : fields) row[field.name] = field.read(object); + return row; + } + + T fromRow(const Row& row) const { + T object{}; + for (const auto& field : fields) { + auto it = row.find(field.name); + if (it != row.end()) field.write(object, it->second); + } + return object; + } +}; + +class Connection { +public: + Connection(std::string connector, std::string connectionString); + ~Connection(); + + Connection(const Connection&) = delete; + Connection& operator=(const Connection&) = delete; + + bool isConnected() const; + void close(); + int execute(const std::string& sql); + std::vector query(const std::string& sql); + std::string queryJson(const std::string& sql); + int insert(const std::string& table, const Row& values); + int update(const std::string& table, const Row& values, const std::string& whereClause); + int remove(const std::string& table, const std::string& whereClause); + + // Script-friendly JSON counterparts. JSON must be an object of column/value pairs. + int insertJson(const std::string& table, const std::string& json); + int updateJson(const std::string& table, const std::string& json, const std::string& whereClause); + + int updateByKey(const std::string& table, const Row& values, const std::string& key, const Value& id); + int removeByKey(const std::string& table, const std::string& key, const Value& id); + std::vector queryByKey(const std::string& table, const std::string& key, const Value& id); + Model* model(std::string table, std::string primaryKey = "id"); + Query* from(std::string table); + + template + int save(const Mapping& mapping, const T& object) { + return insert(mapping.table, mapping.toRow(object)); + } + + template + int exportECS(const std::string& table, const Range& entities, Mapper mapper) { + int count = 0; + execute("BEGIN"); + try { + for (const auto& entity : entities) count += insert(table, mapper(entity)); + execute("COMMIT"); + } catch (...) { + try { + execute("ROLLBACK"); + } catch (...) { + } + throw; + } + return count; + } + +private: + std::unique_ptr session_; +}; + +/** Composable filtered/sorted/paged query shared by C++ and scripts. */ +class Query { +public: + Query(Connection* connection, std::string table); + Query* where(const std::string& column, const std::string& op, Value value); + Query* whereJson(const std::string& column, const std::string& op, const std::string& valueJson); + Query* orderBy(const std::string& column, bool ascending = true); + Query* limit(int count); + Query* offset(int count); + std::vector all(); + std::string allJson(); + std::string firstJson(); + int count(); + +private: + std::string selectSql(bool countOnly, int forcedLimit = -1) const; + Connection* connection_; + std::string table_; + std::vector predicates_; + std::vector ordering_; + int limit_ = -1; + int offset_ = 0; +}; + +/** Typed active-record repository built from a Mapping. */ +template +class Repository { +public: + Repository(Connection& connection, Mapping mapping) : connection_(connection), mapping_(std::move(mapping)) {} + + void createTable(bool ifNotExists = true) { + if (mapping_.fields.empty()) throw std::invalid_argument("ORM mapping has no fields"); + std::string sql = "CREATE TABLE "; + if (ifNotExists) sql += "IF NOT EXISTS "; + sql += mapping_.table + " ("; + for (std::size_t i = 0; i < mapping_.fields.size(); ++i) { + const auto& field = mapping_.fields[i]; + if (i) sql += ','; + sql += field.name + " " + field.sqlType; + if (field.primaryKey) sql += " PRIMARY KEY"; + } + connection_.execute(sql + ")"); + } + + int insert(const T& object) { return connection_.insert(mapping_.table, mapping_.toRow(object)); } + + std::vector all() { + std::vector objects; + for (const auto& row : connection_.query("SELECT * FROM " + mapping_.table)) + objects.push_back(mapping_.fromRow(row)); + return objects; + } + + bool find(const Value& id, T& object) { + auto rows = connection_.queryByKey(mapping_.table, primaryKey().name, id); + if (rows.empty()) return false; + object = mapping_.fromRow(rows.front()); + return true; + } + + int update(const T& object) { + const auto& key = primaryKey(); + Row values = mapping_.toRow(object); + Value id = values.at(key.name); + values.erase(key.name); + return connection_.updateByKey(mapping_.table, values, key.name, id); + } + + int remove(const Value& id) { return connection_.removeByKey(mapping_.table, primaryKey().name, id); } + + std::vector select(const std::function& configure) { + Query query(&connection_, mapping_.table); + configure(query); + std::vector objects; + for (const auto& row : query.all()) objects.push_back(mapping_.fromRow(row)); + return objects; + } + +private: + const Field& primaryKey() const { + for (const auto& field : mapping_.fields) + if (field.primaryKey) return field; + throw std::logic_error("ORM mapping requires one primary key field"); + } + + Connection& connection_; + Mapping mapping_; +}; + +/** Runtime ORM model exposed to Squirrel. */ +class Model { +public: + Model(Connection* connection, std::string table, std::string primaryKey); + int insertJson(const std::string& json); + std::string allJson(); + std::string findJson(const std::string& idJson); + int updateJson(const std::string& idJson, const std::string& json); + int remove(const std::string& idJson); + Query* query(); + +private: + Connection* connection_; + std::string table_; + std::string primaryKey_; +}; + +class Database : public Module { +public: + Module_REG(Database); + Database() = default; + ~Database() override = default; + + Connection* connect(std::string connector, std::string connectionString); + Connection* connectSQLite(std::string path); +}; + +} // namespace eve::database diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 3d633017..1d422778 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -15,6 +15,7 @@ file(GLOB all_test_cpp joystick_cpp.cpp touch.cpp network.cpp + database.cpp RenderSystem.cpp RenderSystem3D.cpp Quad.cpp @@ -141,21 +142,21 @@ endif() if(WIN32 AND (${CMAKE_BUILD_TYPE} STREQUAL "Debug")) set(ThirdParty ${ThirdParty} simplesquirrel_staticmdd sqstdlib_staticmdd squirrel_staticmdd SDL2d - PocoFoundationmdd PocoNetmdd PocoJSONmdd PocoXMLmdd + PocoDataSQLitemdd PocoDatamdd PocoFoundationmdd PocoNetmdd PocoJSONmdd PocoXMLmdd ${SystemLibraries}) else() if(WIN32) set(ThirdParty ${ThirdParty} simplesquirrel_staticmd sqstdlib_staticmd squirrel_staticmd SDL2 - PocoFoundationmd PocoNetmd PocoJSONmd PocoXMLmd + PocoDataSQLitemd PocoDatamd PocoFoundationmd PocoNetmd PocoJSONmd PocoXMLmd ${SystemLibraries}) else() set(ThirdParty ${ThirdParty} simplesquirrel_static sqstdlib_static squirrel_static SDL2 - PocoNet PocoXML PocoJSON PocoFoundation + PocoDataSQLite PocoData PocoNet PocoXML PocoJSON PocoFoundation ${SystemLibraries}) endif() endif() -set(EVELIBS EVCmdLine EVScripts EVWindow EVFileSystem EVMouse EVKeyboard EVJoystick EVTouch EVGraphics EVImage EVDevTools EVCommon EVEvent EVTimer EVSystem EVMath EVIK EVAnimation EVTensor EVNetwork EVData EVSound EVAudio EVUI eve_imgui EVScene EVParticles EVPhysics EVMap EVGpgpu EVModel3D EVFont EVThread EVRPG EVProcgen) +set(EVELIBS EVCmdLine EVScripts EVWindow EVFileSystem EVMouse EVKeyboard EVJoystick EVTouch EVGraphics EVImage EVDevTools EVCommon EVEvent EVTimer EVSystem EVMath EVIK EVAnimation EVTensor EVNetwork EVDatabase EVData EVSound EVAudio EVUI eve_imgui EVScene EVParticles EVPhysics EVMap EVGpgpu EVModel3D EVFont EVThread EVRPG EVProcgen) if(EVENGINE_BUILD_DEMO) list(APPEND EVELIBS EVDemo) diff --git a/test/database.cpp b/test/database.cpp new file mode 100644 index 00000000..008c8e0a --- /dev/null +++ b/test/database.cpp @@ -0,0 +1,65 @@ +#include "database/Database.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::database; + +TEST_CASE("Database.SQLiteCrudOrmAndECSExport") { + Connection db("SQLite", ":memory:"); + CHECK(db.isConnected()); + db.execute("CREATE TABLE actors (id INTEGER, name TEXT, hp REAL)"); + + struct Actor { + std::int64_t id{}; + std::string name; + double hp{}; + }; + Mapping mapping{"actors", + { + {"id", [](const Actor& a) -> Value { return a.id; }, + [](Actor& a, const Value& v) { a.id = std::get(v); }, "INTEGER", true}, + {"name", [](const Actor& a) -> Value { return a.name; }, + [](Actor& a, const Value& v) { a.name = std::get(v); }}, + {"hp", [](const Actor& a) -> Value { return a.hp; }, + [](Actor& a, const Value& v) { a.hp = std::get(v); }}, + }}; + + Repository actors(db, mapping); + // A real ORM owns schema and typed entity CRUD rather than only serializing a row. + db.execute("DROP TABLE actors"); + actors.createTable(); + CHECK_EQ(actors.insert(Actor{1, "mage", 80.0}), 1); + db.insertJson("actors", R"({"id":2,"name":"knight","hp":100})"); + CHECK_EQ(db.updateJson("actors", R"({"hp":95})", "id=2"), 1); + + std::vector ecsView{{3, "rogue", 70.0}, {4, "cleric", 90.0}}; + CHECK_EQ(db.exportECS("actors", ecsView, [&mapping](const Actor& actor) { return mapping.toRow(actor); }), 2); + auto rows = db.query("SELECT id,name,hp FROM actors ORDER BY id"); + CHECK_EQ(rows.size(), 4); + CHECK_EQ(std::get(rows[1]["name"]), "knight"); + CHECK_EQ(db.remove("actors", "id=1"), 1); + CHECK_EQ(db.query("SELECT id FROM actors").size(), 3); + + Actor loaded; + CHECK(actors.find(std::int64_t(2), loaded)); + CHECK_EQ(loaded.name, "knight"); + loaded.hp = 88.0; + CHECK_EQ(actors.update(loaded), 1); + CHECK_EQ(actors.all().size(), 3); + CHECK_EQ(actors.remove(std::int64_t(2)), 1); + + std::unique_ptr model(db.model("actors", "id")); + model->insertJson(R"({"id":5,"name":"bard","hp":60})"); + CHECK(model->findJson("5").find("bard") != std::string::npos); + CHECK_EQ(model->updateJson("5", R"({"hp":65})"), 1); + CHECK_EQ(model->remove("5"), 1); + + db.insertJson("actors", R"({"id":6,"name":"strategist","hp":75})"); + db.insertJson("actors", R"({"id":7,"name":"general","hp":99})"); + std::unique_ptr ranked(model->query()); + ranked->where("hp", ">=", 70.0)->orderBy("hp", false)->limit(2); + auto ranking = ranked->all(); + CHECK_EQ(ranking.size(), 2); + CHECK_EQ(std::get(ranking[0]["name"]), "general"); + CHECK_EQ(ranked->count(), 4); +}