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 <cstdint>
#include <stdexcept>
{{#protocol_derived}}
#include <algorithm>
#include <iterator>
#include <optional>
#include <string>
#include <utility>
#include <vector>
{{/protocol_derived}}
{{#has_uuid_include}}
#include <boost/uuid/uuid_io.hpp>
{{/has_uuid_include}}
<<paste:A7D3F1E2-8C5B-4A2F-B9E0-3D6C1A8F4B2E>>
#include "ores.platform/time/datetime.hpp"
#include "ores.service/messaging/handler_helpers.hpp"

using ores::service::messaging::stamp;

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 {

/**
 * @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}}> 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}}{{#is_uuid}}boost::uuids::to_string(key.{{column}}){{/is_uuid}}{{#is_timestamp}}ores::platform::time::datetime::to_db_string(key.{{column}}){{/is_timestamp}}{{^is_uuid}}{{^is_timestamp}}key.{{column}}{{/is_timestamp}}{{/is_uuid}}{{^last}}, {{/last}}{{/primary_key.key_columns}});
{{/key_is_primary}}
{{^key_is_primary}}
    return repo.read_latest_by_{{declared_key}}(ctx, {{#declared_key_is_uuid}}boost::uuids::to_string(key.{{declared_key}}){{/declared_key_is_uuid}}{{^declared_key_is_uuid}}key.{{declared_key}}{{/declared_key_is_uuid}});
{{/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 key_from(const domain::{{entity_singular}}& v) {
    messaging::{{entity_singular}}_key key;
{{#key_is_primary}}
{{#primary_key.key_columns}}
    key.{{column}} = v.{{column}};
{{/primary_key.key_columns}}
{{/key_is_primary}}
{{^key_is_primary}}
    key.{{declared_key}} = v.{{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}} to_domain(const messaging::{{entity_singular}}_write& write) {
    domain::{{entity_singular}} v;
{{#write_fields}}
    v.{{domain_member}} = write.{{name}};
{{/write_fields}}
    return v;
}
{{/put_operations}}

} // namespace

{{#list_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.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "order_not_supported";
        response.result.message =
            "This store pages in key order and cannot order by a stated field.";
        return response;
    }
{{/has_order}}
{{#has_filter}}
    if (request.filter) {
        response.result.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "filter_not_supported";
        response.result.message = "Filtering is not served for this resource yet.";
        return response;
    }
{{/has_filter}}
    response.{{entity_plural_short}} = repo_.read_latest(ctx_, request.offset, request.limit);
    response.total = repo_.get_total_{{entity_singular_short}}_count(ctx_);
    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() || request.order.descending) {
        response.result.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "order_not_supported";
        response.result.message =
            "This store pages in key order and cannot order by a stated field.";
        return response;
    }
{{/has_order}}
{{#has_filter}}
    if (request.filter) {
        response.result.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "filter_not_supported";
        response.result.message = "Filtering is not served for this resource yet.";
        return response;
    }
{{/has_filter}}
    if (request.scope == ores::utility::domain::scope::subtree) {
        response.result.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "scope_not_supported";
        response.result.message = "This resource reads its direct members; it has no subtree.";
        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}}
    response.{{entity_plural_short}} = repo_.read_latest_by_{{leading}}(
        ctx_, relation, request.offset, request.limit);
    response.total = repo_.get_total_{{entity_singular_short}}_count_by_{{leading}}(ctx_, relation);
    return response;
}

{{/list_scoped_operations}}
{{#get_operations}}
messaging::{{response}}
{{entity_singular}}_service::{{method}}(const messaging::{{request}}& request) {
    messaging::{{response}} response;
    auto found = read_one(repo_, ctx_, request.key);
    if (found.empty()) {
        response.result.outcome = ores::utility::domain::outcome::missing;
        response.result.code = "not_found";
        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());
    for (const auto& k : request.keys) {
        messaging::{{entity_singular}}_lookup entry;
        entry.key = k;
        auto found = read_one(repo_, ctx_, k);
        if (!found.empty())
            entry.{{entity_singular}} = std::move(found.front());
        response.entries.push_back(std::move(entry));
    }
    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);
    auto written = read_one(repo_, ctx_, 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);
    response.{{entity_plural_short}}.reserve(batch.size());
    for (const auto& value : batch) {
        auto written = read_one(repo_, ctx_, key_from(value));
        response.{{entity_plural_short}}.push_back(
            written.empty() ? value : std::move(written.front()));
    }
    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.outcome = outcome::invalid;
        response.result.code = "precondition_not_supported";
        response.result.message = "A removal cannot require that a row is absent.";
        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.outcome = outcome::invalid;
            response.result.code = "precondition_incomplete";
            response.result.message = "A versioned removal must state the version it expects.";
            return response;
        }
        expected = request.removal.precondition.version;
    }
{{^key_is_primary}}
    const auto named = read_one(repo_, ctx_, request.removal.key);
    if (named.empty()) {
        response.result.outcome = outcome::missing;
        response.result.code = "not_found";
        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.outcome = outcome::missing;
        response.result.code = "not_found";
        break;
    case repository::{{entity_singular}}_repository::remove_status::conflicting:
        response.result.outcome = outcome::conflict;
        response.result.code = "version_conflict";
        break;
    case repository::{{entity_singular}}_repository::remove_status::unsupported:
        response.result.outcome = outcome::invalid;
        response.result.code = "precondition_not_supported";
        response.result.message = "This resource keeps no version to match.";
        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.outcome = outcome::invalid;
            response.result.code = "batch_removal_is_unconditional";
            response.result.message =
                "A batch removal is unconditional; remove the rows one at a time "
                "to state a version.";
            return response;
        }
    }
    if (request.removals.empty())
        return response;
{{^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 = 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({{#is_uuid}}boost::uuids::to_string(removal.key.{{column}}){{/is_uuid}}{{#is_timestamp}}ores::platform::time::datetime::to_db_string(removal.key.{{column}}){{/is_timestamp}}{{^is_uuid}}{{^is_timestamp}}removal.key.{{column}}{{/is_timestamp}}{{/is_uuid}});
{{/key_is_primary}}
{{^key_is_primary}}
    {{column}}_keys.reserve(resolved.size());
    for (const auto& row : resolved)
        {{column}}_keys.push_back({{#is_uuid}}boost::uuids::to_string(row.{{column}}){{/is_uuid}}{{#is_timestamp}}ores::platform::time::datetime::to_db_string(row.{{column}}){{/is_timestamp}}{{^is_uuid}}{{^is_timestamp}}row.{{column}}{{/is_timestamp}}{{/is_uuid}});
{{/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.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "order_not_supported";
        response.result.message =
            "This store pages in key order and cannot order by a stated field.";
        return response;
    }
{{/has_order}}
{{#has_filter}}
    if (request.filter) {
        response.result.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "filter_not_supported";
        response.result.message = "Filtering is not served for this resource yet.";
        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 = read_one(repo_, ctx_, request.key);
    if (named.empty()) {
        response.result.outcome = ores::utility::domain::outcome::missing;
        response.result.code = "not_found";
        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 = read_one(repo_, ctx_, request.key.{{entity_singular}});
    if (named.empty()) {
        response.result.outcome = ores::utility::domain::outcome::missing;
        response.result.code = "not_found";
        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.outcome = ores::utility::domain::outcome::missing;
        response.result.code = "not_found";
        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 = to_domain(change.write);
    const auto current = read_one(repo_, ctx_, key_from(out));
    switch (change.precondition.kind) {
    case precondition_kind::must_not_exist:
        if (!current.empty()) {
            result.outcome = outcome::conflict;
            result.code = "already_exists";
            return result;
        }
        break;
    case precondition_kind::must_match_version:
{{#has_audit_columns}}
        if (current.empty()) {
            result.outcome = outcome::missing;
            result.code = "not_found";
            return result;
        }
        // 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().version)
                   != *change.precondition.version) {
            result.outcome = outcome::conflict;
            result.code = "version_conflict";
            return result;
        }
{{/has_audit_columns}}
{{^has_audit_columns}}
        result.outcome = outcome::invalid;
        result.code = "precondition_not_supported";
        result.message = "This resource keeps no version to match.";
        return result;
{{/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);
{{#has_audit_columns}}
    out.change_commentary = intent.commentary;
{{/has_audit_columns}}
    return result;
}

{{/put_operations}}
{{/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 = 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}}
{{#parent_column}}

std::optional<domain::{{entity_singular}}>
{{entity_singular}}_service::find_{{entity_singular_short}}_by_code(const boost::uuids::uuid& {{parent_column}},
                                                                         const std::string& {{column}}) {
    BOOST_LOG_SEV(lg(), debug) << "Finding {{entity_singular_words}} by {{parent_column}}/{{column}}: "
                               << {{parent_column}} << "/" << {{column}};
    auto results = repo_.read_latest_by_code(ctx_, boost::uuids::to_string({{parent_column}}), {{column}});
    if (results.empty()) return std::nullopt;
    return results.front();
}
{{/parent_column}}
{{^parent_column}}

std::optional<domain::{{entity_singular}}>
{{entity_singular}}_service::find_{{entity_singular_short}}_by_code(const std::string& {{column}}) {
    BOOST_LOG_SEV(lg(), debug) << "Finding {{entity_singular_words}} by {{column}}: " << {{column}};
    auto results = repo_.read_latest_by_code(ctx_, {{column}});
    if (results.empty()) return std::nullopt;
    return results.front();
}
{{/parent_column}}
{{/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}}& v) {
{{#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_groups}}
    stamp(t.{{member}}, ctx_);
{{/domain_groups}}
{{/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}}};
}

void {{entity_singular}}_service::save_{{entity_plural_short}}(
    const std::vector<domain::{{entity_singular}}>& {{entity_plural_short}}) {
    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_groups}}
        stamp(e.{{member}}, ctx_);
{{/domain_groups}}
{{/has_domain_groups}}
{{^has_domain_groups}}
        stamp(e, ctx_);
{{/has_domain_groups}}
    }
    repo_.write(ctx_, ts);
}

void {{entity_singular}}_service::delete_{{entity_singular_short}}({{{primary_key.typed_params}}}) {
    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}}};
}
{{#service_find_by_uuid}}

void {{entity_singular}}_service::remove_{{entity_singular_short}}(const boost::uuids::uuid& {{primary_key.column}}) {
    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}};
}
{{/service_find_by_uuid}}

void {{entity_singular}}_service::delete_{{entity_plural_short}}({{{primary_key.batch_params}}}) {
    repo_.remove(ctx_, {{{primary_key.batch_args}}});
}

{{^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 = 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 <cstddef>
#include <cstdint>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
{{#has_uuid_left_or_right}}
#include <boost/uuid/uuid_io.hpp>
{{/has_uuid_left_or_right}}
#include "ores.service/messaging/handler_helpers.hpp"

namespace ores::{{component}}::service {

using namespace ores::logging;
using ores::service::messaging::stamp;

{{#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);
}

} // namespace
{{/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}}> 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 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}} 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}}

} // namespace

{{#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.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "order_not_supported";
        response.result.message =
            "This store pages in key order and cannot order by a stated field.";
        return response;
    }
{{/has_order}}
{{#has_filter}}
    if (request.filter) {
        response.result.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "filter_not_supported";
        response.result.message = "Filtering is not served for this resource yet.";
        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.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "order_not_supported";
        response.result.message =
            "This store pages in key order and cannot order by a stated field.";
        return response;
    }
    if (request.filter) {
        response.result.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "filter_not_supported";
        response.result.message = "Filtering is not served for this resource yet.";
        return response;
    }
    if (request.scope == ores::utility::domain::scope::subtree) {
        response.result.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "scope_not_supported";
        response.result.message = "This resource reads its direct members; it has no subtree.";
        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.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "order_not_supported";
        response.result.message =
            "This store pages in key order and cannot order by a stated field.";
        return response;
    }
    if (request.filter) {
        response.result.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "filter_not_supported";
        response.result.message = "Filtering is not served for this resource yet.";
        return response;
    }
    if (request.scope == ores::utility::domain::scope::subtree) {
        response.result.outcome = ores::utility::domain::outcome::invalid;
        response.result.code = "scope_not_supported";
        response.result.message = "This resource reads its direct members; it has no subtree.";
        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 = read_one(repo_, request.key);
    if (found.empty()) {
        response.result.outcome = ores::utility::domain::outcome::missing;
        response.result.code = "not_found";
        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 = 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 = read_one(repo_, 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 = read_one(repo_, 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.outcome = outcome::invalid;
        response.result.code = "precondition_not_supported";
        response.result.message = "A removal cannot require that a row is absent.";
        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.outcome = outcome::invalid;
            response.result.code = "precondition_incomplete";
            response.result.message = "A versioned removal must state the version it expects.";
            return response;
        }
        expected = request.removal.precondition.version;
    }
    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.outcome = outcome::missing;
        response.result.code = "not_found";
        break;
    case repository::{{name_singular}}_repository::remove_status::conflicting:
        response.result.outcome = outcome::conflict;
        response.result.code = "version_conflict";
        break;
    case repository::{{name_singular}}_repository::remove_status::unsupported:
        response.result.outcome = outcome::invalid;
        response.result.code = "precondition_not_supported";
        response.result.message = "This resource keeps no version to match.";
        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.outcome = outcome::invalid;
            response.result.code = "batch_removal_is_unconditional";
            response.result.message =
                "A batch removal is unconditional; remove the rows one at a time "
                "to state a version.";
            return response;
        }
    }
    if (request.removals.empty())
        return response;
    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 = to_domain(change.write);
    const auto current = read_one(repo_, key_from(out));
    switch (change.precondition.kind) {
    case precondition_kind::must_not_exist:
        if (!current.empty()) {
            result.outcome = outcome::conflict;
            result.code = "already_exists";
            return result;
        }
        break;
    case precondition_kind::must_match_version:
        if (current.empty()) {
            result.outcome = outcome::missing;
            result.code = "not_found";
            return result;
        }
        // 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().version)
                   != *change.precondition.version) {
            result.outcome = outcome::conflict;
            result.code = "version_conflict";
            return result;
        }
        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_);
{{/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}}
{{/protocol_derived}}
}
{{/junction}}

2. See also

Emacs 29.3 (Org mode 9.6.15)