How ClickHouse Resolves a Scalar Function Call
ClickHouse registers 840 scalar functions in a static map, resolves names through two case-sensitive maps plus alias tables, and picks an overload with a declarative signature DSL.
TL;DR: Every scalar function is registered into a process-wide map at static-initialisation time. A call is resolved in three steps: name → alias → canonical name, then type matching against a declarative signature string like
"(const String, String, [const NativeInteger]) -> Array(String)", then abuild()that freezes the choice into anIFunctionBase.
Ask ClickHouse to evaluate splitByChar(',', s) and four separate subsystems
have to agree before a single byte is formatted:
- Something must turn the name
splitByCharinto a creator function. - Something must decide which overload of
splitByCharthis is — the three-argument form or the two-argument one — based on the argument types. - Something must decide what the result type is, which for many functions depends on the argument types and sometimes on the value of a constant argument.
- Something must decide how to execute it, and that decision has layers:
NULLs, constants,
LowCardinality, sparse columns, replicated columns.
This article walks the machinery that does 1–3, and the parts of 4 that live in the framework rather than in the individual function.
Registration: a static map, populated before main
There is no registerFunction("splitByChar", ...) call anywhere. Functions
register themselves through a macro that creates a static object whose
constructor inserts into a global map:
// src/Common/register_objects.h:18
struct FunctionRegister
{
FunctionRegister(std::string_view name, FunctionRegisterFunctionPtr func_ptr)
{
FunctionRegisterMap::instance().emplace(std::move(name), func_ptr);
}
};// src/Common/register_objects.h:28
#define REGISTER_FUNCTION_IMPL(fn, func_name, register_name) \
void func_name(::DB::FunctionFactory & factory); \
static ::DB::FunctionRegister register_name(#fn, func_name); \
void func_name(::DB::FunctionFactory & factory)The static ::DB::FunctionRegister register_name(...) line is the whole
trick: a namespace-scope object with a constructor, so it runs during static
initialisation of its translation unit. By the time main() starts, every
translation unit that contains a function has already inserted its
registration function into the map.
The map itself is tiny:
// src/Common/register_objects.h:13
struct FunctionRegisterMap : public std::unordered_map<std::string_view, FunctionRegisterFunctionPtr>
{
static FunctionRegisterMap & instance();
};The actual registration into the factory happens later, in one place:
// src/Functions/registerFunctions.cpp:10
void registerFunctions();
void registerFunctions()
{
/// Registration runs once and allocates while building static function metadata (for example
/// HMAC enumerates the OpenSSL digests through a C callback). It must not be subject to the
/// memory tracker's limit or fault injection, so block tracking for its duration.
MemoryTrackerBlockerInThread blocker;
auto & factory = FunctionFactory::instance();
for (const auto & [_, reg] : FunctionRegisterMap::instance())
reg(factory);
}The MemoryTrackerBlockerInThread is a small but telling detail: the
registration phase runs once at startup, allocates (the HMAC function
enumerates OpenSSL digests through a C callback), and must not be subject to
the query memory limits or to fault injection, because it is not part of any
query.
There are 840 REGISTER_FUNCTION call sites under src/Functions
alone. Each one expands to a free function taking a FunctionFactory &:
// src/Functions/URL/URLHierarchy.cpp:111
REGISTER_FUNCTION(URLPathHierarchy)
{
FunctionDocumentation::Description description = R"(
Returns an array containing the path component of the URL, truncated at the end by the symbols `/`, `?` and `#`. Unlike `URLHierarchy`, the result does not include the protocol and host — it starts from the path. Consecutive separator characters are counted as one.
)";
FunctionDocumentation::Syntax syntax = "URLPathHierarchy(url)";
FunctionDocumentation::Arguments arguments = {
{"url", "The URL to process.", {"String"}}
};
FunctionDocumentation::ReturnedValue returned_value = {"Returns an array of progressively longer URL path components forming a hierarchy.", {"Array(String)"}};
FunctionDocumentation::Examples examples = {{"Basic usage", "SELECT URLPathHierarchy('https://example.com/a/b?c=1')", "['/a/','/a/b?','/a/b?c=1']"}};
FunctionDocumentation::IntroducedIn introduced_in = {1,1};
FunctionDocumentation::Category category = FunctionDocumentation::Category::URL;
FunctionDocumentation documentation = {description, syntax, arguments, {}, returned_value, examples, introduced_in, category};
factory.registerFunction<FunctionURLPathHierarchy>(documentation);
}Notice that the registration is type-erased: the factory stores a
std::function, not a template. The template is used only at registration
time to extract the function’s static name and its create factory method:
// src/Functions/FunctionFactory.h:34
template <typename Function>
void registerFunction(FunctionDocumentation documentation, Case case_sensitiveness = Case::Sensitive)
{
registerFunction<Function>(Function::name, std::move(documentation), case_sensitiveness);
}// src/Functions/FunctionFactory.h:95
template <typename Function>
void registerFunction(const std::string & name, FunctionDocumentation documentation, Case case_sensitiveness = Case::Sensitive)
{
registerFunction(name, &Function::create, std::move(documentation), case_sensitiveness);
}So &Function::create — a static FunctionPtr create(ContextPtr) — becomes
a std::function<FunctionOverloadResolverPtr(ContextPtr)>. The template
knowledge is gone by the time the factory holds it.
The factory’s data structures
The factory holds two maps of functions, not one:
// src/Functions/FunctionFactory.h:82
private:
using Functions = std::unordered_map<std::string, Value>; // STYLE_CHECK_ALLOW_STD_CONTAINERS
Functions functions;
Functions case_insensitive_functions;
std::unordered_map<std::string, SecretArgumentsSpec> secret_arguments; // STYLE_CHECK_ALLOW_STD_CONTAINERS
const Functions & getMap() const override { return functions; }
const Functions & getCaseInsensitiveMap() const override { return case_insensitive_functions; }
String getFactoryName() const override { return "FunctionFactory"; }The base class IFactoryWithAliases contributes the alias tables:
// src/Common/IFactoryWithAliases.h:131
private:
using InnerMap = std::unordered_map<String, Value>; // name -> creator
using AliasMap = std::unordered_map<String, String>; // alias -> original name
virtual const InnerMap & getMap() const = 0;
virtual const InnerMap & getCaseInsensitiveMap() const = 0;
virtual String getFactoryName() const = 0;
/// Alias map to data_types from previous two maps
AliasMap aliases{};
/// Case insensitive aliases
AliasMap case_insensitive_aliases{};
};So there are four maps in total: exact-name functions, lowercased functions,
exact-name aliases, and lowercased aliases. Plus a fifth, case_insensitive_name_mapping,
which records the canonical spelling of a case-insensitively registered
function so the factory can recover it later.
Case-insensitive registration
Most ClickHouse functions are case-sensitive: splitByChar and
splitbychar are different functions (the latter does not exist). But a
handful are SQL-standard names that must be callable in any case:
toDayOfWeek has DAYOFWEEK and DAY_OF_WEEK aliases; dateDiff is
registered case-insensitively; JSONArrayLength has a JSON_ARRAY_LENGTH
alias.
Registering a function case-insensitively does two things:
// src/Functions/FunctionFactory.cpp:39
void FunctionFactory::registerFunction(
const std::string & name,
FunctionCreator creator,
FunctionDocumentation documentation,
Case case_sensitiveness)
{
if (!functions.emplace(name, FunctionFactoryData{creator, documentation}).second)
throw Exception(ErrorCodes::LOGICAL_ERROR, "FunctionFactory: the function name '{}' is not unique", name);
String function_name_lowercase = Poco::toLower(name);
if (isAlias(name) || isAlias(function_name_lowercase))
throw Exception(ErrorCodes::LOGICAL_ERROR, "FunctionFactory: the function name '{}' is already registered as alias",
name);
if (case_sensitiveness == Case::Insensitive)
{
if (!case_insensitive_functions.emplace(function_name_lowercase, FunctionFactoryData{creator, documentation}).second)
throw Exception(ErrorCodes::LOGICAL_ERROR, "FunctionFactory: the case insensitive function name '{}' is not unique",
name);
case_insensitive_name_mapping[function_name_lowercase] = name;
}
}Three things to note:
- The function is inserted into
functionsunder its exact name always. A case-insensitive function is in both maps: the exact one and the lowercased one. That meanstoDayOfWeekis found by the exact lookup, andTODAYOFWEEKby the lowercased one — and both find the same creator. - Uniqueness is enforced at registration time for both maps. A duplicate
name is a
LOGICAL_ERRORat startup, not a runtime surprise. - A name that is already an alias cannot be registered as a function. This check runs for the exact name and the lowercased name, so an alias registered case-insensitively blocks a function whose name lowercases to it.
The alias machinery itself is shared with the aggregate-function factory and
the table-function factory, which is why it lives in Common:
// src/Common/IFactoryWithAliases.h:31
/// Resolve an alias to the name its target was registered under, or return `name`
/// unchanged when it is not an alias. Both alias maps are consulted, so a
/// case-insensitive alias resolves through any spelling of it.
String getAliasToOrName(const String & name) const
{
if (aliases.contains(name))
return aliases.at(name);
if (String name_lowercase = Poco::toLower(name); case_insensitive_aliases.contains(name_lowercase))
return case_insensitive_aliases.at(name_lowercase);
return name;
}Name resolution: tryGetImpl
The lookup is a three-step process: alias resolution, then exact-name lookup, then lowercased lookup.
// src/Functions/FunctionFactory.cpp:132
FunctionOverloadResolverPtr FunctionFactory::tryGetImpl(
const std::string & name_param,
ContextPtr context) const
{
String name = getAliasToOrName(name_param);
FunctionOverloadResolverPtr res;
const FunctionCreator * canonical_creator = nullptr;
auto it = functions.find(name);
if (functions.end() != it)
{
res = it->second.first(context);
canonical_creator = &it->second.first;
}
else
{
name = Poco::toLower(name);
it = case_insensitive_functions.find(name);
if (case_insensitive_functions.end() != it)
{
res = it->second.first(context);
auto cn_it = case_insensitive_name_mapping.find(name);
if (cn_it != case_insensitive_name_mapping.end())
{
auto fn_it = functions.find(cn_it->second);
if (fn_it != functions.end())
canonical_creator = &fn_it->second.first;
}
}
}
if (!res)
return nullptr;
if (CurrentThread::isInitialized())
{
auto query_context = CurrentThread::get().tryGetQueryContext();
if (query_context && query_context->getSettingsRef()[Setting::log_queries])
query_context->addQueryFactoriesInfo(Context::QueryLogFactories::Function, name);
/// There is a legacy toTime function that has the same name as toTime function for Time data type, so we need to
/// check this setting here and decide if we need to change the function to get
if (query_context && Poco::toLower(name) == "totime" && query_context->getSettingsRef()[Setting::use_legacy_to_time])
{
it = functions.find(ToTimeWithFixedDateImpl::name);
if (functions.end() != it)
{
res = it->second.first(context);
canonical_creator = &it->second.first;
}
}
}
if (canonical_creator)
res->setFactoryHandle(canonical_creator);
return res;
}The canonical_creator deserves attention. It is the address of the
std::function in the map — the same object for every spelling of a
function. The resolver stores it:
// src/Functions/IFunction.h:435
/// has same address for all aliases / case variants of a function
/// nullptr when the function is constructed outside the factory
const FunctionCreator * getFactoryHandle() const { return factory_handle; }
void setFactoryHandle(const FunctionCreator * h) const { factory_handle = h; }This pointer identity is what lets the query planner prove that two different-looking calls are the same function. The planner groups function nodes by creator address to build structural equivalence classes:
// src/Interpreters/ActionsDAG.cpp:1493
const auto * factory_handle = node.function_base->getFactoryHandle();
if (!factory_handle)
continue;
/// for name-sensitive functions keep raw children so siblings already merged by ec
/// do not drag the parent into the same class
const bool look_through_aliases = node.function_base->isNameInsensitive();and then:
// src/Interpreters/ActionsDAG.cpp:1457
std::unordered_map<const FunctionCreator *, std::vector<FunctionCandidate>> functions_by_creator;So f(x) and F(x) (if F is a case-insensitive alias of f) are the same
creator and get merged. But the merge only looks through the arguments
when the function’s result does not depend on column names:
// src/Functions/IFunction.h:405
/// True if the result depends only on argument values, not column names. formatRowNoNewline
/// and toTypeName are counter examples. Default is conservative
virtual bool isNameInsensitive() const { return false; }For a function whose output does embed the column name
(formatRowNoNewline, toTypeName), merging two calls that happen to have
the same shape but different names would change the result, so the
equivalence class keeps the raw children instead.
The toTime special case at the end is a wart of history: the toTime name
is wanted for two different things — the conversion to the Time type, and
a legacy conversion from DateTime that pins the date to 1970-01-02 — and a
setting picks between them. The factory resolves this at lookup time by
re-resolving the name to a different registered function
(toTimeWithFixedDate) and overwriting the resolver.
Also note the log_queries hook: every resolved function name is recorded
into the query context so that system.query_log can report which functions
a query used.
The “maybe you meant” hint
When a name is not found at all, the factory does not just say “unknown function”. It runs a Levenshtein-based suggester:
// src/Functions/FunctionFactory.cpp:87
FunctionOverloadResolverPtr FunctionFactory::getImpl(
const std::string & name,
ContextPtr context) const
{
auto res = tryGetImpl(name, context);
if (!res)
{
String extra_info;
if (AggregateFunctionFactory::instance().hasNameOrAlias(name))
extra_info = ". There is an aggregate function with the same name, but ordinary function is expected here";
auto hints = this->getHints(name);
if (!hints.empty())
throw Exception(ErrorCodes::UNKNOWN_FUNCTION, "Unknown function {}{}. Maybe you meant: {}", name, extra_info, toString(hints));
throw Exception(ErrorCodes::UNKNOWN_FUNCTION, "Unknown function {}{}", name, extra_info);
}
return res;
}The suggester itself is a generic helper with a clang-simple-typo-corrector flavour:
// src/Common/NamePrompter.h:33
static void appendToQueue(size_t ind, const String & name, DistanceIndexQueue & queue, const VectorWithMemoryTracking<String> & prompting_strings)
{
const String & prompt = prompting_strings[ind];
/// Clang SimpleTypoCorrector logic
const size_t min_possible_edit_distance = std::abs(static_cast<int64_t>(name.size()) - static_cast<int64_t>(prompt.size()));
const size_t mistake_factor = (name.size() + 2) / 3;
if (min_possible_edit_distance > 0 && name.size() / min_possible_edit_distance < 3)
return;
if (prompt.size() <= name.size() + mistake_factor && prompt.size() + mistake_factor >= name.size())
{
size_t distance = levenshteinDistanceCaseInsensitive(prompt, name);
if (distance <= mistake_factor)
{
queue.emplace(distance, ind);
if (queue.size() > MaxNumHints)
queue.pop();
}
}
}The mistake_factor scales with the length of the mistyped name: a
three-character name tolerates one edit, a thirty-character name tolerates
ten. The min_possible_edit_distance check is a cheap pre-filter — if the
lengths differ by more than a third of the name, the candidate is hopeless
and the Levenshtein computation is skipped entirely. This matters: the
candidate list is all 840 functions plus all aliases, and computing
Levenshtein for each would be wasteful on a path that only runs when a
query fails to parse.
Overload resolution: the declarative signature
This is the part that changed most in recent ClickHouse, and it is the most interesting.
Historically, each function implemented getReturnTypeImpl(const DataTypes &)
by hand: a switch over type IDs, throwing ILLEGAL_TYPE_OF_ARGUMENT with a
bespoke message. ClickHouse has been migrating that to a declarative
signature string that the framework parses, matches, and uses to compute the
result type.
The DSL is documented in the header:
// src/DataTypes/FunctionSignature.h:32
/** Declarative function signature.
*
* Responsibilities:
* - check types (and constness) of function arguments;
* - compute result type from argument types and values of constant arguments;
* - print documentation about function signature;
* - print understandable error messages on type/constness mismatch.
*
* Examples of supported signatures:
*
* transform(T, const Array(T), const Array(U), U) -> U
* array(T1, ...) -> Array(leastSupertype(T1, ...))
* toFixedString(String, const N UnsignedInteger) -> FixedString(N)
* arrayReduce(const agg String, Array(T1), ...) -> typeFromString(agg)
* multiIf(cond1 UInt8, then1 T1, [cond2 UInt8, then2 T2, ...], else U) -> leastSupertype(T1, ..., U)
* reverse(T : StringOrFixedString) -> T OR reverse(T : Array) -> T
*
* Grammar:
*
* func_name(args_description) -> return_description
* [WHERE constraints]
* [OR ...alternative...]
*
* Argument description:
*
* [const] [arg_name] type_description
*
* Type description:
*
* TypeName | TypeMatcher | TypeName : TypeMatcher
Note toFixedString(String, const N UnsignedInteger) -> FixedString(N): the
return type depends on the value of a constant argument. That is the
“return type depends on arguments” trick in its most general form — the
result type is a function of the argument types and the values of the
constant arguments.
A function opts in by overriding one method:
// src/Functions/FunctionTokens.h:88
/// Per-Generator opt-in to declarative signatures: when the Generator defines a
/// `signature` static member the framework uses it for type checking and return-type
/// resolution; otherwise we fall back to the legacy `Generator::checkArguments` path
/// which always returns `Array(String)`.
String getSignatureString() const override
{
if constexpr (requires { Generator::signature; })
return Generator::signature;
else
return {};
}The if constexpr (requires { ... }) idiom is the migration mechanism. A
function either has a signature member (new style) or does not (old
style); the compiler picks the branch, so there is no runtime flag and no
virtual dispatch.
Here is a real signature, from splitByString:
// src/Functions/splitByString.cpp:39
static constexpr auto signature = "(const String, String, [const NativeInteger]) -> Array(String)";Read it: argument 1 must be a const String (the separator), argument 2 is a
String, argument 3 is an optional const NativeInteger (the max-substrings
limit), and the result is Array(String).
And one with alternatives, from the hex encoder:
// src/Functions/FunctionsBinaryRepresentation.cpp:244
String getSignatureString() const override
{
/// Mirrors the set of types the legacy `getReturnTypeImpl` accepted on
/// master. Date / DateTime / DateTime64 are not handled explicitly in
/// `executeImpl`, but their columns are dispatched through
/// `tryExecuteUIntOrInt` (Date = `ColumnVector<UInt16>`, DateTime =
/// `ColumnVector<UInt32>`) and `tryExecuteDecimal` (DateTime64).
return "(Number) -> String"
" OR (StringOrFixedString) -> String"
" OR (DateOrDateTime) -> String"
" OR (UUID) -> String"
" OR (IPv4) -> String"
" OR (IPv6) -> String"
" OR (AggregateFunction) -> String";
}The OR is an ordered list of alternatives. The first that matches wins.
This is where “overload resolution” now lives — not in hand-written C++
overrides but in a string.
How the DSL is parsed and cached
The signature string is parsed by a recursive-descent parser that reuses
ClickHouse’s SQL tokenizer (TokenIterator, BareWord, QuotedIdentifier,
…). The parsed result is a tree of type matchers and type functions:
- Type matchers answer “does this type match?” and may capture a type
into a variable.
T : Numbercaptures the argument’s type intoT. - Type functions compute a type from the captured variables:
leastSupertype(T1, ...),Array(T),FixedString(N),typeFromString(s).
The framework registers 47 no-argument type matchers and 55 type functions. They are looked up by name in a thread-safe registry:
// src/DataTypes/FunctionSignature.h:111
namespace FunctionSignatures
{
/// A simple thread-safe registry, used for TypeMatchers and TypeFunctions.
template <typename What, typename... Args>
class Factory
{
private:
using Creator = std::function<What(Args...)>;
using Dict = std::map<std::string, Creator>;Parsing is done once per signature string and cached globally:
// src/Functions/IFunction.cpp:1045
namespace
{
/// Parse and cache function signatures globally.
DataTypePtr applyFunctionSignature(const String & signature_str, const String & function_name, const ColumnsWithTypeAndName & arguments, bool types_only = false, bool propagate_nullability = false, int wrong_arity_error_code = ErrorCodes::NUMBER_OF_ARGUMENTS_DOESNT_MATCH)
{
static std::mutex cache_mutex;
static std::unordered_map<String, std::shared_ptr<FunctionSignature>> cache; // STYLE_CHECK_ALLOW_STD_CONTAINERS
// src/Functions/IFunction.cpp:1065
std::shared_ptr<FunctionSignature> sig;
{
std::lock_guard lock(cache_mutex);
auto it = cache.find(signature_str);
if (it == cache.end())
{
sig = std::make_shared<FunctionSignature>(signature_str);
cache.emplace(signature_str, sig);
}
else
sig = it->second;
}The cache is keyed by the signature string, so all functions that share a
signature string share one parsed tree. It is a static local, so it
lives for the process lifetime, and it is guarded by a mutex — parsing
happens once, but matching happens on every query, concurrently.
The Context::SuppressQueryFactoriesInfoScope suppress_factory_info; line
in the same function deserves a note. Parsing a signature resolves type names
through DataTypeFactory and aggregate names through
AggregateFunctionFactory, and those record themselves in
query_log.used_data_type_families / used_aggregate_functions. But a
signature’s type names come from the framework’s source text, not from the
user’s query, so recording them would pollute the user-facing attribution.
The scope suppresses that tracking for the duration of the parse-and-check.
Matching
The matcher is a two-phase thing. First, type matchers assign variables:
// src/DataTypes/FunctionSignature.cpp:346
bool match(const DataTypePtr & what, Variables & vars, size_t iteration, size_t arg_num, std::string & out_reason) const override
{
if (impl && !impl->match(what, vars, iteration, arg_num, out_reason))
return false;
return vars.assignOrCheck(var_key.incrementIndex(iteration), what, arg_num, out_reason);
}assignOrCheck is where the “all occurrences of T must be the same type”
rule lives:
// src/DataTypes/FunctionSignature.cpp:150
bool assignOrCheck(const Key & key, const DataTypePtr & var, size_t arg_num, std::string & out_reason)
{
if (auto it = container.find(key); it == container.end())
{
container.emplace(key, Value(var, arg_num));
return true;
}
else
{
if (it->second == Value(var))
return true;
out_reason = "value provided as " + DB::toString(arg_num + 1) + getOrdinalSuffix(arg_num + 1)
+ " argument must be of type " + key.toString()
+ " that was captured as " + it->second.toString();Second, the return-type expression is evaluated over the captured
variables. That is where -> Array(String) or -> FixedString(N) is
computed.
The const marker is checked separately, and it is checked against the
column, not just the type:
// src/DataTypes/FunctionSignature.cpp:398
/// Constness handling. A `const` position requires a constant argument. The
/// column is null both for a genuinely non-constant argument (the normal
/// `getReturnTypeImpl(ColumnsWithTypeAndName)` path) and for every argument on
/// the column-less types-only path, so `vars.types_only` tells them apart.
/// Rejecting a non-constant argument here lets `OR` alternatives fall through to
/// a non-const variant (e.g. the `allow_nonconst_timezone_arguments` fallback).
if (is_const)
{
const bool known_non_const = column ? !isColumnConst(*column) : !vars.types_only;
if (known_non_const)
{
out_reason = "value provided as " + DB::toString(arg_num + 1) + getOrdinalSuffix(arg_num + 1)
+ " argument" + (argument_name.name.empty() ? "" : " '" + argument_name.name + "'")
+ " must be " + toString() + ", but it is not constant";
return false;
}
}And when the argument is const and named, its value is captured too,
which is what makes -> FixedString(N) possible:
// src/DataTypes/FunctionSignature.cpp:416
if (!argument_name.name.empty())
{
auto key = argument_name.incrementIndex(iteration);
if (is_const && column)
{
if (!vars.assignOrCheck(key, typeid_cast<const ColumnConst &>(*column).getField(), arg_num, out_reason))
return false;
}
else if (is_const)
{
/// Types-only path (a non-const argument would have been rejected above):
/// the const value cannot be captured. Remember this so that, if the result
/// type depends on it, `Variables::get` raises a clean `ILLEGAL_COLUMN`
/// instead of an internal `BAD_FUNCTION_SIGNATURE`.
vars.const_value_unavailable.insert(key);
}
}The const_value_unavailable set is a careful piece of design. On the
types-only path (the legacy getReturnTypeImpl(DataTypes) entry point, where
there are no columns at all) a const argument’s value cannot be captured.
If the result type depends on that value, the framework must report a clean
ILLEGAL_COLUMN — “this argument must be constant” — rather than an
internal BAD_FUNCTION_SIGNATURE.
Alternatives and error messages
The OR alternatives are tried in order, each with a fresh variable scope:
// src/DataTypes/FunctionSignature.cpp:972
DataTypePtr check(const ColumnsWithTypeAndName & args, Variables & vars, std::string & out_reason) const override
{
if (alternatives.size() == 1)
return alternatives.front()->check(args, vars, out_reason);
std::vector<std::string> reasons;
for (const auto & alternative : alternatives)
{
vars.reset();
reasons.emplace_back();
try
{
if (DataTypePtr res = alternative->check(args, vars, reasons.back()))
return res;
}
catch (const Exception & e)
{and the failure message lists every alternative and why it did not match:
// src/DataTypes/FunctionSignature.cpp:1010
WriteBufferFromOwnString out;
out << "None of the alternative function signatures matched.";
size_t num_alternatives = alternatives.size();
for (size_t i = 0; i < num_alternatives; ++i)
out << " Variant " << alternatives[i]->toString() << " doesn't match because " << reasons[i] << ".";
out_reason = out.str();
return nullptr;The comment above that block explains a constraint that is easy to miss:
// src/DataTypes/FunctionSignature.cpp:1005
/// Keep the reason on a single line: it is surfaced as an exception message
/// (`Arguments of function ... do not match its signature (...): <reason>`), and
/// embedded newlines break single-line consumers — `system.errors`/log parsing and,
/// concretely, the `sed 's/^\(Code: [0-9]\+\).*$/\1/g'` filter in shell tests such as
/// `00921_datetime64_compatibility_long`, which only collapses the first line.
That is a comment about ClickHouse’s own test suite reaching into the error message format. It is the kind of thing that makes an error-message contract a real contract.
Distinguishing arity errors from type errors
When the signature does not match, the framework has to pick an error code.
The legacy codes differ per function (TOO_FEW_ARGUMENTS_FOR_FUNCTION,
TOO_MANY_ARGUMENTS_FOR_FUNCTION, NUMBER_OF_ARGUMENTS_DOESNT_MATCH,
BAD_ARGUMENTS), and those codes are part of the user-visible contract, so
adopting a declarative signature must not change them.
The framework does this with a string heuristic on the reason text:
// src/Functions/IFunction.cpp:1093
std::string reason;
DataTypePtr result = sig->check(arguments, reason, types_only);
if (!result)
{
/// Surface the legacy arity code (`NUMBER_OF_ARGUMENTS_DOESNT_MATCH` by default,
/// or whatever the function's `getWrongNumberOfArgumentsErrorCode` selects) when every
/// alternative failed for arity reasons only. The DSL phrases arity
/// problems with one of three exact prefixes ("too few arguments...",
/// "too many arguments...", "number of arguments..."); type problems
/// never start with those words. So: if the reason contains any of
/// those phrases *and* nothing that looks like a type mismatch
/// (a matcher's "value provided as Nth argument ... has type X that is not Y" prefix),
/// we're confident this is an arity-only failure. This keeps the arity
/// diagnostics of a function unchanged after it adopts a declarative signature.
const bool mentions_arity =
reason.contains("too few arguments")
|| reason.contains("too many arguments")
|| reason.contains("number of arguments");plus an authoritative structural check as a backstop:
// src/Functions/IFunction.cpp:1125
/// Authoritative arity signal: when the call has more or fewer arguments than
/// *any* alternative of the signature can accept, this is unambiguously an
/// arity error regardless of which per-alternative reason the matcher reported
/// (a too-many-arguments alternative and a type-mismatch alternative can both
/// appear in the combined reason for a multi-`OR` signature). The string
/// heuristic below still handles in-range arity problems the bounds can't see,
/// such as a parity requirement (`caseWithExpression`: `4 + n * 2`).
const bool argument_count_out_of_range = !sig->isArgumentCountInRange(arguments.size());
int code = ErrorCodes::ILLEGAL_TYPE_OF_ARGUMENT;
if (argument_count_out_of_range || (mentions_arity && !mentions_type_mismatch && !mentions_non_const))
code = wrong_arity_error_code;
else if (mentions_non_const && !mentions_type_mismatch)
code = ErrorCodes::ILLEGAL_COLUMN;That is a hack, honestly labelled as one in the surrounding comments. It exists because the alternative — changing the error codes — would break users’ queries and ClickHouse’s own test suite.
From resolver to executable: build
Once the type matching succeeds, the resolver freezes the choice:
// src/Functions/IFunction.cpp:937
FunctionBasePtr IFunctionOverloadResolver::build(const ColumnsWithTypeAndName & arguments) const
{
FunctionBasePtr base;
/// Use FunctionBaseDynamicAdaptor if default implementation for Dynamic is enabled and we have Dynamic type in arguments.
if (useDefaultImplementationForDynamic())
{
checkNumberOfArguments(arguments.size());
for (const auto & arg : arguments)
{
if (isDynamic(arg.type))
{
DataTypes data_types(arguments.size());
for (size_t i = 0; i < arguments.size(); ++i)
data_types[i] = arguments[i].type;
base = std::make_shared<FunctionBaseDynamicAdaptor>(shared_from_this(), std::move(data_types));
break;
}
}
}
/// Use FunctionBaseVariantAdaptor if default implementation for Variant is enabled and we have Variant type in arguments.
if (!base && useDefaultImplementationForVariant())
{
checkNumberOfArguments(arguments.size());
for (size_t i = 0; i != arguments.size(); ++i)
{
const auto & type = arguments[i].type;
if (isVariant(type))
{
/// A custom-named `Variant` (e.g. `Geometry`) can be excluded separately, so a function
/// handles it directly and keeps the custom name, while ordinary `Variant` inputs still go
/// through the adaptor. Keep scanning: another argument may still need the adaptor.
if (type->hasCustomName() && !useDefaultImplementationForVariantWithCustomName(type))
continue;
ColumnsWithTypeAndName args_copy = arguments;
base = std::make_shared<FunctionBaseVariantAdaptor>(shared_from_this(), std::move(args_copy), i);
break;
}
}
}
if (!base)
{
auto return_type = getReturnType(arguments);
base = buildImpl(arguments, return_type);
}
if (base && factory_handle)
base->setFactoryHandle(factory_handle);
return base;
}The important thing here is that build is where the concrete function
object is created, with the argument types and the result type baked in.
buildImpl is the virtual that each function family overrides — for the
generic case, the adaptor turns the IFunction into an IFunctionBase:
// src/Functions/IFunctionAdaptors.h:187
FunctionBasePtr buildImpl(const ColumnsWithTypeAndName & arguments, const DataTypePtr & result_type) const override
{
DataTypes data_types(arguments.size());
for (size_t i = 0; i < arguments.size(); ++i)
data_types[i] = arguments[i].type;
return std::make_unique<FunctionToFunctionBaseAdaptor>(function, data_types, result_type);
}The three-layer type hierarchy (IFunction → IFunctionBase →
IExecutableFunction) exists so that the expensive preparation can happen
once and the execution can be repeated:
// src/Functions/IFunction.h:218
/// Do preparations and return executable.
/// sample_columns should contain data types of arguments and values of constants, if relevant.
virtual ExecutableFunctionPtr prepare(const ColumnsWithTypeAndName & arguments) const = 0;prepare() is called once per query-plan node; execute() is called once
per block. A function that needs to precompute something expensive (parse a
format string, compile a regexp, build a hash table) does it in prepare().
The ActionsDAG node holds both, and calls prepare at plan-build time:
// src/Interpreters/ActionsDAG.cpp:786
Node node;
node.type = ActionType::FUNCTION;
node.children = std::move(children);
node.function_base = function_base;
node.result_type = result_type;
node.function = node.function_base->prepare(arguments);
tryFoldFunctionToConstant(node, arguments, all_const, /*best_effort=*/false);Constant folding
Constant folding happens right there, at plan-build time, immediately after
prepare:
// src/Interpreters/ActionsDAG.cpp:122
void tryFoldFunctionToConstant(
ActionsDAG::Node & node,
const ColumnsWithTypeAndName & arguments,
bool all_const,
bool best_effort)
{
/// If all arguments are constants, and function is suitable to be executed in 'prepare' stage - execute function.
if (!node.function_base->isSuitableForConstantFolding())
return;
ColumnPtr column;
try
{
if (all_const)
{
size_t num_rows = arguments.empty() ? 0 : arguments.front().column->size();
column = node.function->execute(arguments, node.result_type, num_rows, true);
}
else
{
column = node.function_base->getConstantResultForNonConstArguments(arguments, node.result_type);
}
}
catch (const Exception &)
{
if (!best_effort)
throw;
return;
}Two paths. If all arguments are constant, the function is executed once with a dry-run flag. If only some are constant, the function gets a chance to compute the answer itself:
// src/Functions/IFunction.h:272
/** If function isSuitableForConstantFolding then, this method will be called during query analysis
* if some arguments are constants. For example logical functions (AndFunction, OrFunction) can
* return the result based on some constant arguments.
* Arguments are passed without modifications, useDefaultImplementationForNulls, useDefaultImplementationForNothing,
* useDefaultImplementationForConstants, useDefaultImplementationForLowCardinality are not applied.
*/
virtual ColumnPtr getConstantResultForNonConstArguments(
const ColumnsWithTypeAndName & /* arguments */, const DataTypePtr & /* result_type */) const { return nullptr; }So x AND TRUE can be folded to x by the and function itself, and
x AND FALSE to a constant false — without the function’s normal
execution path running at all.
isSuitableForConstantFolding is the opt-out, and its comment names the
canonical example:
// src/Functions/IFunction.h:266
/** Should we evaluate this function while constant folding, if arguments are constants?
* Usually this is true. Notable counterexample is function 'sleep'.
* If we will call it during query analysis, we will sleep extra amount of time.
*/
virtual bool isSuitableForConstantFolding() const { return true; }The default-implementation cascade
The last piece of framework machinery worth understanding is the set of
useDefaultImplementationFor* switches. They let a function decline the
framework’s automatic handling of certain column shapes, and they are the
reason IFunction has so many virtual methods.
The framework handles, in order: Replicated columns, Sparse columns,
LowCardinality columns, Nothing type, all-constant arguments, and
Nullable arguments. The dispatch reads like a chain of nested function
calls:
// src/Functions/IFunction.cpp:520
ColumnPtr IExecutableFunction::executeWithoutLowCardinalityColumns(
const ColumnsWithTypeAndName & args, const DataTypePtr & result_type, size_t input_rows_count, bool dry_run) const
{
if (auto res = defaultImplementationForNothing(args, result_type, input_rows_count))
return res;
if (auto res = defaultImplementationForConstantArguments(args, result_type, input_rows_count, dry_run))
return res;
if (auto res = defaultImplementationForNulls(args, result_type, input_rows_count, dry_run))
return res;
ColumnPtr res;
if (dry_run)
res = executeDryRunImpl(args, result_type, input_rows_count);
else
res = executeImpl(args, result_type, input_rows_count);
if (!res)
throw Exception(ErrorCodes::LOGICAL_ERROR, "Empty column was returned by function {}", getName());
return res;
}And the outer layers:
// src/Functions/IFunction.cpp:667
ColumnPtr IExecutableFunction::execute(
const ColumnsWithTypeAndName & arguments, const DataTypePtr & result_type, size_t input_rows_count, bool dry_run) const
{
checkFunctionArgumentSizes(arguments, input_rows_count);
if (useDefaultImplementationForReplicatedColumns())
{The Nullable handling is the most intricate, because it has to decide what
the function sees in the rows that are NULL. The framework’s default is:
strip the NULL map, run the function on the nested columns (where NULL rows
contain arbitrary values), and mask the result. The rule is spelled out in
the interface:
// src/Functions/IFunction.h:82
/** Default implementation in presence of Nullable arguments or NULL constants as arguments is the following:
* if some of arguments are NULL constants then return NULL constant (the underlying function
* may or may not be executed in this case),
* if some of arguments are Nullable, then execute function as usual for columns,
* where Nullable columns are substituted with nested columns,
* and wrap result in Nullable column where NULLs are in all rows where any of arguments are NULL.
* The underlying function may or may not be called for the rows containing NULLs. When called,
* arbitrary values are used instead of NULLs - whatever values happened to be in ColumnNullable's
* nested column; typically default values, but that's not guaranteed.
* So this only makes sense for functions that don't fail and don't have side effects.
*/A function that can fail on the default value (like intDiv on a
zero denominator, or parseDateTime on garbage) opts out with
canBeExecutedOnDefaultArguments() == false:
// src/Functions/IFunction.h:125
/** True if function can be called on default arguments and won't throw.
* Counterexample: modulo(0, 0)
*
* Useful when executing on LowCardinality dictionary, which contains default value even if
* none of the rows use it.
*
* Also used when executing on Nullable columns: `createBlockWithNestedColumns` leaves the rows
* behind a NULL untouched, so a function that declines this contract is not executed on them
* either - they are filtered out first, and their result is masked out as NULL anyway. This
* means the nested value under a NULL is not observable for such a function, e.g.:
* select assumeNotNull(materialize(null::Nullable(Int32)) + 42) as x
* ┌──x─┐
* │ 42 │
* └────┘
* still holds for `plus` (which accepts the contract), while a declining function such as
* `modulo` yields the default of its result type there instead.
*/
virtual bool canBeExecutedOnDefaultArguments() const { return true; }When the function declines, the framework filters out the NULL rows before executing, then expands the result back:
// src/Functions/IFunction.cpp:364
/// A function that declines execution on default arguments must not be executed on the rows
/// behind a NULL either. `createBlockWithNestedColumns` does not overwrite those rows, so the
/// nested column keeps whatever it holds there - for a column that was built as `Nullable`
/// from the start that is the type's default value - and a function that throws on it fails
/// on entirely valid data, e.g. `parseDateTime` over a `Nullable(String)` containing a NULL.
/// The result for those rows is masked out by [[wrapInNullable]] anyway, so filter them out
/// before executing instead. This is only needed when the input really contains a NULL: an
/// argument that is `Nullable` but has no NULL in it keeps every regular path, including the
/// numeric fast path below.
const bool must_filter_null_rows
= !canBeExecutedOnDefaultArguments() && anyArgumentHasNullRows(args, input_rows_count);and later:
// src/Functions/IFunction.cpp:468
else
{
/// If short circuit is enabled, we only execute the function on rows with all arguments not null
/// Generate Filter
IColumn::Filter filter_mask(input_rows_count);
const auto & result_null_map_data = assert_cast<const ColumnUInt8 &>(*result_null_map).getData();
for (size_t i = 0; i < input_rows_count; ++i)
filter_mask[i] = !result_null_map_data[i];
/// Filter every column by mask
for (auto & col : temporary_columns)
col.column = col.column->filter(filter_mask, rows_without_nulls);
auto res = executeWithoutLowCardinalityColumns(temporary_columns, temporary_result_type, rows_without_nulls, dry_run);
auto mutable_res = IColumn::mutate(std::move(res));
mutable_res->expand(filter_mask, false);
auto new_res = wrapInNullable(std::move(mutable_res), std::move(result_null_map));
return new_res;
}The expand(filter_mask, false) call puts the results back into the right
rows, and the null map masks them.
Trade-offs
The declarative-signature approach buys real things:
- Uniform error messages. A function that opts in gets the same “value provided as 2nd argument has type X that is not Y” diagnostics as every other opted-in function, without writing a line of message-building code.
- Return types computed from constant values.
-> FixedString(N)is expressible; in the hand-written path it required a bespokegetReturnTypeImplthat read the constant column. - A parse cache. Signature strings are parsed once per process and shared across all functions that use the same string.
- Documentability. The signature string is also what
system.functionsshows, so documentation and enforcement cannot drift apart.
It also costs things:
- A DSL to learn. 47 matchers, 55 type functions, and a grammar with
ellipses, optional groups, repetition bounds,
ORalternatives andWHEREconstraints. The comment in the header says the goal is “signatures should be comprehensible by non-programmers” — but this is still a DSL. - String heuristics for error codes. The arity-vs-type discrimination by substring matching on the reason text is fragile, and the code says so.
- A migration seam. The
if constexpr (requires { Impl::signature; })idiom means the codebase now carries two ways of doing the same thing, and the “documentation-only” variant (signature_documentation) is a third thing that looks like the first but is not applied. The comments explaining the differences are long and careful, which is itself a sign of the cost. - Loss of the “return type depends on arguments” freedom. Some
functions genuinely need settings-driven validation or a type parsed from
a constant string —
CAST(x, 'SomeType')is the canonical case.CASTtherefore keeps its hand-writtengetReturnTypeImpl, and its signature is documentation only:
// src/Functions/CastOverloadResolver.cpp:196
String getSignatureString() const override
{
return "(Any, const t String) -> typeFromString(t)";
}The comment above it is a summary of everything the DSL cannot express:
// src/Functions/CastOverloadResolver.cpp:177
/// Documentation-only, and intentionally so: CAST is where a declarative signature stops being
/// a reasonable fit. Its result type is parsed from the const type-name argument
/// (`typeFromString`), but `getReturnTypeImpl` then applies rules that are not type-level:
/// - `validateDataType` rejects "suspicious" target types based on query settings
/// (`allow_suspicious_low_cardinality_types`, fixed-string sizes, ...) — a settings-driven
/// validation, not a type computation;
/// - three cast-type variants (plain / accurate / accurateOrNull) plus the internal `_CAST`
/// differ in Nullable behavior, with `accurateOrNull` wrapping only when the target cannot
/// already contain NULL (and an extra nested-type validation);
/// - `cast_keep_nullable` wraps the result in Nullable depending on the source's nullability;
/// - the source time zone is substituted into a tz-less DateTime/DateTime64 target.
/// Expressing this would require moving the settings-driven validations into the signature
/// machinery (they cannot be), so the legacy `getReturnTypeImpl` stays authoritative and the
/// signature is never applied by the framework. The signature string is therefore purely
/// descriptive; we still make it as informative as possible — `(Any, const t String) ->
/// typeFromString(t)` documents that the result type is the one named by the const second
/// argument. It is only rendered (e.g. in `system.functions`), never evaluated, so it does not
/// matter that CAST-as-overload-resolver would reach type deduction through the types-only path
/// where the const value is unavailable.
The lesson
The function factory is a good example of a framework that grows a second
mechanism rather than replacing the first. There is still a hand-written
getReturnTypeImpl in most functions, and there is now a declarative
signature DSL, and the framework has to know which one is authoritative for
each function, and how to reconcile their error codes.
But the direction is clear, and the pieces that make it work are worth naming:
- Type erasure at the boundary. The factory stores
std::functions. Templates are used only at registration. - Identity by address. The
FunctionCreator *handle lets the planner merge calls that differ only in spelling. - Parse once, match many. The signature cache turns a per-query operation into a per-process one.
- Fallbacks everywhere. The JIT regexp compiler, the expression
compiler, the
Dynamic/Variantadaptors — each falls back to the general engine rather than failing. That is the pattern that lets ClickHouse adopt a faster path incrementally, function by function, without ever breaking a query.