ores.cpp.service.service_impl
Table of Contents
Repository-backed operations exposed to messaging. service profile (per entity). Application-layer service wrapping repository calls with business-rule validation, NATS event emission, and structured error handling.
Save preconditions validate each key member: a uuid member must not be nil, a text member must not be empty. An integer key member has no empty state and is not validated.
See the Template variable reference for the complete list of available variables and their semantics.
1. Template
The full template source. Edit here and re-tangle with
compass build --direct tangle_codegen_templates to regenerate
library/templates/cpp_service.cpp.mustache.
{{! GENERATED FILE — tangled from projects/ores.codegen/library/templates/cpp_messaging.org. Edit the org source. }}
{{{cpp_license}}}
{{#domain_entity}}
#include "ores.{{component_core}}/service/{{entity_singular}}_service.hpp"
#include "ores.{{component_include}}/domain/{{entity_singular}}.hpp"
#include "ores.{{component_include}}/messaging/{{entity_singular}}_protocol.hpp"
#include "ores.{{component_core}}/repository/{{entity_singular}}_repository.hpp"
#include <cstdint>
#include <stdexcept>
{{#gated}}
#include "ores.{{component_core}}/repository/{{entity_singular}}_change_repository.hpp"
#include "ores.service/messaging/approval_gate.hpp"
#include <algorithm>
#include <string_view>
{{/gated}}
{{#protocol_derived}}
#include <optional>
#include <string>
#include <utility>
#include <vector>
{{/protocol_derived}}
{{#service_pages_versions}}
#include <algorithm>
#include <cstddef>
#include <iterator>
{{/service_pages_versions}}
{{#service_lists_by_as_of}}
#include <chrono>
{{/service_lists_by_as_of}}
#include <boost/log/sources/severity_feature.hpp>
{{#service_uses_uuid}}
#include <boost/uuid/uuid.hpp>
{{/service_uses_uuid}}
{{#service_parses_declared_uuid}}
#include <boost/lexical_cast.hpp>
{{/service_parses_declared_uuid}}
{{#service_uses_uuid_io}}
// Log lines stream uuids with uuid_io's operator<<, which the include check
// does not count as a use.
#include <boost/uuid/uuid_io.hpp> // IWYU pragma: keep.
{{/service_uses_uuid_io}}
<<paste:A7D3F1E2-8C5B-4A2F-B9E0-3D6C1A8F4B2E>>
{{#domain_entity.has_as_of_lookup}}
#include "ores.database/repository/valid_at.hpp"
{{/domain_entity.has_as_of_lookup}}
{{#domain_entity.has_range_filter}}
#include "ores.database/repository/range_bound.hpp"
{{/domain_entity.has_range_filter}}
#include "ores.database/domain/context.hpp"
#include "ores.database/domain/outcome_code.hpp"
#include "ores.logging/boost_severity.hpp"
{{#service_uses_datetime}}
#include "ores.platform/time/datetime.hpp"
{{/service_uses_datetime}}
#include "ores.service/messaging/handler_helpers.hpp"
#include "ores.utility/domain/protocol.hpp"
{{#has_parent_id}}
#include "ores.utility/domain/hierarchy.hpp"
{{/has_parent_id}}
using ores::service::messaging::stamp;
// Every refusal names its outcome; the catalogue supplies the code and the
// sentence, so a service states what happened and nothing about how to say it.
using ores::database::domain::outcome_args;
using ores::database::domain::outcome_code;
using ores::database::domain::refuse;
namespace ores::{{component}}::service {
using namespace ores::logging;
{{entity_singular}}_service::{{entity_singular}}_service(context ctx)
: ctx_(std::move(ctx))
<<paste:4F8A2B1E-7C3D-4E9F-B6A0-1D5C8F2E7A4B>>{}
{{#protocol_derived}}
namespace {
<<paste:ED2E71EE-7410-4696-A732-C09D67DCA1E9>>
/**
* @brief The current row a key names, or an empty vector when there is none.
*
* A key record carries each column with the column's own type, and the
* repository takes the text form every one of its key parameters shares, so
* the conversion lives here rather than at every call site.
*
* The key record carries the key the model declares, which is the one a caller
* holds. When that is not the storage key the row is found by it and the
* repository's storage-key read is not used at all.
*/
std::vector<domain::{{entity_singular}}> {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repository::{{entity_singular}}_repository& repo,
const ores::database::context& ctx,
const messaging::{{entity_singular}}_key& key) {
{{#key_is_primary}}
return repo.read_latest(ctx,
{{#primary_key.key_columns}}{{{key_text}}}{{^last}}, {{/last}}{{/primary_key.key_columns}});
{{/key_is_primary}}
{{^key_is_primary}}
return repo.read_latest_by_{{declared_key}}(ctx, {{{declared_key_text}}});
{{/key_is_primary}}
}
{{#put_operations}}
/**
* @brief The key a domain object states, so a written row can be read back.
*
* A create states its own key in the write record, so the key of the row a
* write produced is the one the object carries.
*/
messaging::{{entity_singular}}_key {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(const domain::{{entity_singular}}& v) {
messaging::{{entity_singular}}_key key;
{{#key_is_primary}}
{{#primary_key.key_columns}}
key.{{column}} = v.{{group_prefix}}{{column}};
{{/primary_key.key_columns}}
{{/key_is_primary}}
{{^key_is_primary}}
key.{{declared_key}} = v.{{declared_key_group_prefix}}{{declared_key}};
{{/key_is_primary}}
return key;
}
/**
* @brief Builds the domain object a write record states.
*
* The record carries the user-owned fields and nothing else: tenancy,
* provenance, the version and the validity window are the service's and the
* database's to state, and are set after this conversion.
*/
domain::{{entity_singular}} {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_to_domain(const messaging::{{entity_singular}}_write& write) {
domain::{{entity_singular}} v;
{{#write_fields}}
{{#enum_from_string}}
{
const auto parsed = {{{enum_from_string}}}(write.{{name}});
v.{{domain_member}} = parsed.value_or({{{enum_default}}});
}
{{/enum_from_string}}
{{#date_from_string}}
if (!write.{{name}}.empty())
v.{{domain_member}} = ores::platform::time::datetime::from_iso8601_date(write.{{name}});
{{/date_from_string}}
{{#timestamp_from_string}}
if (!write.{{name}}.empty())
v.{{domain_member}} = ores::platform::time::datetime::from_iso8601_utc(write.{{name}});
{{/timestamp_from_string}}
{{^enum_from_string}}
{{^date_from_string}}
{{^timestamp_from_string}}
v.{{domain_member}} = write.{{name}};
{{/timestamp_from_string}}
{{/date_from_string}}
{{/enum_from_string}}
{{/write_fields}}
return v;
}
{{#gated}}
/**
* @brief The columns a write changes, by the names the approval policy uses.
*
* A write that creates a row changes every column.
*/
std::vector<std::string> {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_columns_changed(const std::vector<domain::{{entity_singular}}>& live,
const domain::{{entity_singular}}& next) {
const bool created = live.empty();
std::vector<std::string> names;
// The key names the row and is not a column a policy decides.
const auto note = [&](std::string_view name, bool changed) {
if (changed && name != "{{primary_key.column}}")
names.emplace_back(name);
};
{{#write_fields}}
note("{{name}}", created || live.front().{{domain_member}} != next.{{domain_member}});
{{/write_fields}}
return names;
}
/**
* @brief Refuses a direct write to a gated entity.
*
* The in-process save and remove methods take no request, so no approved
* request could open the gate for them. They are refused, and the protocol
* operations are the only way to write.
*/
[[noreturn]] void {{entity_singular}}_refuse_direct_write() {
throw std::logic_error(ores::database::domain::describe(outcome_code::approval_required,
{.entity = "{{entity_singular}}"}));
}
{{/gated}}
{{/put_operations}}
}
{{#list_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
{{#has_order}}
if (!request.order.field.empty() &&
!repository::{{entity_singular}}_repository::is_sortable(request.order.field)) {
response.result = refuse(outcome_code::order_not_supported,
{.entity = "{{entity_plural_words}}",
.field = request.order.field});
return response;
}
{{/has_order}}
{{#has_filter}}
{{#domain_entity.filter_members}}
{{#is_one_of}}
if (request.filter && request.filter->{{member}} &&
request.filter->{{member}}->size() > 1000) {
response.result = refuse(outcome_code::filter_too_large,
{.field = "{{member}}", .limit = "1000"});
return response;
}
{{/is_one_of}}
{{#is_range}}
if (request.filter && request.filter->{{member}} &&
!ores::database::repository::range_bound(
*request.filter->{{member}},
ores::database::repository::range_end::{{#is_from}}from{{/is_from}}{{#is_to}}to{{/is_to}},
{{#is_timestamp}}true{{/is_timestamp}}{{^is_timestamp}}false{{/is_timestamp}})) {
response.result = refuse(outcome_code::filter_range_invalid,
{.field = "{{member}}", .value = *request.filter->{{member}}});
return response;
}
{{/is_range}}
{{/domain_entity.filter_members}}
{{#domain_entity.has_search}}
if (request.filter && request.filter->search && request.filter->search->size() > 256) {
response.result =
refuse(outcome_code::filter_too_large, {.field = "search", .limit = "256"});
return response;
}
{{/domain_entity.has_search}}
{{/has_filter}}
{{#domain_entity.list_default_filter}}
// A list narrows to {{column}} unless the caller states one, so the default view is
// the common case and the others are asked for by name. A caller that named the
// rows it wants has already been specific, and narrowing a lookup by a default
// would answer nothing about a row that exists.
auto filter = request.filter.value_or({{domain_entity.filter_type}}{});
if (!filter.{{column}}{{#domain_entity.filter_members}}{{#is_key}}{{#is_one_of}} && !filter.{{member}}{{/is_one_of}}{{/is_key}}{{/domain_entity.filter_members}})
filter.{{column}} = {{{expression}}};
{{/domain_entity.list_default_filter}}
{{#domain_entity.has_as_of_lookup}}
// A stated instant is checked here, so a malformed one is the caller's
// mistake rather than a database error. The caller's text is what the
// store reads, so a fraction of a second is kept.
std::optional<std::string> as_of;
if (request.as_of) {
as_of = ores::database::repository::parse_as_of(*request.as_of);
if (!as_of) {
response.result =
refuse(outcome_code::as_of_invalid, {.value = *request.as_of});
return response;
}
}
response.{{entity_plural_short}} = repo_.read_latest(
ctx_, request.offset, request.limit, {{#has_order}}request.order{{/has_order}}{{^has_order}}{}{{/has_order}}{{#domain_entity.has_filter_record}}, {{#has_filter}}{{#domain_entity.list_default_filter}}filter{{/domain_entity.list_default_filter}}{{^domain_entity.list_default_filter}}request.filter{{/domain_entity.list_default_filter}}{{/has_filter}}{{^has_filter}}std::nullopt{{/has_filter}}{{/domain_entity.has_filter_record}}, as_of);
response.total = repo_.get_total_{{entity_singular_short}}_count(
ctx_{{#domain_entity.has_filter_record}}, {{#has_filter}}{{#domain_entity.list_default_filter}}filter{{/domain_entity.list_default_filter}}{{^domain_entity.list_default_filter}}request.filter{{/domain_entity.list_default_filter}}{{/has_filter}}{{^has_filter}}std::nullopt{{/has_filter}}{{/domain_entity.has_filter_record}}, as_of);
{{/domain_entity.has_as_of_lookup}}
{{^domain_entity.has_as_of_lookup}}
response.{{entity_plural_short}} = repo_.read_latest(
ctx_, request.offset, request.limit{{#has_order}}, request.order{{/has_order}}{{#has_filter}}{{^has_order}}, {}{{/has_order}}, {{#domain_entity.list_default_filter}}filter{{/domain_entity.list_default_filter}}{{^domain_entity.list_default_filter}}request.filter{{/domain_entity.list_default_filter}}{{/has_filter}});
response.total = repo_.get_total_{{entity_singular_short}}_count(ctx_{{#has_filter}}{{#domain_entity.list_default_filter}}, filter{{/domain_entity.list_default_filter}}{{^domain_entity.list_default_filter}}, request.filter{{/domain_entity.list_default_filter}}{{/has_filter}});
{{/domain_entity.has_as_of_lookup}}
return response;
}
{{/list_operations}}
{{#list_scoped_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
{{#has_order}}
if (!request.order.field.empty() &&
!repository::{{entity_singular}}_repository::is_sortable(request.order.field)) {
response.result = refuse(outcome_code::order_not_supported,
{.entity = "{{entity_plural_words}}",
.field = request.order.field});
return response;
}
{{/has_order}}
{{#has_filter}}
{{#domain_entity.filter_members}}
{{#is_one_of}}
if (request.filter && request.filter->{{member}} &&
request.filter->{{member}}->size() > 1000) {
response.result = refuse(outcome_code::filter_too_large,
{.field = "{{member}}", .limit = "1000"});
return response;
}
{{/is_one_of}}
{{#is_range}}
if (request.filter && request.filter->{{member}} &&
!ores::database::repository::range_bound(
*request.filter->{{member}},
ores::database::repository::range_end::{{#is_from}}from{{/is_from}}{{#is_to}}to{{/is_to}},
{{#is_timestamp}}true{{/is_timestamp}}{{^is_timestamp}}false{{/is_timestamp}})) {
response.result = refuse(outcome_code::filter_range_invalid,
{.field = "{{member}}", .value = *request.filter->{{member}}});
return response;
}
{{/is_range}}
{{/domain_entity.filter_members}}
{{#domain_entity.has_search}}
if (request.filter && request.filter->search && request.filter->search->size() > 256) {
response.result =
refuse(outcome_code::filter_too_large, {.field = "search", .limit = "256"});
return response;
}
{{/domain_entity.has_search}}
{{/has_filter}}
if (request.scope == ores::utility::domain::scope::subtree) {
response.result = refuse(outcome_code::scope_not_supported,
{.entity = "{{entity_plural_words}}"});
return response;
}
{{#leading_is_optional}}
// A read scoped by a relation the request may leave unstated has no scope
// when it does, so it is refused rather than answered with every unset row.
if (!request.{{leading}}) {
response.result = refuse(outcome_code::relation_required,
{.entity = "{{entity_plural_words}}", .field = "{{leading}}"});
return response;
}
{{#leading_is_uuid}}
const auto relation = boost::uuids::to_string(*request.{{leading}});
{{/leading_is_uuid}}
{{^leading_is_uuid}}
const auto relation = *request.{{leading}};
{{/leading_is_uuid}}
{{/leading_is_optional}}
{{^leading_is_optional}}
{{#leading_is_uuid}}
const auto relation = boost::uuids::to_string(request.{{leading}});
{{/leading_is_uuid}}
{{^leading_is_uuid}}
const auto relation = request.{{leading}};
{{/leading_is_uuid}}
{{/leading_is_optional}}
response.{{entity_plural_short}} = repo_.read_latest_by_{{leading}}(
ctx_, relation, request.offset, request.limit{{#has_order}}, request.order{{/has_order}}{{#has_filter}}{{^has_order}}, {}{{/has_order}}, request.filter{{/has_filter}});
response.total = repo_.get_total_{{entity_singular_short}}_count_by_{{leading}}(
ctx_, relation{{#has_filter}}, request.filter{{/has_filter}});
return response;
}
{{/list_scoped_operations}}
{{#get_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
auto found = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, request.key);
if (found.empty()) {
response.result = refuse(outcome_code::not_found, {.entity = "{{entity_singular}}"});
return response;
}
response.{{payload_member}} = std::move(found.front());
return response;
}
{{/get_operations}}
{{#get_many_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
// One entry per requested key, in the order asked for, so the reply is
// positional and a caller reads absence from an empty entry rather than
// from a missing one.
response.entries.reserve(request.keys.size());
{{#service_batch_reads_by_trade_id}}
// One read for the whole key set. The store returns every row the keys
// name, and the reply joins them back to the order asked for, so a caller
// sees the same positional answer without a round trip per key. The set is
// capped for the reason the list filter's is: one statement carries every
// key, and the store's bind count is finite.
if (request.keys.size() > 1000) {
response.result = refuse(outcome_code::filter_too_large,
{.field = "keys", .limit = "1000"});
return response;
}
std::vector<std::string> keys;
keys.reserve(request.keys.size());
for (const auto& k : request.keys)
keys.push_back(boost::uuids::to_string(k.trade_id));
const auto rows = repo_.read_latest(ctx_, keys);
for (const auto& k : request.keys) {
messaging::{{entity_singular}}_lookup entry;
entry.key = k;
for (const auto& row : rows) {
if (row.identity.trade_id == k.trade_id) {
entry.{{entity_singular}} = row;
break;
}
}
response.entries.push_back(std::move(entry));
}
{{/service_batch_reads_by_trade_id}}
{{^service_batch_reads_by_trade_id}}
for (const auto& k : request.keys) {
messaging::{{entity_singular}}_lookup entry;
entry.key = k;
auto found = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, k);
if (!found.empty())
entry.{{entity_singular}} = std::move(found.front());
response.entries.push_back(std::move(entry));
}
{{/service_batch_reads_by_trade_id}}
return response;
}
{{/get_many_operations}}
{{#put_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
domain::{{entity_singular}} value;
response.result = prepare_change(request.change, request.intent, value);
if (response.result.outcome != ores::utility::domain::outcome::ok)
return response;
repo_.write(ctx_, value, request.change.precondition);
<<paste:71B64FF3-6991-49F1-BCD4-41115B1E03B2>>
auto written = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(value));
if (!written.empty())
response.{{payload_member}} = std::move(written.front());
return response;
}
{{/put_operations}}
{{#put_many_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
std::vector<domain::{{entity_singular}}> batch;
batch.reserve(request.changes.size());
for (const auto& change : request.changes) {
domain::{{entity_singular}} value;
const auto result = prepare_change(change, request.intent, value);
if (result.outcome != ores::utility::domain::outcome::ok) {
// Nothing has been written: the whole set is checked before any
// of it lands, so a refused element refuses the batch.
response.result = result;
return response;
}
batch.push_back(std::move(value));
}
// One statement, so the set lands together. The store checks each row's
// claim inside that statement, which is what makes the check above and the
// write one decision rather than two.
std::vector<ores::utility::domain::precondition> claims;
claims.reserve(request.changes.size());
for (const auto& change : request.changes)
claims.push_back(change.precondition);
repo_.write(ctx_, batch, claims);
<<paste:C379A38A-666B-4130-BE6C-419F00081322>>
response.{{entity_plural_short}}.reserve(batch.size());
{{#service_batch_reads_by_trade_id}}
// One read for the whole written set, for the same reason as the batch
// get: the store already holds every row the write just landed.
std::vector<std::string> written_keys;
written_keys.reserve(batch.size());
for (const auto& value : batch)
written_keys.push_back(boost::uuids::to_string(value.identity.trade_id));
const auto written_rows = repo_.read_latest(ctx_, written_keys);
for (const auto& value : batch) {
const auto row = std::find_if(written_rows.begin(), written_rows.end(),
[&](const auto& candidate) {
return candidate.identity.trade_id ==
value.identity.trade_id;
});
response.{{entity_plural_short}}.push_back(row == written_rows.end() ? value : *row);
}
{{/service_batch_reads_by_trade_id}}
{{^service_batch_reads_by_trade_id}}
for (const auto& value : batch) {
auto written = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(value));
response.{{entity_plural_short}}.push_back(
written.empty() ? value : std::move(written.front()));
}
{{/service_batch_reads_by_trade_id}}
return response;
}
{{/put_many_operations}}
{{#delete_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
using ores::utility::domain::outcome;
using ores::utility::domain::precondition_kind;
if (request.removal.precondition.kind == precondition_kind::must_not_exist) {
response.result = refuse(outcome_code::precondition_not_supported);
return response;
}
std::optional<std::uint32_t> expected;
if (request.removal.precondition.kind == precondition_kind::must_match_version) {
if (!request.removal.precondition.version) {
response.result = refuse(outcome_code::precondition_incomplete);
return response;
}
expected = request.removal.precondition.version;
}
{{#gated}}
if (const auto refused = gate_delete({{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, request.removal.key), expected)) {
response.result = *refused;
return response;
}
{{/gated}}
{{^key_is_primary}}
const auto named = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, request.removal.key);
if (named.empty()) {
response.result = refuse(outcome_code::not_found, {.entity = "{{entity_singular}}"});
return response;
}
const auto& row = named.front();
{{/key_is_primary}}
switch (repo_.remove(ctx_, {{#key_is_primary}}{{{primary_key.removal_key_args}}}{{/key_is_primary}}{{^key_is_primary}}{{{primary_key.row_args}}}{{/key_is_primary}}, expected)) {
case repository::{{entity_singular}}_repository::remove_status::removed:
break;
case repository::{{entity_singular}}_repository::remove_status::missing:
response.result = refuse(outcome_code::not_found, {.entity = "{{entity_singular}}"});
break;
case repository::{{entity_singular}}_repository::remove_status::conflicting: {
{{#domain_entity.has_audit_columns}}
// A conflicting removal states the version it expected but not the one
// the row now holds, and the sentence wants both. The row is read only
// on the refusal path.
const auto live = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, request.removal.key);
response.result = refuse(outcome_code::version_conflict,
{.entity = "{{entity_singular}}",
.field = "{{domain_entity.primary_key.column}}",
.expected = expected ? std::to_string(*expected) : std::string{},
.current =
live.empty()
? std::string{}
: std::to_string(
live.front().{{domain_entity.scaffold_prefix}}version)});
{{/domain_entity.has_audit_columns}}
{{^domain_entity.has_audit_columns}}
// A resource that keeps no version cannot reach this status, so there
// is no current version for its sentence to state.
response.result = refuse(outcome_code::version_conflict,
{.entity = "{{entity_singular}}",
.field = "{{domain_entity.primary_key.column}}",
.expected = expected ? std::to_string(*expected) : std::string{}});
{{/domain_entity.has_audit_columns}}
break;
}
case repository::{{entity_singular}}_repository::remove_status::unsupported:
response.result = refuse(outcome_code::precondition_not_supported);
break;
}
return response;
}
{{/delete_operations}}
{{#delete_many_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
using ores::utility::domain::outcome;
using ores::utility::domain::precondition_kind;
for (const auto& removal : request.removals) {
if (removal.precondition.kind != precondition_kind::any) {
// The store removes a set in one statement, which carries no
// per-row version. Refusing is the only answer that keeps the
// batch atomic: serving it as a sequence of single removals would
// leave a partial batch behind as soon as one row had moved on.
response.result = refuse(outcome_code::batch_removal_is_unconditional);
return response;
}
}
if (request.removals.empty())
return response;
{{#gated}}
// A batch carries no versions, so an approved line that claims one never
// matches here. The applier removes one row at a time, with its version.
for (const auto& removal : request.removals) {
if (const auto refused = gate_delete({{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, removal.key), std::nullopt)) {
response.result = *refused;
return response;
}
}
{{/gated}}
{{^key_is_primary}}
// A removal names its row by the key a caller holds, and the repository
// takes the storage key, so the two are joined once here rather than at
// each column's conversion. A name that matches no row is skipped: the
// batch reports what it removed, and a row that is already gone is not a
// failure.
std::vector<domain::{{entity_singular}}> resolved;
resolved.reserve(request.removals.size());
for (const auto& removal : request.removals) {
auto named = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, removal.key);
if (!named.empty())
resolved.push_back(std::move(named.front()));
}
{{/key_is_primary}}
{{#primary_key.key_columns}}
std::vector<std::string> {{column}}_keys;
{{#key_is_primary}}
{{column}}_keys.reserve(request.removals.size());
for (const auto& removal : request.removals)
{{column}}_keys.push_back({{{removal_key_text}}});
{{/key_is_primary}}
{{^key_is_primary}}
{{column}}_keys.reserve(resolved.size());
for (const auto& row : resolved)
{{column}}_keys.push_back({{{row_text}}});
{{/key_is_primary}}
{{/primary_key.key_columns}}
repo_.remove(ctx_, {{{primary_key.batch_keys_args}}});
return response;
}
{{/delete_many_operations}}
{{#list_versions_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
{{#has_order}}
if (!request.order.field.empty() || request.order.descending) {
response.result = refuse(outcome_code::order_not_supported,
{.entity = "{{entity_plural_words}}",
.field = request.order.field});
return response;
}
{{/has_order}}
{{#has_filter}}
if (request.filter) {
response.result = refuse(outcome_code::filter_not_supported,
{.entity = "{{entity_plural_words}}"});
return response;
}
{{/has_filter}}
{{^key_is_primary}}
// The versions of the row the caller's key names. The repository reads by
// the storage key, so the declared key is resolved once here.
const auto named = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, request.key);
if (named.empty()) {
response.result = refuse(outcome_code::not_found, {.entity = "{{entity_singular}}"});
return response;
}
const auto& row = named.front();
{{/key_is_primary}}
auto all = repo_.read_all(ctx_, {{#key_is_primary}}{{{primary_key.key_request_args}}}{{/key_is_primary}}{{^key_is_primary}}{{{primary_key.row_args}}}{{/key_is_primary}});
// The store reads versions newest first, and the order a caller gets when
// it states none is key order, which for a version key is oldest first.
std::reverse(all.begin(), all.end());
response.total = all.size();
const auto begin = std::min<std::size_t>(request.offset, all.size());
const auto end = std::min<std::size_t>(begin + request.limit, all.size());
response.versions.assign(std::make_move_iterator(all.begin() + begin),
std::make_move_iterator(all.begin() + end));
return response;
}
{{/list_versions_operations}}
{{#get_version_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
{{^key_is_primary}}
// The version key nests the entity's own key, which is the declared one.
// The repository reads by the storage key, so it is resolved once here.
const auto named = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, request.key.{{entity_singular}});
if (named.empty()) {
response.result = refuse(outcome_code::not_found, {.entity = "{{entity_singular}}"});
return response;
}
const auto& row = named.front();
{{/key_is_primary}}
auto found = repo_.read_at_version(
ctx_, {{#key_is_primary}}{{{primary_key.version_key_args}}}{{/key_is_primary}}{{^key_is_primary}}{{{primary_key.row_args}}}{{/key_is_primary}}, request.key.version);
if (!found) {
response.result = refuse(outcome_code::not_found, {.entity = "{{entity_singular}}"});
return response;
}
response.version = std::move(*found);
return response;
}
{{/get_version_operations}}
{{#put_operations}}
ores::utility::domain::result
{{entity_singular}}_service::prepare_change(
const messaging::{{entity_singular}}_change& change,
const ores::utility::domain::change_intent& intent,
domain::{{entity_singular}}& out) {
using ores::utility::domain::outcome;
using ores::utility::domain::precondition_kind;
ores::utility::domain::result result;
out = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_to_domain(change.write);
const auto current = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(out));
{{#gated}}
// The gate comes before the claim is checked, so an unapproved caller
// learns nothing about the row from a conflict.
if (const auto refused = gate_put(current, out, change.precondition))
return *refused;
{{/gated}}
switch (change.precondition.kind) {
case precondition_kind::must_not_exist:
if (!current.empty())
return refuse(outcome_code::already_exists,
{.entity = "{{entity_singular}}",
.field = "{{domain_entity.primary_key.column}}"});
break;
case precondition_kind::must_match_version:
{{#has_audit_columns}}
if (current.empty())
return refuse(outcome_code::not_found, {.entity = "{{entity_singular}}"});
// The protocol states the version as a uint32 and the row carries it
// as an int, so the comparison states the conversion.
if (!change.precondition.version
|| static_cast<std::uint32_t>(current.front().{{domain_entity.scaffold_prefix}}version)
!= *change.precondition.version) {
return refuse(outcome_code::version_conflict,
{.entity = "{{entity_singular}}",
.field = "{{domain_entity.primary_key.column}}",
.expected = change.precondition.version
? std::to_string(*change.precondition.version)
: std::string{},
.current = std::to_string(
current.front().{{domain_entity.scaffold_prefix}}version)});
}
{{/has_audit_columns}}
{{^has_audit_columns}}
return refuse(outcome_code::precondition_not_supported);
{{/has_audit_columns}}
break;
case precondition_kind::any:
break;
}
{{#domain_entity.has_preserved_fields}}
// What the row already records about its own life is the row's to keep.
// The write carries no way to state these, so an edit that let them take
// a default would clear the stamps the service itself wrote.
if (!current.empty()) {
{{#domain_entity.preserved_fields}}
out.{{member}} = current.front().{{member}};
{{/domain_entity.preserved_fields}}
}
{{/domain_entity.has_preserved_fields}}
// The version is the repository's to state, from the claim: it is the one
// thing the store's arbiter reads, and stating it in two places is how the
// two come to disagree.
{{#domain_entity.has_stamp_members}}
// The audit reaches the row through whichever member carries it, so a
// grouped entity is stamped member by member; stamping it whole would
// find no audit column and leave the row without one.
{{/domain_entity.has_stamp_members}}
{{#domain_entity.stamp_members}}
stamp(out.{{member}}, ctx_,
intent.reason_code.empty()
? std::string(ores::service::messaging::change_reasons::new_record)
: intent.reason_code);
{{/domain_entity.stamp_members}}
{{^domain_entity.stamp_members}}
stamp(out, ctx_,
intent.reason_code.empty()
? std::string(ores::service::messaging::change_reasons::new_record)
: intent.reason_code);
{{/domain_entity.stamp_members}}
{{#has_audit_columns}}
out.{{domain_entity.audit_prefix}}change_commentary = intent.commentary;
{{/has_audit_columns}}
return result;
}
{{/put_operations}}
{{#gated}}
void {{entity_singular}}_service::carry_out_request(std::string request_id) {
pass_ = {.request_id = std::move(request_id), .rehearsal = false};
}
void {{entity_singular}}_service::rehearse() {
pass_.rehearsal = true;
}
std::vector<std::string>
{{entity_singular}}_service::changed_columns(const messaging::{{entity_singular}}_write& proposed) {
const auto next = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_to_domain(proposed);
return {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_columns_changed({{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(next)), next);
}
bool {{entity_singular}}_service::holds_line(const std::string& operation,
const boost::uuids::uuid& id,
int base_version) {
if (pass_.request_id.empty())
return false;
// One more than the most a request may hold, so a request over the limit
// is seen as over it and never read as a shorter one.
constexpr std::uint32_t max_request_lines = 1000;
const auto lines = repository::{{entity_singular}}_change_repository{}.read_latest_by_request_id(
ctx_, pass_.request_id, 0, max_request_lines + 1);
return std::ranges::any_of(lines, [&](const auto& l) {
return !l.applied && l.operation == operation && l.entity_id == id &&
l.base_version == base_version;
});
}
std::optional<ores::utility::domain::result>
{{entity_singular}}_service::gate_put(const std::vector<domain::{{entity_singular}}>& current,
const domain::{{entity_singular}}& next,
const ores::utility::domain::precondition& claim) {
using ores::service::messaging::gate_verdict;
using ores::utility::domain::precondition_kind;
if (pass_.rehearsal)
return std::nullopt;
switch (ores::service::messaging::gate_verdict_of(
ctx_, "{{entity_singular}}", "put", {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_columns_changed(current, next), pass_.request_id)) {
case gate_verdict::free:
return std::nullopt;
case gate_verdict::approved: {
// The line holds the version the maker read. A write with no claim
// names no line, so it never matches.
int base_version = -1;
if (claim.kind == precondition_kind::must_not_exist)
base_version = 0;
else if (claim.kind == precondition_kind::must_match_version && claim.version)
base_version = static_cast<int>(*claim.version);
if (holds_line("put", next.{{primary_key.column}}, base_version))
return std::nullopt;
break;
}
case gate_verdict::required:
break;
}
return refuse(outcome_code::approval_required, {.entity = "{{entity_singular}}"});
}
std::optional<ores::utility::domain::result>
{{entity_singular}}_service::gate_delete(const std::vector<domain::{{entity_singular}}>& live,
std::optional<std::uint32_t> expected) {
using ores::service::messaging::gate_verdict;
// A row that is not there has nothing to protect: the removal answers
// that it is missing.
if (pass_.rehearsal || live.empty())
return std::nullopt;
switch (ores::service::messaging::gate_verdict_of(
ctx_, "{{entity_singular}}", "delete", {}, pass_.request_id)) {
case gate_verdict::free:
return std::nullopt;
case gate_verdict::approved:
if (holds_line("delete", live.front().{{primary_key.column}}, static_cast<int>(expected.value_or(0))))
return std::nullopt;
break;
case gate_verdict::required:
break;
}
return refuse(outcome_code::approval_required, {.entity = "{{entity_singular}}"});
}
{{/gated}}
{{/protocol_derived}}
std::vector<domain::{{entity_singular}}> {{entity_singular}}_service::list_{{entity_plural_short}}(std::uint32_t offset, std::uint32_t limit) {
BOOST_LOG_SEV(lg(), debug) << "Listing all {{entity_plural_words}}";
return repo_.read_latest(ctx_, offset, limit);
}
{{#list_filter_column}}
std::vector<domain::{{entity_singular}}> {{entity_singular}}_service::list_{{entity_plural_short}}(
std::uint32_t offset, std::uint32_t limit, const std::string& {{list_filter_column}}) {
BOOST_LOG_SEV(lg(), debug) << "Listing {{entity_plural_words}} by {{list_filter_column}}"
<< " offset=" << offset << ", limit=" << limit;
return repo_.read_latest_for_{{list_filter_column}}(ctx_, offset, limit, {{list_filter_column}});
}
std::uint32_t {{entity_singular}}_service::count_{{entity_plural_short}}(
const std::string& {{list_filter_column}}) {
BOOST_LOG_SEV(lg(), debug) << "Counting {{entity_plural_words}} by {{list_filter_column}}";
return repo_.count_latest_for_{{list_filter_column}}(ctx_, {{list_filter_column}});
}
{{/list_filter_column}}
std::uint32_t {{entity_singular}}_service::count_{{entity_plural_short}}() {
BOOST_LOG_SEV(lg(), debug) << "Getting total {{entity_plural_words}} count";
return repo_.get_total_{{entity_singular_short}}_count(ctx_);
}
{{#foreign_keys}}
{{#list_by}}
std::vector<domain::{{entity_singular}}>
{{entity_singular}}_service::list_{{entity_plural_short}}_by_{{column}}(const std::string& {{column}},
std::uint32_t offset, std::uint32_t limit) {
BOOST_LOG_SEV(lg(), debug) << "Listing {{entity_plural_words}} by {{column}}: " << {{column}};
return repo_.read_latest_by_{{column}}(ctx_, {{column}}, offset, limit);
}
std::uint32_t {{entity_singular}}_service::count_{{entity_plural_short}}_by_{{column}}(const std::string& {{column}}) {
BOOST_LOG_SEV(lg(), debug) << "Getting total {{entity_plural_words}} count by {{column}}: " << {{column}};
return repo_.get_total_{{entity_singular_short}}_count_by_{{column}}(ctx_, {{column}});
}
{{#list_by_uuid}}
std::vector<domain::{{entity_singular}}>
{{entity_singular}}_service::list_{{entity_plural_short}}_by_{{column}}(const boost::uuids::uuid& {{column}},
std::uint32_t offset, std::uint32_t limit) {
BOOST_LOG_SEV(lg(), debug) << "Listing {{entity_plural_words}} by {{column}}: " << {{column}};
return repo_.read_latest_by_{{column}}(ctx_, boost::uuids::to_string({{column}}), offset, limit);
}
std::uint32_t {{entity_singular}}_service::count_{{entity_plural_short}}_by_{{column}}(const boost::uuids::uuid& {{column}}) {
BOOST_LOG_SEV(lg(), debug) << "Getting total {{entity_plural_words}} count by {{column}}: " << {{column}};
return repo_.get_total_{{entity_singular_short}}_count_by_{{column}}(ctx_, boost::uuids::to_string({{column}}));
}
{{/list_by_uuid}}
{{/list_by}}
{{#list_by_as_of}}
std::vector<domain::{{entity_singular}}>
{{entity_singular}}_service::list_{{entity_plural_short}}_by_{{column}}_as_of(
const std::string& {{column}},
std::chrono::system_clock::time_point valid_from_bound,
std::chrono::system_clock::time_point valid_to_bound) {
BOOST_LOG_SEV(lg(), debug) << "Listing {{entity_plural_words}} by {{column}} as of window: " << {{column}};
return repo_.read_by_{{column}}_as_of(ctx_, {{column}}, valid_from_bound, valid_to_bound);
}
{{/list_by_as_of}}
{{/foreign_keys}}
{{#has_audit_columns}}
std::optional<domain::{{entity_singular}}>
{{entity_singular}}_service::get_{{entity_singular_short}}_at_version({{{primary_key.typed_params}}},
std::uint32_t version) {
BOOST_LOG_SEV(lg(), debug) << "Getting {{entity_singular_words}} at version. " << {{{primary_key.log_fields}}}
<< " version: " << version;
return repo_.read_at_version(ctx_, {{{primary_key.typed_args}}}, version);
}
{{/has_audit_columns}}
std::optional<domain::{{entity_singular}}>
{{entity_singular}}_service::{{#service_find_prefix}}find_{{/service_find_prefix}}{{^service_find_prefix}}get_{{/service_find_prefix}}{{entity_singular_short}}({{{primary_key.typed_params}}}) {
BOOST_LOG_SEV(lg(), debug) << "{{#service_find_prefix}}Finding{{/service_find_prefix}}{{^service_find_prefix}}Getting{{/service_find_prefix}} {{entity_singular_words}}. " << {{{primary_key.log_fields}}};
auto results = repo_.read_latest(ctx_, {{{primary_key.typed_args}}});
if (results.empty()) return std::nullopt;
return results.front();
}
{{^key_is_primary}}
std::optional<domain::{{entity_singular}}>
{{entity_singular}}_service::get_{{entity_singular_short}}_by_{{declared_key}}(const std::string& {{declared_key}}) {
BOOST_LOG_SEV(lg(), debug) << "Getting {{entity_singular_words}} by {{declared_key}}: " << {{declared_key}};
messaging::{{entity_singular}}_key k;
{{#declared_key_is_uuid}}
k.{{declared_key}} = boost::lexical_cast<boost::uuids::uuid>({{declared_key}});
{{/declared_key_is_uuid}}
{{^declared_key_is_uuid}}
k.{{declared_key}} = {{declared_key}};
{{/declared_key_is_uuid}}
auto found = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, k);
if (found.empty())
return std::nullopt;
return found.front();
}
{{/key_is_primary}}
{{#service_find_by_uuid}}
std::optional<domain::{{entity_singular}}>
{{entity_singular}}_service::find_{{entity_singular_short}}(const boost::uuids::uuid& {{primary_key.column}}) {
BOOST_LOG_SEV(lg(), debug) << "Finding {{entity_singular_words}}. " << "{{primary_key.column}}: " << {{primary_key.column}};
auto results = repo_.read_latest(ctx_, boost::uuids::to_string({{primary_key.column}}));
if (results.empty()) return std::nullopt;
return results.front();
}
{{/service_find_by_uuid}}
{{#service_find_by_code}}
std::optional<domain::{{entity_singular}}>
{{entity_singular}}_service::find_{{entity_singular_short}}_by_code({{{service_params}}}) {
BOOST_LOG_SEV(lg(), debug) << "Finding {{entity_singular_words}} by {{log_label}}: "
<< {{{log_fields}}};
auto results = repo_.read_latest_by_code(ctx_, {{{service_call_args}}});
if (results.empty()) return std::nullopt;
return results.front();
}
{{/service_find_by_code}}
std::vector<domain::{{entity_singular}}>
{{entity_singular}}_service::get_{{entity_plural_short}}({{{primary_key.batch_params}}}) {
return repo_.read_latest(ctx_, {{{primary_key.batch_args}}});
}
void {{entity_singular}}_service::save_{{entity_singular_short}}(const domain::{{entity_singular}}& {{^gated}}v{{/gated}}) {
{{#gated}}
{{entity_singular}}_refuse_direct_write();
{{/gated}}
{{^gated}}
{{#primary_key.columns}}
{{^is_int}}
{{^is_timestamp}}
{{^is_date}}
if (v.{{{group_prefix}}}{{column}}.{{#is_uuid}}is_nil(){{/is_uuid}}{{^is_uuid}}empty(){{/is_uuid}})
throw std::invalid_argument("{{entity_title}} {{column}} cannot be empty.");
{{/is_date}}
{{/is_timestamp}}
{{/is_int}}
{{/primary_key.columns}}
BOOST_LOG_SEV(lg(), debug) << "Saving {{entity_singular_words}}. " << {{{primary_key.value_log_fields}}};
auto t = v;
{{#has_domain_groups}}
{{#domain_entity.stamp_members}}
stamp(t.{{member}}, ctx_);
{{/domain_entity.stamp_members}}
{{/has_domain_groups}}
{{^has_domain_groups}}
stamp(t, ctx_);
{{/has_domain_groups}}
repo_.write(ctx_, t);
BOOST_LOG_SEV(lg(), info) << "Saved {{entity_singular_words}}. " << {{{primary_key.value_log_fields}}};
{{/gated}}
}
void {{entity_singular}}_service::save_{{entity_plural_short}}(
const std::vector<domain::{{entity_singular}}>& {{^gated}}{{entity_plural_short}}{{/gated}}) {
{{#gated}}
{{entity_singular}}_refuse_direct_write();
{{/gated}}
{{^gated}}
for (const auto& e : {{entity_plural_short}}) {
{{#primary_key.columns}}
{{^is_int}}
{{^is_timestamp}}
{{^is_date}}
if (e.{{{group_prefix}}}{{column}}.{{#is_uuid}}is_nil(){{/is_uuid}}{{^is_uuid}}empty(){{/is_uuid}})
throw std::invalid_argument("{{entity_title}} {{column}} cannot be empty.");
{{/is_date}}
{{/is_timestamp}}
{{/is_int}}
{{/primary_key.columns}}
}
BOOST_LOG_SEV(lg(), debug) << "Saving " << {{entity_plural_short}}.size()
<< " {{entity_plural_words}}";
auto ts = {{entity_plural_short}};
for (auto& e : ts) {
{{#has_domain_groups}}
{{#domain_entity.stamp_members}}
stamp(e.{{member}}, ctx_);
{{/domain_entity.stamp_members}}
{{/has_domain_groups}}
{{^has_domain_groups}}
stamp(e, ctx_);
{{/has_domain_groups}}
}
repo_.write(ctx_, ts);
{{/gated}}
}
void {{entity_singular}}_service::delete_{{entity_singular_short}}({{#gated}}{{{primary_key.typed_params_unnamed}}}{{/gated}}{{^gated}}{{{primary_key.typed_params}}}{{/gated}}) {
{{#gated}}
{{entity_singular}}_refuse_direct_write();
{{/gated}}
{{^gated}}
BOOST_LOG_SEV(lg(), debug) << "Removing {{entity_singular_words}}. " << {{{primary_key.log_fields}}};
repo_.remove(ctx_, {{{primary_key.typed_args}}});
BOOST_LOG_SEV(lg(), info) << "Removed {{entity_singular_words}}. " << {{{primary_key.log_fields}}};
{{/gated}}
}
{{#service_find_by_uuid}}
void {{entity_singular}}_service::remove_{{entity_singular_short}}(const boost::uuids::uuid& {{^gated}}{{primary_key.column}}{{/gated}}) {
{{#gated}}
{{entity_singular}}_refuse_direct_write();
{{/gated}}
{{^gated}}
BOOST_LOG_SEV(lg(), debug) << "Removing {{entity_singular_words}}. " << "{{primary_key.column}}: " << {{primary_key.column}};
repo_.remove(ctx_, boost::uuids::to_string({{primary_key.column}}));
BOOST_LOG_SEV(lg(), info) << "Removed {{entity_singular_words}}. " << "{{primary_key.column}}: " << {{primary_key.column}};
{{/gated}}
}
{{/service_find_by_uuid}}
void {{entity_singular}}_service::delete_{{entity_plural_short}}({{#gated}}{{{primary_key.batch_params_unnamed}}}{{/gated}}{{^gated}}{{{primary_key.batch_params}}}{{/gated}}) {
{{#gated}}
{{entity_singular}}_refuse_direct_write();
{{/gated}}
{{^gated}}
repo_.remove(ctx_, {{{primary_key.batch_args}}});
{{/gated}}
}
{{^current_state}}
{{#key_is_primary}}
std::vector<domain::{{entity_singular}}>
{{entity_singular}}_service::get_{{entity_singular_short}}_history({{{primary_key.params}}}) {
BOOST_LOG_SEV(lg(), debug) << "Getting history for {{entity_singular_words}}. " << {{{primary_key.log_fields}}};
return repo_.read_all(ctx_, {{{primary_key.args}}});
}
{{/key_is_primary}}
{{^key_is_primary}}
std::vector<domain::{{entity_singular}}>
{{entity_singular}}_service::get_{{entity_singular_short}}_history(const std::string& key) {
BOOST_LOG_SEV(lg(), debug) << "Getting history for {{entity_singular_words}}. key: " << key;
// The caller holds the key the model declares and this reads by the
// storage key, so the two are joined here exactly as they are for any
// other read. Without this step a provider looks the versions up under a
// value the storage key never holds, and reports an entity that has a
// history as having none.
messaging::{{entity_singular}}_key k;
{{#declared_key_is_uuid}}
k.{{declared_key}} = boost::lexical_cast<boost::uuids::uuid>(key);
{{/declared_key_is_uuid}}
{{^declared_key_is_uuid}}
k.{{declared_key}} = key;
{{/declared_key_is_uuid}}
{{#current_state}}
// A current-state table deletes for real, so a row that is gone has no
// history to read and resolving through the latest row is correct.
const auto found = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, ctx_, k);
{{/current_state}}
{{^current_state}}
// A delete here closes the transaction-time window and leaves every version
// in place, so resolving through a latest read would lose the history at
// exactly the moment it is wanted. This takes the newest row carrying the
// declared key whether or not it is still current, which for a record that
// still exists is the same row the latest read would have returned.
{{#declared_key_is_uuid}}
const auto found =
repo_.read_any_by_{{declared_key}}(ctx_, boost::uuids::to_string(k.{{declared_key}}));
{{/declared_key_is_uuid}}
{{^declared_key_is_uuid}}
const auto found = repo_.read_any_by_{{declared_key}}(ctx_, k.{{declared_key}});
{{/declared_key_is_uuid}}
{{/current_state}}
if (found.empty())
return {};
const auto& row = found.front();
return repo_.read_all(ctx_, {{{primary_key.row_args}}});
}
{{/key_is_primary}}
{{#service_find_by_uuid}}
std::vector<domain::{{entity_singular}}>
{{entity_singular}}_service::get_{{entity_singular_short}}_history(const boost::uuids::uuid& {{primary_key.column}}) {
BOOST_LOG_SEV(lg(), debug) << "Getting history for {{entity_singular_words}}. " << "{{primary_key.column}}: " << {{primary_key.column}};
return repo_.read_all(ctx_, boost::uuids::to_string({{primary_key.column}}));
}
{{/service_find_by_uuid}}
{{/current_state}}
{{#has_parent_id}}
std::vector<ores::utility::domain::hierarchy_node>
{{entity_singular}}_service::get_hierarchy(const boost::uuids::uuid& root_id, bool from_root) {
BOOST_LOG_SEV(lg(), debug) << "Getting hierarchy for {{entity_singular}} root: " << root_id;
auto rows = repo_.get_hierarchy(ctx_, root_id, from_root);
return ores::utility::domain::build_tree(rows);
}
{{/has_parent_id}}
<<paste:D3E7A5F2-8B4C-4D1E-9A6F-2E1C7B4D8A3F>>
}
{{/domain_entity}}
{{#junction}}
{{! The response's collection member is the junction's full name, not
:name_short:. A junction's protocol builds that member from
entity_plural_short, which org_loader sets to the name for a junction,
so reading name_short here emits a member the response does not have
whenever the two differ -- currency_currency_groups, members and
resolutions all do. The repository's own name_short parameter is a
different thing and keeps its meaning. }}
#include "ores.{{component_core}}/service/{{name_singular}}_service.hpp"
#include "ores.{{component_include}}/domain/{{name_singular}}.hpp"
#include "ores.{{component_include}}/messaging/{{name_singular}}_protocol.hpp"
{{#service_uses_cstdint}}
#include <cstdint>
{{/service_uses_cstdint}}
#include <optional>
#include <string>
#include <utility>
#include <vector>
{{#has_uuid_left_or_right}}
// Log lines stream uuids with uuid_io's operator<<, which the include check
// does not count as a use.
#include <boost/uuid/uuid_io.hpp> // IWYU pragma: keep.
{{/has_uuid_left_or_right}}
{{#party_id_is_target}}
#include "ores.database/domain/context.hpp"
{{/party_id_is_target}}
#include "ores.service/messaging/handler_helpers.hpp"
#include "ores.utility/domain/protocol.hpp"
#include "ores.database/domain/outcome_code.hpp"
{{#gated}}
#include "ores.{{component_core}}/repository/{{name_singular}}_change_repository.hpp"
#include "ores.service/messaging/approval_gate.hpp"
#include <algorithm>
{{/gated}}
namespace ores::{{component}}::service {
using namespace ores::logging;
using ores::service::messaging::stamp;
// Every refusal names its outcome; the catalogue supplies the code and the
// sentence, so a service states what happened and nothing about how to say it.
using ores::database::domain::outcome_args;
using ores::database::domain::outcome_code;
using ores::database::domain::refuse;
{{#party_id_is_target}}
namespace {
/**
* @brief Stamps a {{name_singular_words}} row without touching its party_id.
*
* This junction's party_id names the association's target, which the
* client supplies, not the caller's own scope. The generic stamp()
* matches any field of that name by reflection and overwrites it with
* the caller's current party, which would silently replace the requested
* association. Mirrors ores.iam's stamp_account_party.
*/
void stamp_{{name_singular}}(domain::{{name_singular}}& row,
const ores::database::context& ctx) {
row.tenant_id = ctx.tenant_id().to_string();
const auto& actor = ctx.actor();
const auto& svc = ctx.service_account();
if (!actor.empty())
row.modified_by = actor;
else if (!svc.empty())
row.modified_by = svc;
if (!svc.empty())
row.performed_by = svc;
if (row.change_reason_code.empty())
row.change_reason_code =
std::string(ores::service::messaging::change_reasons::new_record);
}
}
{{/party_id_is_target}}
{{name_singular}}_service::{{name_singular}}_service(context ctx)
: ctx_(std::move(ctx)), repo_(ctx_) {}
{{#protocol_derived}}
namespace {
/**
* @brief The current row a key names, or an empty vector when there is none.
*
* A junction key is the pair of columns that names one link, and the
* repository takes each in its own column's type, so the pair passes
* straight through.
*/
std::vector<domain::{{name_singular}}> {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(
repository::{{name_singular}}_repository& repo,
const messaging::{{name_singular}}_key& key) {
return repo.read_latest(key.{{left.column}}, key.{{right.column}});
}
{{#put_operations}}
/**
* @brief The key a domain object states, so a written row can be read back.
*
* A create states its own key in the write record, so the key of the row a
* write produced is the one the object carries.
*/
messaging::{{name_singular}}_key {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(const domain::{{name_singular}}& v) {
messaging::{{name_singular}}_key key;
key.{{left.column}} = v.{{left.column}};
key.{{right.column}} = v.{{right.column}};
return key;
}
/**
* @brief Builds the domain object a write record states.
*
* The record carries the two halves of the key and the junction's own
* columns and nothing else: tenancy, provenance, the version and the
* validity window are the service's and the database's to state, and are
* set after this conversion.
*/
domain::{{name_singular}} {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_to_domain(const messaging::{{name_singular}}_write& write) {
domain::{{name_singular}} v;
v.{{left.column}} = write.{{left.column}};
v.{{right.column}} = write.{{right.column}};
{{#columns}}
v.{{name}} = write.{{name}};
{{/columns}}
return v;
}
{{/put_operations}}
}
{{#list_operations}}
messaging::{{response}}
{{name_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
{{#has_order}}
if (!request.order.field.empty() || request.order.descending) {
response.result = refuse(outcome_code::order_not_supported,
{.entity = "{{name}}", .field = request.order.field});
return response;
}
{{/has_order}}
{{#has_filter}}
if (request.filter) {
response.result = refuse(outcome_code::filter_not_supported,
{.entity = "{{name}}"});
return response;
}
{{/has_filter}}
response.{{name}} = repo_.read_latest(request.offset, request.limit);
response.total = repo_.get_total_{{name_singular_short}}_count();
return response;
}
{{/list_operations}}
{{#left.list_by}}
messaging::list_by_{{left.column}}_{{name}}_response
{{name_singular}}_service::list_by_{{left.column}}_{{name}}(
const messaging::list_by_{{left.column}}_{{name}}_request& request) {
messaging::list_by_{{left.column}}_{{name}}_response response;
{{! The junction's paged reads always carry order and filter, and its scoped reads a scope. }}
if (!request.order.field.empty() || request.order.descending) {
response.result = refuse(outcome_code::order_not_supported,
{.entity = "{{name}}", .field = request.order.field});
return response;
}
if (request.filter) {
response.result = refuse(outcome_code::filter_not_supported,
{.entity = "{{name}}"});
return response;
}
if (request.scope == ores::utility::domain::scope::subtree) {
response.result = refuse(outcome_code::scope_not_supported,
{.entity = "{{name}}"});
return response;
}
response.{{name}} = repo_.read_latest_by_{{left.column_short}}(
request.{{left.column}}, request.offset, request.limit);
response.total =
repo_.get_total_{{name_singular_short}}_count_by_{{left.column_short}}(
request.{{left.column}});
return response;
}
{{/left.list_by}}
{{#right.list_by}}
messaging::list_by_{{right.column}}_{{name}}_response
{{name_singular}}_service::list_by_{{right.column}}_{{name}}(
const messaging::list_by_{{right.column}}_{{name}}_request& request) {
messaging::list_by_{{right.column}}_{{name}}_response response;
if (!request.order.field.empty() || request.order.descending) {
response.result = refuse(outcome_code::order_not_supported,
{.entity = "{{name}}", .field = request.order.field});
return response;
}
if (request.filter) {
response.result = refuse(outcome_code::filter_not_supported,
{.entity = "{{name}}"});
return response;
}
if (request.scope == ores::utility::domain::scope::subtree) {
response.result = refuse(outcome_code::scope_not_supported,
{.entity = "{{name}}"});
return response;
}
response.{{name}} = repo_.read_latest_by_{{right.column_short}}(
request.{{right.column}}, request.offset, request.limit);
response.total =
repo_.get_total_{{name_singular_short}}_count_by_{{right.column_short}}(
request.{{right.column}});
return response;
}
{{/right.list_by}}
{{#get_operations}}
messaging::{{response}}
{{name_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
auto found = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, request.key);
if (found.empty()) {
response.result = refuse(outcome_code::not_found, {.entity = "{{name_singular}}"});
return response;
}
response.{{name_singular}} = std::move(found.front());
return response;
}
{{/get_operations}}
{{#get_many_operations}}
messaging::{{response}}
{{name_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
// One entry per requested key, in the order asked for, so the reply is
// positional and a caller reads absence from an empty entry rather than
// from a missing one.
response.entries.reserve(request.keys.size());
for (const auto& k : request.keys) {
messaging::{{name_singular}}_lookup entry;
entry.key = k;
auto found = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, k);
if (!found.empty())
entry.{{name_singular}} = std::move(found.front());
response.entries.push_back(std::move(entry));
}
return response;
}
{{/get_many_operations}}
{{#put_operations}}
messaging::{{response}}
{{name_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
domain::{{name_singular}} value;
response.result = prepare_change(request.change, request.intent, value);
if (response.result.outcome != ores::utility::domain::outcome::ok)
return response;
repo_.write(value, request.change.precondition);
auto written = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(value));
if (!written.empty())
response.{{name_singular}} = std::move(written.front());
return response;
}
{{/put_operations}}
{{#put_many_operations}}
messaging::{{response}}
{{name_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
std::vector<domain::{{name_singular}}> batch;
batch.reserve(request.changes.size());
for (const auto& change : request.changes) {
domain::{{name_singular}} value;
const auto result = prepare_change(change, request.intent, value);
if (result.outcome != ores::utility::domain::outcome::ok) {
// Nothing has been written: the whole set is checked before any
// of it lands, so a refused element refuses the batch.
response.result = result;
return response;
}
batch.push_back(std::move(value));
}
// One statement, so the set lands together. The store checks each row's
// claim inside that statement, which is what makes the check above and the
// write one decision rather than two.
std::vector<ores::utility::domain::precondition> claims;
claims.reserve(request.changes.size());
for (const auto& change : request.changes)
claims.push_back(change.precondition);
repo_.write(batch, claims);
response.{{name}}.reserve(batch.size());
for (const auto& value : batch) {
auto written = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(value));
response.{{name}}.push_back(
written.empty() ? value : std::move(written.front()));
}
return response;
}
{{/put_many_operations}}
{{#delete_operations}}
messaging::{{response}}
{{name_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
using ores::utility::domain::outcome;
using ores::utility::domain::precondition_kind;
if (request.removal.precondition.kind == precondition_kind::must_not_exist) {
response.result = refuse(outcome_code::precondition_not_supported);
return response;
}
std::optional<std::uint32_t> expected;
if (request.removal.precondition.kind == precondition_kind::must_match_version) {
if (!request.removal.precondition.version) {
response.result = refuse(outcome_code::precondition_incomplete);
return response;
}
expected = request.removal.precondition.version;
}
{{#gated}}
if (const auto refused = gate_delete({{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, request.removal.key), expected)) {
response.result = *refused;
return response;
}
{{/gated}}
switch (repo_.remove(request.removal.key.{{left.column}},
request.removal.key.{{right.column}}, expected)) {
case repository::{{name_singular}}_repository::remove_status::removed:
break;
case repository::{{name_singular}}_repository::remove_status::missing:
response.result = refuse(outcome_code::not_found, {.entity = "{{name_singular}}"});
break;
case repository::{{name_singular}}_repository::remove_status::conflicting: {
// The junction's removal states neither version, so the row is read for
// the one it now holds. The sentence carries what the caller stated and
// what the store holds.
const auto live = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, request.removal.key);
response.result = refuse(outcome_code::version_conflict,
{.entity = "{{name_singular}}",
.field = "{{left.column}}",
.expected = expected ? std::to_string(*expected) : std::string{},
.current = live.empty()
? std::string{}
: std::to_string(live.front().version)});
break;
}
case repository::{{name_singular}}_repository::remove_status::unsupported:
response.result = refuse(outcome_code::precondition_not_supported);
break;
}
return response;
}
{{/delete_operations}}
{{#delete_many_operations}}
messaging::{{response}}
{{name_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
using ores::utility::domain::outcome;
using ores::utility::domain::precondition_kind;
for (const auto& removal : request.removals) {
if (removal.precondition.kind != precondition_kind::any) {
// The store removes a set in one statement, which carries no
// per-row version. Refusing is the only answer that keeps the
// batch atomic: serving it as a sequence of single removals would
// leave a partial batch behind as soon as one row had moved on.
response.result = refuse(outcome_code::batch_removal_is_unconditional);
return response;
}
}
if (request.removals.empty())
return response;
{{#gated}}
// A batch carries no versions, so an approved line that claims one never
// matches here. The applier removes one link at a time, with its version.
for (const auto& removal : request.removals) {
if (const auto refused = gate_delete({{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, removal.key), std::nullopt)) {
response.result = *refused;
return response;
}
}
{{/gated}}
std::vector<{{left.cpp_type}}> {{left.column}}_keys;
{{left.column}}_keys.reserve(request.removals.size());
for (const auto& removal : request.removals)
{{left.column}}_keys.push_back(removal.key.{{left.column}});
std::vector<{{right.cpp_type}}> {{right.column}}_keys;
{{right.column}}_keys.reserve(request.removals.size());
for (const auto& removal : request.removals)
{{right.column}}_keys.push_back(removal.key.{{right.column}});
repo_.remove({{left.column}}_keys, {{right.column}}_keys);
return response;
}
{{/delete_many_operations}}
{{#put_operations}}
ores::utility::domain::result
{{name_singular}}_service::prepare_change(
const messaging::{{name_singular}}_change& change,
const ores::utility::domain::change_intent& intent,
domain::{{name_singular}}& out) {
using ores::utility::domain::outcome;
using ores::utility::domain::precondition_kind;
ores::utility::domain::result result;
out = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_to_domain(change.write);
const auto current = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_read_one(repo_, {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(out));
{{#gated}}
// The gate comes before the claim is checked, so an unapproved caller
// learns nothing about the link from a conflict.
if (const auto refused = gate_put(out, change.precondition))
return *refused;
{{/gated}}
switch (change.precondition.kind) {
case precondition_kind::must_not_exist:
if (!current.empty())
return refuse(outcome_code::already_exists,
{.entity = "{{name_singular}}", .field = "{{left.column}}"});
break;
case precondition_kind::must_match_version:
if (current.empty())
return refuse(outcome_code::not_found, {.entity = "{{name_singular}}"});
// The protocol states the version as a uint32 and the row carries it
// as an int, so the comparison states the conversion.
if (!change.precondition.version
|| static_cast<std::uint32_t>(current.front().{{domain_entity.scaffold_prefix}}version)
!= *change.precondition.version) {
return refuse(outcome_code::version_conflict,
{.entity = "{{name_singular}}",
.field = "{{left.column}}",
.expected = change.precondition.version
? std::to_string(*change.precondition.version)
: std::string{},
.current = std::to_string(current.front().version)});
}
break;
case precondition_kind::any:
break;
}
// The version is the repository's to state, from the claim: it is the one
// thing the store's arbiter reads, and stating it in two places is how the
// two come to disagree.
{{#party_id_is_target}}
stamp_{{name_singular}}(out, ctx_);
// The stamp states the owning party, not why the row changed; the caller's
// reason is the only source for that, and a branch that ignores it both
// loses the reason and leaves the parameter unused.
if (!intent.reason_code.empty())
out.change_reason_code = intent.reason_code;
{{/party_id_is_target}}
{{^party_id_is_target}}
stamp(out, ctx_,
intent.reason_code.empty()
? std::string(ores::service::messaging::change_reasons::new_record)
: intent.reason_code);
{{/party_id_is_target}}
return result;
}
{{/put_operations}}
{{#gated}}
void {{name_singular}}_service::carry_out_request(std::string request_id) {
pass_ = {.request_id = std::move(request_id), .rehearsal = false};
}
void {{name_singular}}_service::rehearse() {
pass_.rehearsal = true;
}
bool {{name_singular}}_service::holds_line(const std::string& operation,
const {{left.cpp_type}}& {{left.column}},
const {{right.cpp_type}}& {{right.column}},
int base_version) {
if (pass_.request_id.empty())
return false;
// One more than the most a request may hold, so a request over the limit
// is seen as over it and never read as a shorter one.
constexpr std::uint32_t max_request_lines = 1000;
const auto lines = repository::{{name_singular}}_change_repository{}.read_latest_by_request_id(
ctx_, pass_.request_id, 0, max_request_lines + 1);
return std::ranges::any_of(lines, [&](const auto& l) {
return !l.applied && l.operation == operation && l.{{left.column}} == {{left.column}} &&
l.{{right.column}} == {{right.column}} && l.base_version == base_version;
});
}
std::optional<ores::utility::domain::result>
{{name_singular}}_service::gate_put(const domain::{{name_singular}}& next,
const ores::utility::domain::precondition& claim) {
using ores::service::messaging::gate_verdict;
using ores::utility::domain::precondition_kind;
if (pass_.rehearsal)
return std::nullopt;
switch (ores::service::messaging::gate_verdict_of(
ctx_, "{{name_singular}}", "put", {}, pass_.request_id)) {
case gate_verdict::free:
return std::nullopt;
case gate_verdict::approved: {
// The line holds the version the maker read. A write with no claim
// names no line, so it never matches.
int base_version = -1;
if (claim.kind == precondition_kind::must_not_exist)
base_version = 0;
else if (claim.kind == precondition_kind::must_match_version && claim.version)
base_version = static_cast<int>(*claim.version);
if (holds_line("put", next.{{left.column}}, next.{{right.column}}, base_version))
return std::nullopt;
break;
}
case gate_verdict::required:
break;
}
return refuse(outcome_code::approval_required, {.entity = "{{name_singular}}"});
}
std::optional<ores::utility::domain::result>
{{name_singular}}_service::gate_delete(const std::vector<domain::{{name_singular}}>& live,
std::optional<std::uint32_t> expected) {
using ores::service::messaging::gate_verdict;
// A link that is not there has nothing to protect: the removal answers
// that it is missing.
if (pass_.rehearsal || live.empty())
return std::nullopt;
switch (ores::service::messaging::gate_verdict_of(
ctx_, "{{name_singular}}", "delete", {}, pass_.request_id)) {
case gate_verdict::free:
return std::nullopt;
case gate_verdict::approved:
if (holds_line("delete", live.front().{{left.column}}, live.front().{{right.column}},
static_cast<int>(expected.value_or(0))))
return std::nullopt;
break;
case gate_verdict::required:
break;
}
return refuse(outcome_code::approval_required, {.entity = "{{name_singular}}"});
}
{{/gated}}
{{/protocol_derived}}
}
{{/junction}}
{{#family}}
#include "ores.{{component_core}}/service/{{entity_singular}}_service.hpp"
#include "ores.{{component_include}}/domain/{{central.entity}}.hpp"
{{#parts}}
#include "ores.{{component_include}}/domain/{{entity}}.hpp"
{{/parts}}
#include "ores.{{component_include}}/messaging/{{entity_singular}}_protocol.hpp"
#include "ores.{{component_core}}/repository/{{central.entity}}_repository.hpp"
{{#parts}}
#include "ores.{{component_core}}/repository/{{entity}}_repository.hpp"
{{/parts}}
#include "ores.database/domain/context.hpp"
#include "ores.database/domain/outcome_code.hpp"
#include "ores.database/repository/unit_of_work.hpp"
#include "ores.platform/time/datetime.hpp"
#include "ores.service/messaging/handler_helpers.hpp"
#include "ores.utility/domain/protocol.hpp"
#include <boost/uuid/uuid_io.hpp>
#include <limits>
#include <optional>
#include <ranges>
#include <string>
#include <utility>
#include <vector>
using ores::database::domain::outcome_code;
using ores::database::domain::refuse;
using ores::service::messaging::stamp;
namespace ores::{{component}}::service {
using namespace ores::logging;
namespace {
/**
* @brief The current central row a family key names, or an empty vector.
*/
std::vector<domain::{{central.entity}}> read_{{central.entity}}(
repository::{{central.entity}}_repository& repo,
const ores::database::context& ctx,
const messaging::{{entity_singular}}_key& key) {
return repo.{{central.read_method}}(ctx, {{central.read_key_expr}});
}
/**
* @brief The key a central domain row states, so the row read back is
* addressed exactly as the request addressed it.
*/
messaging::{{entity_singular}}_key {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(const domain::{{central.entity}}& v) {
messaging::{{entity_singular}}_key key;
key.{{central.key_field.name}} = v.{{central.key_member}};
return key;
}
/**
* @brief Builds the central domain object a change's write record states.
*
* The record carries the user-owned fields and nothing else: tenancy, the
* provenance, the version and the validity window are the service's and the
* database's to state.
*/
domain::{{central.entity}} {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_to_domain(const messaging::{{central.entity}}_write& write) {
domain::{{central.entity}} v;
{{#central.write_fields}}
{{#enum_from_string}}
{
const auto parsed = {{{enum_from_string}}}(write.{{name}});
v.{{domain_member}} = parsed.value_or({{{enum_default}}});
}
{{/enum_from_string}}
{{#date_from_string}}
if (!write.{{name}}.empty())
v.{{domain_member}} = ores::platform::time::datetime::from_iso8601_date(write.{{name}});
{{/date_from_string}}
{{#timestamp_from_string}}
if (!write.{{name}}.empty())
v.{{domain_member}} = ores::platform::time::datetime::from_iso8601_utc(write.{{name}});
{{/timestamp_from_string}}
{{^enum_from_string}}
{{^date_from_string}}
{{^timestamp_from_string}}
v.{{domain_member}} = write.{{name}};
{{/timestamp_from_string}}
{{/date_from_string}}
{{/enum_from_string}}
{{/central.write_fields}}
return v;
}
{{#parts}}
/**
* @brief The live rows of one part kind that belong to one central row.
*/
std::vector<domain::{{entity}}> read_{{part}}(
repository::{{entity}}_repository& repo,
const ores::database::context& ctx,
const domain::{{central.entity}}& central) {
return repo.read_latest_by_{{fk_column}}(
ctx, {{fk_read_expr}}, 0, std::numeric_limits<std::uint32_t>::max(), {}, std::nullopt);
}
/**
* @brief Builds the part domain object a change's write record states.
*/
domain::{{entity}} to_domain_{{part}}(const messaging::{{entity}}_write& write) {
domain::{{entity}} v;
{{#write_fields}}
{{#enum_from_string}}
{
const auto parsed = {{{enum_from_string}}}(write.{{name}});
v.{{domain_member}} = parsed.value_or({{{enum_default}}});
}
{{/enum_from_string}}
{{#date_from_string}}
if (!write.{{name}}.empty())
v.{{domain_member}} = ores::platform::time::datetime::from_iso8601_date(write.{{name}});
{{/date_from_string}}
{{#timestamp_from_string}}
if (!write.{{name}}.empty())
v.{{domain_member}} = ores::platform::time::datetime::from_iso8601_utc(write.{{name}});
{{/timestamp_from_string}}
{{^enum_from_string}}
{{^date_from_string}}
{{^timestamp_from_string}}
v.{{domain_member}} = write.{{name}};
{{/timestamp_from_string}}
{{/date_from_string}}
{{/enum_from_string}}
{{/write_fields}}
return v;
}
/**
* @brief The key a part domain row states.
*/
messaging::{{entity}}_key key_from_{{part}}(const domain::{{entity}}& v) {
messaging::{{entity}}_key key;
key.{{key_field.name}} = v.{{key_member}};
return key;
}
{{/parts}}
}
{{entity_singular}}_service::{{entity_singular}}_service(context ctx)
: ctx_(std::move(ctx)) {}
{{#get_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
auto found = read_{{central.entity}}(central_repo_, ctx_, request.key);
if (found.empty()) {
response.result = refuse(outcome_code::not_found, {.entity = "{{entity_singular}}"});
return response;
}
response.item = assemble(ctx_, request.key, found.front());
return response;
}
{{/get_operations}}
{{#get_many_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
// One request answers the whole set: every central row and every part it
// owns is read and assembled in this one call.
response.items.reserve(request.keys.size());
for (const auto& key : request.keys) {
auto found = read_{{central.entity}}(central_repo_, ctx_, key);
if (found.empty())
continue;
response.items.push_back(assemble(ctx_, key, found.front()));
}
return response;
}
{{/get_many_operations}}
{{#put_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
// The central row and every part land in one transaction, so no reader
// sees a part disagree with the central version.
ores::database::repository::unit_of_work uow(ctx_);
const auto& ctx = uow.ctx();
domain::{{central.entity}} central;
response.result = prepare_{{central.entity}}(request.item.change, request.intent, ctx, central);
if (response.result.outcome != ores::utility::domain::outcome::ok)
return response;
central_repo_.write(ctx, central, request.item.change.precondition);
// The store owns the key and the window, so the key the row was written
// under is the one the write stated rather than assumed from the request.
const auto key = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(central);
// A part write bumps the central row through the part table's own trigger,
// so the central row is read back after the parts land and the item carries
// the version the write produced.
{{#parts}}
response.result = reconcile_{{part}}(ctx, central, request.item.{{part}}, request.intent);
if (response.result.outcome != ores::utility::domain::outcome::ok)
return response;
{{/parts}}
auto stored = read_{{central.entity}}(central_repo_, ctx, key);
if (stored.empty()) {
response.result = refuse(outcome_code::not_found, {.entity = "{{central.entity}}"});
return response;
}
uow.commit();
response.item = assemble(ctx_, key, stored.front());
return response;
}
{{/put_operations}}
{{#put_many_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
messaging::{{response}} response;
// One transaction for the whole batch. Each element states its own claim,
// and the store checks each row's claim as it is written; an element that
// is refused leaves the transaction uncommitted and nothing lands.
ores::database::repository::unit_of_work uow(ctx_);
const auto& ctx = uow.ctx();
response.items.reserve(request.items.size());
for (const auto& write : request.items) {
domain::{{central.entity}} central;
response.result = prepare_{{central.entity}}(write.change, request.intent, ctx, central);
if (response.result.outcome != ores::utility::domain::outcome::ok)
return response;
central_repo_.write(ctx, central, write.change.precondition);
const auto key = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(central);
// A part write bumps the central row, so it is read back after them.
{{#parts}}
response.result = reconcile_{{part}}(ctx, central, write.{{part}}, request.intent);
if (response.result.outcome != ores::utility::domain::outcome::ok)
return response;
{{/parts}}
auto stored = read_{{central.entity}}(central_repo_, ctx, key);
if (stored.empty()) {
response.result = refuse(outcome_code::not_found, {.entity = "{{central.entity}}"});
return response;
}
response.items.push_back(assemble(ctx_, key, stored.front()));
}
uow.commit();
return response;
}
{{/put_many_operations}}
messaging::{{entity_singular}}_item
{{entity_singular}}_service::assemble(
const context& ctx,
const messaging::{{entity_singular}}_key& key,
const std::optional<domain::{{central.entity}}>& central) {
messaging::{{entity_singular}}_item item;
item.key = key;
item.{{central.entity}} = central;
{{#parts}}
if (central)
item.{{part}} = read_{{part}}({{part}}_repo_, ctx, *central);
{{/parts}}
return item;
}
ores::utility::domain::result
{{entity_singular}}_service::prepare_{{central.entity}}(
const messaging::{{central.entity}}_change& change,
const ores::utility::domain::change_intent& intent,
const context& ctx,
domain::{{central.entity}}& out) {
using ores::utility::domain::outcome;
using ores::utility::domain::precondition_kind;
ores::utility::domain::result result;
out = {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_to_domain(change.write);
const auto current = read_{{central.entity}}(central_repo_, ctx, {{entity_singular}}{{^entity_singular}}{{name_singular}}{{/entity_singular}}_key_from(out));
switch (change.precondition.kind) {
case precondition_kind::must_not_exist:
if (!current.empty())
return refuse(outcome_code::already_exists, {.entity = "{{central.entity}}"});
break;
case precondition_kind::must_match_version:
{{#central.has_audit_columns}}
if (current.empty())
return refuse(outcome_code::not_found, {.entity = "{{central.entity}}"});
// The protocol states the version as a uint32 and the row carries it
// as an int, so the comparison states the conversion.
if (!change.precondition.version
|| static_cast<std::uint32_t>(current.front().{{central.version_member}})
!= *change.precondition.version) {
return refuse(outcome_code::version_conflict,
{.entity = "{{central.entity}}",
.expected = change.precondition.version
? std::to_string(*change.precondition.version)
: std::string{},
.current =
std::to_string(current.front().{{central.version_member}})});
}
{{/central.has_audit_columns}}
{{^central.has_audit_columns}}
// A resource that keeps no version cannot answer the claim.
return refuse(outcome_code::precondition_not_supported);
{{/central.has_audit_columns}}
break;
case precondition_kind::any:
break;
}
// The version is the repository's to state, from the claim: it is the one
// thing the store's arbiter reads, and stating it in two places is how the
// two come to disagree.
stamp(out, ctx,
intent.reason_code.empty()
? std::string(ores::service::messaging::change_reasons::new_record)
: intent.reason_code);
{{#central.has_audit_columns}}
out.{{central.commentary_member}} = intent.commentary;
{{/central.has_audit_columns}}
return result;
}
{{#parts}}
ores::utility::domain::result
{{entity_singular}}_service::prepare_{{part}}(
const messaging::{{entity}}_change& change,
const ores::utility::domain::change_intent& intent,
const context& ctx,
domain::{{entity}}& out) {
using ores::utility::domain::outcome;
using ores::utility::domain::precondition_kind;
ores::utility::domain::result result;
out = to_domain_{{part}}(change.write);
const auto key = key_from_{{part}}(out);
const auto current = {{part}}_repo_.{{read_method}}(ctx, {{read_key_expr}});
switch (change.precondition.kind) {
case precondition_kind::must_not_exist:
if (!current.empty())
return refuse(outcome_code::already_exists, {.entity = "{{entity}}"});
break;
case precondition_kind::must_match_version:
{{#has_audit_columns}}
if (current.empty())
return refuse(outcome_code::not_found, {.entity = "{{entity}}"});
if (!change.precondition.version
|| static_cast<std::uint32_t>(current.front().{{version_member}})
!= *change.precondition.version) {
return refuse(outcome_code::version_conflict,
{.entity = "{{entity}}",
.expected = change.precondition.version
? std::to_string(*change.precondition.version)
: std::string{},
.current = std::to_string(current.front().{{version_member}})});
}
{{/has_audit_columns}}
{{^has_audit_columns}}
// A resource that keeps no version cannot answer the claim.
return refuse(outcome_code::precondition_not_supported);
{{/has_audit_columns}}
break;
case precondition_kind::any:
break;
}
stamp(out, ctx,
intent.reason_code.empty()
? std::string(ores::service::messaging::change_reasons::new_record)
: intent.reason_code);
{{#has_audit_columns}}
out.{{commentary_member}} = intent.commentary;
{{/has_audit_columns}}
return result;
}
ores::utility::domain::result
{{entity_singular}}_service::reconcile_{{part}}(
const context& ctx,
const domain::{{central.entity}}& central,
const std::vector<messaging::{{entity}}_change>& requested,
const ores::utility::domain::change_intent& intent) {
using ores::utility::domain::outcome;
// Every requested row is checked before any of them is written, so a
// refused row refuses the family element rather than landing half of it.
std::vector<domain::{{entity}}> wanted;
wanted.reserve(requested.size());
std::vector<ores::utility::domain::precondition> claims;
claims.reserve(requested.size());
for (const auto& change : requested) {
domain::{{entity}} row;
const auto result = prepare_{{part}}(change, intent, ctx, row);
if (result.outcome != outcome::ok)
return result;
row.{{fk_member}} = {{fk_assign_expr}};
{{#copy_party_id}}
// A part that carries the owning party takes it from the central row,
// which is the row the session stamped. The part table's own policy
// reads that column, so it must agree with the central row even when
// the session has no party of its own.
row.{{party_member}} = central.{{central_party_member}};
{{/copy_party_id}}
wanted.push_back(std::move(row));
claims.push_back(change.precondition);
}
// A row that is live now but absent from the request is closed, so this
// part's rows for the central row become exactly the rows the request
// carries. The close bumps the central version through the part table's
// own trigger, so the C++ never touches it.
const auto live = read_{{part}}({{part}}_repo_, ctx, central);
for (const auto& row : live) {
const bool present = std::ranges::any_of(
wanted, [&](const auto& w) { return w.{{pk_member}} == row.{{pk_member}}; });
if (!present)
{{part}}_repo_.remove(ctx, {{pk_text_expr}});
}
// One statement, so the requested set for this part lands together. The
// store checks each row's claim inside it.
if (!wanted.empty())
{{part}}_repo_.write(ctx, wanted, claims);
return {};
}
{{/parts}}
}
{{/family}}
2. See also
- Parent facet: ores.cpp.service
- Template variable reference