Files
Jason Larabie 346e2b2514 Add C++ query builder (#4664)
# Description of Changes
- Added a query builder for C++ module bindings
  - Added query-builder table/filter/join types
- Added semijoin support with compile-time checks for lookup-table and
indexed-field usage
  - Added support for returning query-builder queries from C++ views
- Hooked query-builder metadata into the C++ table/view macros and V10
module-def path
- Added test coverage for the new C++ query-builder behavior
  - Compile tests for pass/fail cases
  - SQL tests for generated query output
  - Added a C++ test module for view primary key coverage
- **Update:** Switched the core to pass the columns and index-columns
metadata with the table source for better client-side codegen to have
some shared code between server + client.
# API and ABI breaking changes
- No intended API or ABI breaking changes
- Adds a new public query-builder API to the C++ bindings
- C++ views can now return query-builder query types in addition to
materialized row results

# Expected complexity level and risk

3 - Mostly contained to C++ bindings, but it touches macros, view
registration/serialization, and module-def generation, so there are a
few places where the pieces need to stay in sync.

# Testing

I've done end to end testing of I think every type as well as built some
tests to confirm the SQL output.

- [x] Run the C++ query-builder SQL tests
[crates/bindings-cpp/tests/query-builder-compile/run_query_builder_compile_tests.sh]
- [x] Smoke test a generated C++ module using query-builder views

---------

Signed-off-by: Jason Larabie <jason@clockworklabs.io>
Co-authored-by: Ryan <r.ekhoff@clockworklabs.io>
Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com>
2026-06-12 13:02:36 +00:00

537 lines
22 KiB
C++

#pragma once
#include "spacetimedb/bsatn/timestamp.h"
#include "spacetimedb/bsatn/types.h"
#include <charconv>
#include <cstdint>
#include <limits>
#include <iomanip>
#include <locale>
#include <memory>
#include <sstream>
#include <string>
#include <string_view>
#include <type_traits>
#include <utility>
#include <variant>
#include <vector>
namespace SpacetimeDB::query_builder {
template<typename TRow, typename TValue>
class Col;
template<typename T>
struct is_col : std::false_type {};
template<typename TValue, typename TRhs>
inline constexpr bool is_rhs_for_value_v =
std::is_same_v<TValue, std::remove_cvref_t<TRhs>> ||
(std::is_same_v<TValue, std::string> && std::is_convertible_v<TRhs, std::string_view>);
template<typename TRow>
class ColumnRef {
public:
constexpr ColumnRef()
: table_name_(""), column_name_("") {}
constexpr ColumnRef(const char* table_name, const char* column_name)
: table_name_(table_name), column_name_(column_name) {}
[[nodiscard]] std::string format() const {
return "\"" + std::string(table_name_) + "\".\"" + std::string(column_name_) + "\"";
}
[[nodiscard]] constexpr const char* table_name() const { return table_name_; }
[[nodiscard]] constexpr const char* column_name() const { return column_name_; }
private:
const char* table_name_;
const char* column_name_;
};
namespace detail {
inline std::string quote_string(std::string_view value) {
std::string escaped;
escaped.reserve(value.size() + 2);
escaped.push_back('\'');
for (char ch : value) {
escaped.push_back(ch);
if (ch == '\'') {
escaped.push_back('\'');
}
}
escaped.push_back('\'');
return escaped;
}
inline std::string trim_timestamp_fraction(std::string value) {
// Keep this in sync with the current Timestamp::to_string() UTC form.
// If that representation changes away from a +00:00 / Z suffix, revisit this trimming logic.
const std::size_t plus = value.rfind("+00:00");
const std::size_t z = value.rfind('Z');
const std::size_t dot = value.find('.');
const std::size_t suffix = plus != std::string::npos ? plus : z;
if (suffix == std::string::npos || dot == std::string::npos || dot > suffix) {
return value;
}
std::size_t trim = suffix;
while (trim > dot + 1 && value[trim - 1] == '0') {
--trim;
}
if (trim == dot + 1) {
value.erase(dot, suffix - dot);
} else {
value.erase(trim, suffix - trim);
}
return value;
}
inline std::string literal_sql(const std::string& value) { return quote_string(value); }
inline std::string literal_sql(std::string_view value) { return quote_string(value); }
inline std::string literal_sql(const char* value) { return quote_string(value == nullptr ? "" : value); }
inline std::string literal_sql(bool value) { return value ? "TRUE" : "FALSE"; }
inline std::string literal_sql(const Identity& value) { return "0x" + value.to_hex_string(); }
inline std::string literal_sql(const ConnectionId& value) { return "0x" + value.to_string(); }
inline std::string literal_sql(const Timestamp& value) { return quote_string(trim_timestamp_fraction(value.to_string())); }
inline std::string literal_sql(const TimeDuration&) = delete;
inline std::string literal_sql(const std::vector<uint8_t>& value) {
std::ostringstream out;
out << "0x" << std::hex << std::setfill('0');
for (uint8_t byte : value) {
out << std::setw(2) << static_cast<unsigned>(byte);
}
return out.str();
}
inline std::string literal_sql(const u128& value) { return value.to_string(); }
inline std::string literal_sql(const i128& value) { return value.to_string(); }
inline std::string literal_sql(const u256& value) { return value.to_string(); }
inline std::string literal_sql(const i256& value) { return value.to_string(); }
template<typename TFloat>
inline std::string format_floating_point(TFloat value) {
char buffer[64];
const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value, std::chars_format::general);
if (result.ec == std::errc{}) {
return std::string(buffer, result.ptr);
}
std::ostringstream out;
out.imbue(std::locale::classic());
out << std::setprecision(std::numeric_limits<TFloat>::max_digits10);
out << value;
return out.str();
}
inline std::string literal_sql(float value) {
return format_floating_point(value);
}
inline std::string literal_sql(double value) {
return format_floating_point(value);
}
template<typename TValue>
std::string literal_sql(const TValue& value)
requires(std::is_integral_v<TValue> && !std::is_same_v<std::remove_cv_t<TValue>, bool>)
{
return std::to_string(value);
}
template<typename TRow>
class Operand {
public:
static Operand column(ColumnRef<TRow> column) { return Operand(std::move(column)); }
static Operand literal(std::string sql) { return Operand(std::move(sql)); }
[[nodiscard]] std::string format() const {
return std::holds_alternative<ColumnRef<TRow>>(value_)
? std::get<ColumnRef<TRow>>(value_).format()
: std::get<std::string>(value_);
}
private:
explicit Operand(ColumnRef<TRow> column) : value_(std::move(column)) {}
explicit Operand(std::string sql) : value_(std::move(sql)) {}
std::variant<ColumnRef<TRow>, std::string> value_;
};
template<typename TRow, typename TValue>
Operand<TRow> to_operand(const Col<TRow, TValue>& column);
template<typename TRow, typename TValue>
Operand<TRow> to_operand(const TValue& value) {
return Operand<TRow>::literal(literal_sql(value));
}
} // namespace detail
template<typename TRow>
class BoolExpr {
public:
enum class Kind {
Eq,
Ne,
Gt,
Lt,
Gte,
Lte,
And,
Or,
Not,
};
static BoolExpr compare(Kind kind, detail::Operand<TRow> lhs, detail::Operand<TRow> rhs) {
return BoolExpr(std::make_shared<Node>(kind, std::move(lhs), std::move(rhs)));
}
static BoolExpr always(bool value) {
return compare(
Kind::Eq,
detail::Operand<TRow>::literal(value ? "TRUE" : "FALSE"),
detail::Operand<TRow>::literal("TRUE"));
}
[[nodiscard]] std::string format() const {
return format_node(root_);
}
[[nodiscard]] BoolExpr and_(const BoolExpr& other) const {
return BoolExpr(std::make_shared<Node>(Kind::And, root_, other.root_));
}
[[nodiscard]] BoolExpr And(const BoolExpr& other) const { return and_(other); }
[[nodiscard]] BoolExpr or_(const BoolExpr& other) const {
return BoolExpr(std::make_shared<Node>(Kind::Or, root_, other.root_));
}
[[nodiscard]] BoolExpr Or(const BoolExpr& other) const { return or_(other); }
[[nodiscard]] BoolExpr not_() const {
return BoolExpr(std::make_shared<Node>(Kind::Not, root_, nullptr));
}
[[nodiscard]] BoolExpr Not() const { return not_(); }
private:
struct Node;
struct CompareData {
detail::Operand<TRow> lhs;
detail::Operand<TRow> rhs;
};
struct LogicData {
std::shared_ptr<const Node> left;
std::shared_ptr<const Node> right;
};
struct NotData {
std::shared_ptr<const Node> child;
};
struct Node {
Node(Kind kind_in, detail::Operand<TRow> lhs_in, detail::Operand<TRow> rhs_in)
: kind(kind_in), data(CompareData{std::move(lhs_in), std::move(rhs_in)}) {}
Node(Kind kind_in, std::shared_ptr<const Node> left_in, std::shared_ptr<const Node> right_in)
: kind(kind_in),
data(kind_in == Kind::Not
? NodeData(NotData{std::move(left_in)})
: NodeData(LogicData{std::move(left_in), std::move(right_in)})) {}
Kind kind;
using NodeData = std::variant<CompareData, LogicData, NotData>;
NodeData data;
};
explicit BoolExpr(std::shared_ptr<const Node> root)
: root_(std::move(root)) {}
static std::string format_node(const std::shared_ptr<const Node>& node) {
switch (node->kind) {
case Kind::Eq: {
const auto& compare = std::get<CompareData>(node->data);
return "(" + compare.lhs.format() + " = " + compare.rhs.format() + ")";
}
case Kind::Ne: {
const auto& compare = std::get<CompareData>(node->data);
return "(" + compare.lhs.format() + " <> " + compare.rhs.format() + ")";
}
case Kind::Gt: {
const auto& compare = std::get<CompareData>(node->data);
return "(" + compare.lhs.format() + " > " + compare.rhs.format() + ")";
}
case Kind::Lt: {
const auto& compare = std::get<CompareData>(node->data);
return "(" + compare.lhs.format() + " < " + compare.rhs.format() + ")";
}
case Kind::Gte: {
const auto& compare = std::get<CompareData>(node->data);
return "(" + compare.lhs.format() + " >= " + compare.rhs.format() + ")";
}
case Kind::Lte: {
const auto& compare = std::get<CompareData>(node->data);
return "(" + compare.lhs.format() + " <= " + compare.rhs.format() + ")";
}
case Kind::And: {
const auto& logic = std::get<LogicData>(node->data);
return "(" + format_node(logic.left) + " AND " + format_node(logic.right) + ")";
}
case Kind::Or: {
const auto& logic = std::get<LogicData>(node->data);
return "(" + format_node(logic.left) + " OR " + format_node(logic.right) + ")";
}
case Kind::Not: {
const auto& not_data = std::get<NotData>(node->data);
return "(NOT " + format_node(not_data.child) + ")";
}
}
return {};
}
std::shared_ptr<const Node> root_;
};
namespace detail {
template<typename TRow>
BoolExpr<TRow> make_bool_expr(BoolExpr<TRow> expr) {
return expr;
}
template<typename TRow>
BoolExpr<TRow> make_bool_expr(bool value) {
return BoolExpr<TRow>::always(value);
}
template<typename TRow>
BoolExpr<TRow> make_bool_expr(const Col<TRow, bool>& column) {
return column.eq(true);
}
} // namespace detail
template<typename TRow, typename TValue>
class Col {
public:
constexpr Col() = default;
constexpr Col(const char* table_name, const char* column_name)
: column_(table_name, column_name) {}
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> eq(const TRhs& rhs) const { return compare(BoolExpr<TRow>::Kind::Eq, rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> Eq(const TRhs& rhs) const { return eq(rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> ne(const TRhs& rhs) const { return compare(BoolExpr<TRow>::Kind::Ne, rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> Ne(const TRhs& rhs) const { return ne(rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> gt(const TRhs& rhs) const { return compare(BoolExpr<TRow>::Kind::Gt, rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> Gt(const TRhs& rhs) const { return gt(rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> lt(const TRhs& rhs) const { return compare(BoolExpr<TRow>::Kind::Lt, rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> Lt(const TRhs& rhs) const { return lt(rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> gte(const TRhs& rhs) const { return compare(BoolExpr<TRow>::Kind::Gte, rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> Gte(const TRhs& rhs) const { return gte(rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> lte(const TRhs& rhs) const { return compare(BoolExpr<TRow>::Kind::Lte, rhs); }
template<typename TRhs>
requires(is_rhs_for_value_v<TValue, TRhs>)
[[nodiscard]] BoolExpr<TRow> Lte(const TRhs& rhs) const { return lte(rhs); }
// Keep incompatible non-column RHS values on a dedicated overload so they
// fail with the same diagnostic shape as mismatched column comparisons.
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto eq(const TRhs&) const {
static_assert(is_rhs_for_value_v<TValue, TRhs>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto Eq(const TRhs& rhs) const { return eq(rhs); }
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto ne(const TRhs&) const {
static_assert(is_rhs_for_value_v<TValue, TRhs>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto Ne(const TRhs& rhs) const { return ne(rhs); }
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto gt(const TRhs&) const {
static_assert(is_rhs_for_value_v<TValue, TRhs>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto Gt(const TRhs& rhs) const { return gt(rhs); }
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto lt(const TRhs&) const {
static_assert(is_rhs_for_value_v<TValue, TRhs>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto Lt(const TRhs& rhs) const { return lt(rhs); }
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto gte(const TRhs&) const {
static_assert(is_rhs_for_value_v<TValue, TRhs>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto Gte(const TRhs& rhs) const { return gte(rhs); }
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto lte(const TRhs&) const {
static_assert(is_rhs_for_value_v<TValue, TRhs>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TRhs>
requires(!is_rhs_for_value_v<TValue, TRhs> && !is_col<std::remove_cvref_t<TRhs>>::value)
[[nodiscard]] auto Lte(const TRhs& rhs) const { return lte(rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> eq(const Col<TRow, TOtherValue>& rhs) const { return compare(BoolExpr<TRow>::Kind::Eq, rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> Eq(const Col<TRow, TOtherValue>& rhs) const { return eq(rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> ne(const Col<TRow, TOtherValue>& rhs) const { return compare(BoolExpr<TRow>::Kind::Ne, rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> Ne(const Col<TRow, TOtherValue>& rhs) const { return ne(rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> gt(const Col<TRow, TOtherValue>& rhs) const { return compare(BoolExpr<TRow>::Kind::Gt, rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> Gt(const Col<TRow, TOtherValue>& rhs) const { return gt(rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> lt(const Col<TRow, TOtherValue>& rhs) const { return compare(BoolExpr<TRow>::Kind::Lt, rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> Lt(const Col<TRow, TOtherValue>& rhs) const { return lt(rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> gte(const Col<TRow, TOtherValue>& rhs) const { return compare(BoolExpr<TRow>::Kind::Gte, rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> Gte(const Col<TRow, TOtherValue>& rhs) const { return gte(rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> lte(const Col<TRow, TOtherValue>& rhs) const { return compare(BoolExpr<TRow>::Kind::Lte, rhs); }
template<typename TOtherValue>
requires(std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] BoolExpr<TRow> Lte(const Col<TRow, TOtherValue>& rhs) const { return lte(rhs); }
// Keep mismatched column-to-column comparisons on a dedicated overload so
// they fail here with a clear diagnostic instead of disappearing into the
// generic operand-conversion path.
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto eq(const Col<TRow, TOtherValue>&) const {
static_assert(std::is_same_v<TValue, TOtherValue>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto Eq(const Col<TRow, TOtherValue>& rhs) const { return eq(rhs); }
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto ne(const Col<TRow, TOtherValue>&) const {
static_assert(std::is_same_v<TValue, TOtherValue>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto Ne(const Col<TRow, TOtherValue>& rhs) const { return ne(rhs); }
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto gt(const Col<TRow, TOtherValue>&) const {
static_assert(std::is_same_v<TValue, TOtherValue>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto Gt(const Col<TRow, TOtherValue>& rhs) const { return gt(rhs); }
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto lt(const Col<TRow, TOtherValue>&) const {
static_assert(std::is_same_v<TValue, TOtherValue>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto Lt(const Col<TRow, TOtherValue>& rhs) const { return lt(rhs); }
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto gte(const Col<TRow, TOtherValue>&) const {
static_assert(std::is_same_v<TValue, TOtherValue>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto Gte(const Col<TRow, TOtherValue>& rhs) const { return gte(rhs); }
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto lte(const Col<TRow, TOtherValue>&) const {
static_assert(std::is_same_v<TValue, TOtherValue>, "Column comparison requires both sides to have the same value type.");
return BoolExpr<TRow>::always(false);
}
template<typename TOtherValue>
requires(!std::is_same_v<TValue, TOtherValue>)
[[nodiscard]] auto Lte(const Col<TRow, TOtherValue>& rhs) const { return lte(rhs); }
[[nodiscard]] constexpr const ColumnRef<TRow>& column_ref() const { return column_; }
private:
template<typename TRhs>
[[nodiscard]] BoolExpr<TRow> compare(typename BoolExpr<TRow>::Kind kind, const TRhs& rhs) const {
return BoolExpr<TRow>::compare(kind, detail::to_operand<TRow>(*this), detail::to_operand<TRow>(rhs));
}
ColumnRef<TRow> column_;
};
template<typename TRow, typename TValue>
struct is_col<Col<TRow, TValue>> : std::true_type {};
namespace detail {
template<typename TRow, typename TValue>
Operand<TRow> to_operand(const Col<TRow, TValue>& column) {
return Operand<TRow>::column(column.column_ref());
}
} // namespace detail
} // namespace SpacetimeDB::query_builder