The high-level query API has two execution paths.
The normal C++ API builds typed expression objects through overloaded operators and field proxy objects. This path uses lambdas such as [](auto row) { ... } and does not require a compiler plugin.
Native builds may also run the optional akkara-query Clang plugin. In that path, supported typed lambdas such as [](const Profile& row) { ... } are rewritten at compile time into bytecode descriptors that can run directly over row bytes when possible.
Fields used in a query must be registered with AKKARADB_ENTITY or AKKARADB_QUERYABLE.
struct Profile { uint64_t id; std::string email; std::string name; uint32_t age;};
AKKARADB_ENTITY(Profile, id, email, name, age);Register normal field indexes for equality, membership, and numeric range predicates. Register prefix indexes when startsWith() or prefix like() must avoid a full field-index scan.
profiles.indexed<&Profile::email>() .indexed<&Profile::age>() .indexed<&Profile::name>() .prefixIndexed<&Profile::email>();Basic Query
Section titled “Basic Query”auto adults = profiles .query([](auto profile) { return profile.age >= 18; }) .limit(10) .toVector();query() returns a lazy view. It is evaluated when you iterate it or call a terminal method.
| Method | Result |
|---|---|
where(fn) | Adds another predicate with logical AND. |
limit(n) | Limits matched rows. |
first() | Returns std::optional<Entry>. |
any() | Returns bool. |
count() | Counts matched rows. |
toVector() | Materializes matched entries. |
Query result order is not a stable API contract. Sort materialized results in application code when order matters.
Supported Expressions
Section titled “Supported Expressions”| Expression | Example |
|---|---|
| Equality and comparison | profile.age >= 18 |
| Logical operators | profile.age >= 18 && profile.name != "Bob" |
| List membership | profile.name.in({"Alice", "Carol"}) |
| Negative membership | profile.age.notIn(std::vector<uint32_t>{17, 41}) |
| String prefix/search | profile.email.startsWith("a@"), profile.email.contains("@") |
| SQL-like string pattern | profile.email.like("a@%") |
| Optional checks | profile.nickname.isNull() |
| Nested fields | user.address.template field<&Address::city>() == "Tokyo" |
| Map lookup | user.tags.get("tier") == std::optional<std::string>{"gold"} |
Nested fields require template field<...>() because the proxy expression is a dependent template call.
Native Bytecode Rewrite
Section titled “Native Bytecode Rewrite”With the akkara-query Clang plugin, typed entity lambdas can be rewritten to akkaradb::query::bytecode::CompiledQueryDescriptor<T>.
auto adults = profiles.query([](const Profile& profile) { return profile.age >= 18 && profile.name == "Alice";});The generated descriptor stores bytecode, constants, field bindings, plan hints, and any owned captures. PackedTable::query(descriptor) creates a bytecode query view. When all referenced fields have raw readers, the view can evaluate the predicate directly against encoded row bytes; otherwise it decodes the entity and runs the same bytecode VM.
where() can also be rewritten when it is bytecode-composable:
auto view = profiles.query([](const Profile& profile) { return profile.age >= 18;});
auto bobby = view.where([](const Profile& profile) { return profile.name == "Bobby";});Direct chains such as profiles.query(...).where(...) and variables that hold a rewritten query view are both supported. Unsupported where lambdas are left as normal decoded predicate filters. Unsupported query lambdas may still be wrapped as a generated HostCallBool descriptor.
Practical Rules
Section titled “Practical Rules”Use [](auto row) for the normal high-level expression DSL. Use [](const Entity& row) when the Clang plugin should rewrite the lambda into native bytecode.
Register indexes immediately after opening a table. Add indexed<&Field>() for exact lookup, IN, negative membership, and numeric range predicates. Add prefixIndexed<&Field>() separately for hot startsWith() or simple like("prefix%") predicates.
Keep map and nested-field predicates for expressiveness, but do not assume they are index-seeded unless a top-level indexed predicate is also present.
Advanced
Section titled “Advanced”Query Lambda Boundary
Section titled “Query Lambda Boundary”The normal lambda passed to query() is called with a query proxy, not a real entity. It must return a query expression built from proxy fields and supported operators.
auto adults = profiles.query([](auto profile) { return profile.age >= 18 && profile.email.endsWith(".test"); // not a query DSL operator});The lambda itself is ordinary C++ and can use local constants or helper functions. The boundary is the return value: it must be an expression type that the query API understands. Helpers are fine only when they return query expressions over proxy fields.
The bytecode rewrite path has a different boundary. It receives a typed entity lambda and attempts to lower the returned C++ expression. Builtin comparisons, logical operators, arithmetic, supported string operations, captures, and visible static custom opcode registrations can be lowered. Unsupported row-dependent code falls back to a host call for query(...) and stays decoded for where(...).
Index Planning
Section titled “Index Planning”The planner looks for registered indexes that can reduce the candidate set. The full expression is still evaluated before a row is returned, so an index plan changes scan cost, not query semantics.
| Predicate | Plan |
|---|---|
field == literal | Equality range over a normal field index. |
numeric field >/>=/</<= literal | Ordered range over a normal field index for arithmetic non-bool fields. |
field.in(values) | One equality range per convertible value, with candidate primary-key dedupe. |
field != literal, notIn, isNotNull | Full normal field-index scan plus expression filtering. |
isNull | Equality range for the encoded empty optional on a normal field index. |
startsWith("prefix") | Prefix-index range when prefixIndexed<&Field>() is registered; otherwise a full normal field-index scan if indexed<&Field>() is registered. |
like("prefix%") | Prefix-index range when the pattern is a simple trailing % prefix and prefixIndexed<&Field>() is registered; otherwise a full normal field-index scan if available. |
like("exact") | Equality range over a normal field index. |
contains | Full normal field-index scan plus expression filtering. |
AND | The planner evaluates both sides and chooses the higher-scoring usable index plan. |
OR | If both sides can produce index plans, the planner scans the union of their ranges and deduplicates candidate primary keys. If either side cannot be indexed, the query falls back to table scan. |
When an AND expression contains multiple usable index plans, the planner uses a simple score to pick the candidate source.
| Plan kind | Relative score |
|---|---|
| Equality | 100 |
| String prefix index | 95 |
IN equality ranges | 90 |
Optional isNull equality | 85 |
| Ordered numeric range | 80 |
OR union | 70 |
| Full field-index scan | 10 |
These scores are heuristics, not a cost model based on table cardinality or index selectivity.
Literal Conversion
Section titled “Literal Conversion”Index ranges are built only when the literal can be converted to the field type.
| Field/literal case | Behavior |
|---|---|
std::string field with string-like literal | Converts through std::string_view. |
| Arithmetic field with arithmetic literal | Converts if the value fits in the field type. Negative values do not convert to unsigned fields. |
| Constructible or convertible field type | Uses construction or assignment conversion. |
| Unconvertible literal | The predicate cannot seed that index plan. |
IN values | Each convertible value creates an equality range; unconvertible values are skipped. |
The full query expression is still evaluated after loading candidates. Literal conversion only decides whether an index range can be produced.
LIKE Semantics
Section titled “LIKE Semantics”like() supports SQL-style wildcard matching.
| Pattern token | Meaning |
|---|---|
% | Matches zero or more characters. |
_ | Matches exactly one character. |
| Any other character | Matches itself. |
There is no documented escape syntax for treating % or _ as literal wildcard characters.
Only a simple trailing-percent pattern such as like("abc%") can use a prefix index. Patterns such as like("%abc"), like("a_c%"), like("a%b%"), or like("abc_") are still evaluated correctly, but they cannot use the prefix-index range optimization.
Bytecode Runtime Details
Section titled “Bytecode Runtime Details”The bytecode VM supports field loads, constants, comparisons, !, supported string predicates, user custom opcodes, host-call fallback, numeric +, -, *, /, %, and short-circuit && / || through JumpIfFalse / JumpIfTrue.
BytecodeQueryView::where(descriptor) composes the existing descriptor and the new descriptor with logical AND, producing one prepared bytecode program for the scan. Composed descriptors keep host-call and custom-opcode capture pointers per call or binding, so two independently captured descriptors can be combined without sharing one descriptor-wide capture pointer.
User opcode ids are in 0x8000..0xFFFF. CustomOpcodeRegistry::registerOpcode(...) rejects invalid or duplicate metadata, logs the rejection, and leaves the registry unchanged. Static lowering through AKKARADB_QUERY_OPCODE(...) works when the registration is visible earlier in the translation unit.
Bytecode does not traverse Ref<T> as a local raw-row field. A predicate such as post.author->name == "Alice" crosses a table boundary. In query(...), the plugin keeps normal C++ lazy resolution by emitting a HostCallBool descriptor. In where(...), the predicate stays as a decoded filter unless it can be emitted as composable bytecode.
Planner Tradeoffs
Section titled “Planner Tradeoffs”AND can safely use one indexed side because the remaining predicate is applied after loading each candidate row. OR needs both sides to be indexable; using only one side would miss rows that match the unindexed branch.
An OR union plan is not always small. Branches such as !=, notIn, isNotNull, and contains may produce full field-index scans. The union plan remains correct, but it can still scan many index entries.
Prefix indexes are separate from normal field indexes. They are string-like-field only, do not serve findBy<&Field>(), and do not replace normal field indexes for equality or numeric range planning. They add extra write/delete/update maintenance for the indexed string field.
Indexes and prefix indexes are not backfilled over existing rows. Register them immediately after opening a table and before writing rows that should be visible through those indexes.