Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 129 additions & 18 deletions gimuserver/db/DatabaseInterface.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,77 @@

#include <drogon/drogon.h>

#include <algorithm>
#include <stdexcept>

namespace db
{

std::string DatabaseInterface::buildWhere(
const Cells& lookup,
size_t from,
Cells& binds)
{
std::string sql;
size_t placeholder = from;

for (const auto& cell : lookup)
{
if (!sql.empty())
{
sql += " AND ";
}

if (cell.use != Cell::Use::LookupIn)
{
sql += cell.name + " = $" + std::to_string(placeholder++);
binds.push_back(cell);
continue;
}

// An empty IN list would either match nothing or, worse, invite the
// caller to splice the list into SQL themselves. Reject it instead.
if (cell.list.empty())
{
LOG_ERROR << "Empty IN list for column: " << cell.name;
throw std::invalid_argument("Empty IN list for database lookup");
}

sql += cell.name + " IN (";
for (size_t index = 0; index < cell.list.size(); ++index)
{
sql += (index == 0 ? "" : ", ") + std::string("$") +
std::to_string(placeholder++);
binds.push_back(Lookup(cell.name, cell.list[index]));
}
sql += ")";
}

return sql;
}

drogon::Task<InterfaceResult<Result>> DatabaseInterface::read(
const Database database,
const std::string table,
const Cells cells)
{
const auto data = getCellsFor<Use::Data>(cells);
const auto lookup = getCellsFor<Use::Lookup>(cells);
const auto lookup = getLookupCells(cells);
validate(database, table, data);

const auto selectSql = "SELECT " + joinSql(data, [](const Cell& cell, const size_t index) {
return std::string(index == 0 ? "" : ", ") + cell.name;
});
const auto whereSql = joinSql(lookup, [](const Cell& cell, const size_t index) {
return std::string(index == 0 ? "" : " AND ") +
cell.name + " = $" + std::to_string(index + 1);
});
Cells binds;
const auto whereSql = buildWhere(lookup, 1, binds);

// Empty lookup means the caller intentionally requested a table-wide read.
const auto sql = selectSql +
" FROM " + table +
(lookup.empty() ? "" : " WHERE " + whereSql) + ";";

auto binder = *database << sql;
bind(binder, lookup);
bind(binder, binds);

auto result = co_await drogon::orm::internal::SqlAwaiter(std::move(binder));
co_return InterfaceResult<Result>{
Expand All @@ -45,17 +87,15 @@ drogon::Task<InterfaceResult<>> DatabaseInterface::update(
const Cells cells)
{
const auto data = getCellsFor<Use::Data>(cells);
const auto lookup = getCellsFor<Use::Lookup>(cells);
const auto lookup = getLookupCells(cells);
validate(database, table, data);

const auto setSql = joinSql(data, [](const Cell& cell, const size_t index) {
return std::string(index == 0 ? "" : ", ") +
cell.name + " = $" + std::to_string(index + 1);
});
const auto whereSql = joinSql(lookup, [from = data.size() + 1](const Cell& cell, const size_t index) {
return std::string(index == 0 ? "" : " AND ") +
cell.name + " = $" + std::to_string(from + index);
});
Cells binds;
const auto whereSql = buildWhere(lookup, data.size() + 1, binds);

// Empty lookup means the caller intentionally requested a table-wide update.
const auto sql = "UPDATE " + table +
Expand All @@ -64,7 +104,7 @@ drogon::Task<InterfaceResult<>> DatabaseInterface::update(

auto binder = *database << sql;
bind(binder, data);
bind(binder, lookup);
bind(binder, binds);

auto result = co_await drogon::orm::internal::SqlAwaiter(std::move(binder));
co_return InterfaceResult<>{
Expand Down Expand Up @@ -107,24 +147,95 @@ drogon::Task<InterfaceResult<Result>> DatabaseInterface::insert(
};
}

drogon::Task<InterfaceResult<>> DatabaseInterface::upsert(
const Database database,
const std::string table,
const Cells cells,
const Keys conflict,
const Keys accumulate)
{
const auto data = getCellsFor<Use::Data>(cells);
validate(database, table, data);

if (conflict.empty())
{
LOG_ERROR << "Upsert without a conflict target on table: " << table;
throw std::invalid_argument("Upsert requires a conflict target");
}

Keys keys;
keys.reserve(data.size());
for (const auto& cell : data)
{
keys.push_back(cell.name);
}

const auto isIn = [](const Keys& list, const Key& key) {
return std::find(list.begin(), list.end(), key) != list.end();
};

// Conflict-target columns identify the row, so they are never reassigned.
std::string setSql;
for (const auto& key : keys)
{
if (isIn(conflict, key))
{
continue;
}

if (!setSql.empty())
{
setSql += ", ";
}

setSql += isIn(accumulate, key)
? key + " = " + key + " + excluded." + key
: key + " = excluded." + key;
}

const auto columnSql = joinSql(keys, [](const Key& key, const size_t index) {
return std::string(index == 0 ? "" : ", ") + key;
});
const auto valueSql = joinSql(data, [](const auto&, const size_t index) {
return std::string(index == 0 ? "" : ", ") + "$" + std::to_string(index + 1);
});
const auto conflictSql = joinSql(conflict, [](const Key& key, const size_t index) {
return std::string(index == 0 ? "" : ", ") + key;
});

// Every data column being part of the conflict target leaves nothing to
// assign; DO NOTHING is the correct degenerate form.
const auto sql = "INSERT INTO " + table +
" (" + columnSql + ") VALUES (" + valueSql + ")" +
" ON CONFLICT(" + conflictSql + ") DO " +
(setSql.empty() ? "NOTHING" : "UPDATE SET " + setSql) + ";";

auto binder = *database << sql;
bind(binder, data);

auto result = co_await drogon::orm::internal::SqlAwaiter(std::move(binder));
co_return InterfaceResult<>{
.data = {},
.affected = result.affectedRows(),
};
}

drogon::Task<InterfaceResult<>> DatabaseInterface::remove(
const Database database,
const std::string table,
const Cells cells)
{
const auto lookup = getCellsFor<Use::Lookup>(cells);
const auto lookup = getLookupCells(cells);
validate(database, table, lookup);

const auto whereSql = joinSql(lookup, [](const Cell& cell, const size_t index) {
return std::string(index == 0 ? "" : " AND ") +
cell.name + " = $" + std::to_string(index + 1);
});
Cells binds;
const auto whereSql = buildWhere(lookup, 1, binds);

const auto sql = "DELETE FROM " + table +
" WHERE " + whereSql + ";";

auto binder = *database << sql;
bind(binder, lookup);
bind(binder, binds);

auto result = co_await drogon::orm::internal::SqlAwaiter(std::move(binder));
co_return InterfaceResult<>{
Expand Down
58 changes: 58 additions & 0 deletions gimuserver/db/DatabaseInterface.h
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,32 @@ class DatabaseInterface final
const std::string table,
const Cells cells);

/*!
* Inserts a row, or merges into the existing one on key conflict.
*
* Exists because plain insert ignores conflicting rows, which cannot express
* "add to the stack I already own". Without it callers fall back to raw
* execSqlCoro and the typed layer stops seeing their queries.
*
* Data cells are the inserted columns. On conflict with `conflict`, columns
* named in `accumulate` are ADDED to (col = col + excluded.col) and every
* other non-key data column is replaced. With an empty `accumulate` this is
* a plain insert-or-replace.
*
* @param database Database client or transaction to use.
* @param table SQL table name.
* @param cells Data cells for the insert.
* @param conflict Columns forming the conflict target.
* @param accumulate Data columns to accumulate instead of replace.
* @return Number of affected rows.
*/
static drogon::Task<InterfaceResult<>> upsert(
const Database database,
const std::string table,
const Cells cells,
const Keys conflict,
const Keys accumulate = {});

/*!
* Deletes rows from a table.
*
Expand Down Expand Up @@ -129,6 +155,38 @@ class DatabaseInterface final
}
}

/*!
* Builds a WHERE clause from lookup cells.
*
* Handles both equality (Lookup) and IN (LookupIn) predicates, numbering
* placeholders from `from` and appending the values to bind — one cell per
* placeholder, IN lists expanded — to `binds` in that same order. Callers
* then bind `binds` rather than the original lookup cells.
*
* @param lookup Lookup cells to turn into predicates.
* @param from First placeholder number to use.
* @param binds Receives the values to bind, in placeholder order.
* @return SQL predicate text, without the leading WHERE.
*/
static std::string buildWhere(const Cells& lookup, size_t from, Cells& binds);

/*!
* Collects every predicate cell — equality and IN alike — in caller order.
*/
static Cells getLookupCells(const Cells& cells)
{
Cells output;
for (const auto& cell : cells)
{
if (cell.use == Use::Lookup || cell.use == Use::LookupIn)
{
output.push_back(cell);
}
}

return output;
}

/*!
* Filters mixed cells down to either lookup predicates or data values.
*/
Expand Down
Loading