Skip to content

isndev/qb-linq

qb-linq

Lazy LINQ-style pipelines for C++17 — header-only, zero dependencies beyond the STL

CMake C++17 CMake License: MIT Header-only Platforms

#include <qb/linq.h> · namespace qb::linq · target qb::linq · Versioning

Features · Quick start · Full API · Examples · Build & test · Performance · Docs hub · Contributing · Changelog · AI assistants


qb-linq brings Enumerable-style method chains to modern C++: where, select, order_by, group_by, join, set algebra, percentiles / variance, zip / zip_fold, scan, chunk, materializers, and fused terminals — without std::ranges, without a runtime library. Views are lazy; work happens when you iterate or call a terminal.


Why qb-linq?

LINQ surface Filters, projections, sorting, grouping, joins, sets, stats, factories — see complete API below.
Lazy by default Pipelines are cheap to build; execution is driven by iterators and terminals.
where is smart O(1) construction; first begin() runs a single find_if and caches the result (CRTP query_range_algorithms).
Fused hot paths sum_if, count_if, any_if, all_if, none_if, aggregate / foldone pass, no extra where_iterator layer.
CMake-native find_package(qb-linq) or add_subdirectory → link qb::linq.

Requirements

  • C++17 — GCC, Clang, MSVC
  • CMake 3.22+ for the bundled config / presets
  • Linux · macOS · Windows (verified in CI)

Quick start (CMake)

Installed package

find_package(qb-linq 1.3 CONFIG REQUIRED)
target_link_libraries(your_target PRIVATE qb::linq)
target_compile_features(your_target PRIVATE cxx_std_17)

add_subdirectory

add_subdirectory(third_party/qb-linq)
target_link_libraries(your_target PRIVATE qb::linq)

Hello pipeline

#include <iostream>
#include <vector>
#include <qb/linq.h>

int main() {
    std::vector<int> v{1, 2, 3, 4, 5, 6};
    int s = qb::linq::from(v)
                .where([](int x) { return x % 2 == 0; })
                .select([](int x) { return x * x; })
                .sum();
    std::cout << s << '\n';   // 4 + 16 + 36 = 56
}

Include only <qb/linq.h>; other headers stay under qb/linq/.
Version macros (QB_LINQ_VERSION_STRING, qb::linq::version, …) come from qb/linq/version.h, generated by CMake — see docs/VERSIONING.md.

Single header (single_header/linq.h)

For a drop-in file (no qb/ include tree, no CMake-generated version.h on disk), add -I /path/to/qb-linq/single_header and:

#include <linq.h>

Same namespace qb::linq and API as <qb/linq.h>; the version is embedded from project(VERSION …) when the file is regenerated.

Regenerate after editing headers under include/qb/linq/ or bumping the project version:

  • CMake: cmake --build <build> --target qb_linq_single_header
  • Scripts (repo root — use whichever fits your machine):
    • python3 scripts/amalgamate_single_header.py — Python 3 (also python … on Windows if that is your launcher).
    • sh scripts/amalgamate_single_header.sh — POSIX sh; calls python3 internally.
    • pwsh -File scripts/amalgamate_single_header.ps1 — PowerShell (typical on Windows).

The qb_linq_single_header_test target (see tests/) keeps this file honest in CI.


Design at a glance

  1. enumerable<Handle> — fluent façade; each lazy step returns a new enumerable.
  2. query_range_algorithms (CRTP) — algorithms use derived().begin() / end().
  3. Terminalssum, to_vector, first, order_by, group_by, … trigger real work (many materialize via shared_ptr).
  4. Caveatsselect_many = tuple per row (not C# flatten); except/intersect follow .NET distinct set semantics and need hashable value_type; zip/concat inner pipelines may need copy-assignable functors (+[](){}); to_vector() / materialize() return an enumerable wrapping materialized_range (not a raw std::vector); scan iterators must not outlive the scan_view. Full notes in qb/linq.h.

Complete API reference

All operations below live in namespace qb::linq. Method names use snake_case.
Unless noted, methods are const and return a new enumerable for lazy ops, or a value / materialized range for terminals.

Free functions — factories & entry points

API Role
from(container&) / from(container const&) Non-owning iterators — the container must outlive the pipeline for lazy ops; do not chain from(temp).where… (see qb/linq.h caveats).
from(Iter b, Iter e) Iterator pair.
range(b, e) Alias of from(b, e).
as_enumerable(...) Overloads: enumerable& / const& / && (identity); container ref → from; (Iter b, Iter e)from.
iota(T first, T last) Lazy half-open [first, last) (no allocation).
empty<T>() Empty sequence.
once(T&&) Single-element sequence.
repeat(T&&, std::size_t n) Repeat value n times.
asc(key) / desc(key) Build ascending / descending key projection for order_by.
make_filter<CustomBase>(key, args...) Custom comparator base for order_by (e.g. make_filter<MyLess>([](int x){ return x; })).
CTAD enumerable(h) deduces enumerable<std::decay_t<decltype(h)>>.

enumerable — iteration

API Role
begin() / end() Range-based for, STL iterator usage.
value_type Element / projected type.

Lazy transformation & slicing

API Role
select(f) Map each element.
select_indexed(f) f(element, index) (lazy; zero-based index).
select_many(fs...) Tuple of one projection per loader per row (not nested flattening).
flat_map(f) / select_many_flatten(f) True C# SelectMany: project each element to a sub-range via f and flatten (lazy).
where(pred) Filter; first begin() may cache first match.
where_indexed(pred) pred(element, index) (lazy).
of_type<U>() Polymorphic dynamic_cast filter (value_type must be pointer, pointee polymorphic).
cast<U>() Lazy static_cast<U> of each element (complement to of_type).
skip(n) Drop first n.
skip_while(pred) Drop while predicate true.
take(n) Take n (int; negative magnitude like LINQ; INT_MIN → empty).
take_while(pred) Take while predicate true.
skip_last(n) / take_last(n) Materialize then trim head/tail.
reverse() Reverse order (underlying chain must be bidirectional). Composes correctly with take(n) and take_while(pred) via dedicated bounds (logical prefix only).

Composition & extra lazy views

API Role
concat(rhs) Concatenate another range (same value_type).
zip(rhs) Pairwise zip; stops at shorter length.
zip(r2, r3) Three-way zip; std::tuple of references; stops at shortest length.
zip_fold(rhs, init, f) Single-pass fold over pairs f(acc, left, right); same length rule as zip.
append(v) / prepend(v) Single element after/before sequence.
default_if_empty() / default_if_empty(def) Sentinel when empty.
enumerate() (index, element) pairs.
scan(seed, f) Running fold; yields each accumulator.
chunk(size) Chunks as std::vector slices (lazy per chunk).
stride(step) Every step-th element (0 clamped to 1).
sliding_window(size) Overlapping windows of size elements (each as std::vector); slides by one element.
distinct() First occurrence by value (hash).
distinct_by(keyfn) First occurrence per key.

Ordering & grouping

API Role
order_by(key_filters...) Materialize + stable multi-key sort (asc / desc / make_filter).
group_by(keys...) Materialize nested map / vectors (multi-level keys).
to_lookup(key) Alias of group_by(key).
asc() / desc() On materialized sorted views: identity / reverse iterator view (when supported by handle).

Relational

API Role
join(inner, outer_key, inner_key, result_sel) Inner join; flat result rows.
left_join(inner, outer_key, inner_key, result_sel) Left outer join; result_sel(outer, std::optional<inner>) — empty when no match.
right_join(inner, outer_key, inner_key, result_sel) Right outer join; result_sel(std::optional<outer>, inner) — empty when no match.
group_join(inner, outer_key, inner_key) Outer + inner groups per key.

Set-style (after materialization / hash)

API Role
except(rhs) Set difference (.NET Except): distinct left values not in rhs, first-seen order.
intersect(rhs) Set intersection (.NET Intersect): distinct left values also in rhs, first-seen order.
except_by(rhs, keyL, keyR) .NET ExceptBy: distinct left rows by keyL, excluding keys from rhs via keyR.
intersect_by(rhs, keyL, keyR) .NET IntersectBy: distinct left rows by key present in rhs keys.
union_by(rhs, keyL, keyR) .NET UnionBy: same value_type both sides; unique keys, left then new right keys.
count_by(keyfn) .NET CountBy: (key, count) pairs in first-seen key order (materialized).
union_with(rhs) concat + distinct.

Materialization — collections & maps

API Role
to_vector() Eager materialization: returns enumerable<materialized_range<…>> owning a std::vector via shared_ptr (iterate, chain further, or copy out — not a std::vector prvalue). Random-access sources may reserve.
materialize() Alias of to_vector().
to_set() / to_unordered_set() Unique sets.
to_map(keyf, elemf) std::map (ordered keys).
to_unordered_map(keyf, elemf) std::unordered_map (last wins on duplicate key).
to_dictionary(keyf, elemf) unordered_map; throws on duplicate key.

Search & indexing

API Role
contains(v) Linear search ==.
contains(v, eq) Custom equality.
index_of(v) / last_index_of(v) std::optional<std::size_t>.
index_of(v, eq) / last_index_of(v, eq) With custom equality.

Counting & boolean quantifiers

API Role
any() Non-empty?
count() / long_count() std::distance (full scan).
try_get_non_enumerated_count() optional size for random-access ranges only; no linear scan.
count_if / long_count_if Predicate count (fused).
any_if / all_if / none_if Fused predicate quantifiers.

Element access & single-element contracts

API Role
first() / last() Throws if empty. last() works with forward-only iterators (linear scan; bidirectional+ uses std::prev).
first_or_default() / last_or_default() Value or value_type{} (requires default-constructible value_type).
first_if / first_or_default_if Predicate. *_or_default_if requires default-constructible value_type.
last_if / last_or_default_if Predicate (last match). last_or_default_if requires default-constructible value_type; last_if does not.
element_at(i) / element_at_or_default(i) Zero-based index. *_or_default requires default-constructible value_type.
single() / single_or_default() Exactly one element (or default). single_or_default requires default-constructible value_type.
single_if / single_or_default_if Exactly one match for predicate. single_or_default_if requires default-constructible value_type; single_if does not.
sequence_equal(rhs) / sequence_equal(rhs, comp) Pairwise compare two ranges.

Min / max (by value or key)

API Role
min() / max() / min_max() Throws if empty.
min(comp) / max(comp) / min_max(comp) Custom strict weak ordering.
min_or_default() / max_or_default() Empty → default.
min_by / max_by Compare by projected key.
min_by_or_default / max_by_or_default Empty → default.

Aggregates & folds

API Role
sum() += from value_type{}.
sum_if(pred) Fused filtered sum.
average() / average_if(pred) Mean (double); throws if empty / no match.
aggregate(init, f) / fold Left fold with seed (fold = alias).
reduce(f) Fold without seed; first element is initial acc; empty throws.
aggregate_by(keyf, seed, f) Group + fold per key in one pass; vector<pair<Key, Acc>> in first-seen order.
reduce_by(keyf, f) Group + fold per key without seed; first element per key is initial acc.

Order statistics & spread

API Role
percentile(p) / percentile_by(p, key) p in [0,100]; sort-based interpolation.
median() / median_by(key) Percentile 50.
variance_population() / variance_sample() Welford; arithmetic value_type or use *_by.
variance_population_by / variance_sample_by Project before stats.
std_dev_population (+ _by) / std_dev_sample (+ _by) sqrt of variance.

Side effects

API Role
each(f) Apply f to each element; returns copy of current stage for safe chaining (const& / & overloads).

Map / lookup bracket (when handle supports it)

API Role
operator[](key) Available on map-backed materialized ranges (e.g. to_dictionary, grouped structures) when the underlying container exposes at.

Coverage: the table above lists every forwarding entry point on enumerable plus all documented free factories in enumerable.h. Internal iterator adaptors (select_iterator, …) live in iterators.h; combinators (concat_view, zip_view, …) in detail/extra_views.h.


Examples

Pipeline: filter → map → take → materialize

std::vector<int> data{3, 1, 4, 1, 5, 9, 2, 6};
auto v = qb::linq::from(data)
             .where([](int x) { return x > 2; })
             .select([](int x) { return x * x; })
             .take(3)
             .to_vector();

Fused aggregate

std::vector<int> xs{1, 2, 3, 4, 5, 6};
auto n = qb::linq::from(xs).count_if([](int x) { return x % 2 == 0; });
auto s = qb::linq::from(xs).sum_if([](int x) { return x % 2 == 0; });

Multi-key sort

struct Row { int dept; int id; };
std::vector<Row> rows{{2, 10}, {1, 5}, {1, 3}, {2, 7}};
auto sorted = qb::linq::from(rows)
                  .order_by(qb::linq::asc([](Row const& r) { return r.dept; }),
                            qb::linq::desc([](Row const& r) { return r.id; }))
                  .to_vector();

join / group_join

struct Person { int id; std::string name; };
struct Score { int person_id; int points; };
std::vector<Person> people{{1, "Ann"}, {2, "Bob"}};
std::vector<Score> scores{{1, 10}, {1, 11}, {2, 20}};

auto joined = qb::linq::from(people).join(scores,
    [](Person const& p) { return p.id; },
    [](Score const& s) { return s.person_id; },
    [](Person const& p, Score const& s) {
        return p.name + ":" + std::to_string(s.points);
    });

auto grouped = qb::linq::from(people).group_join(scores,
    [](Person const& p) { return p.id; },
    [](Score const& s) { return s.person_id; });

Sets & stats

std::vector<int> a{1, 2, 3, 4}, b{3, 4, 5};
auto u = qb::linq::from(a).union_with(b).to_vector();
std::vector<double> xs{1, 2, 3, 4, 5};
double m = qb::linq::from(xs).median();
double p = qb::linq::from(xs).percentile(75.0);

zip · scan · enumerate · factories

std::vector<int> a{1, 2, 3}, b{10, 20, 30};
for (auto const& pr : qb::linq::from(a).zip(b)) { /* pr.first, pr.second */ }

for (int x : qb::linq::from(a).scan(0, [](int acc, int v) { return acc + v; })) { }

for (auto const& pr : qb::linq::from(std::string("abc")).enumerate()) { }

auto xs = qb::linq::iota(0, 10);
auto one = qb::linq::once(42);
auto pat = qb::linq::repeat(std::string("x"), 3);
auto none = qb::linq::empty<int>();

each (in-place)

std::vector<int> data{1, 2, 3};
qb::linq::from(data).each([](int& x) { x *= 2; });

C# LINQ ↔ qb-linq (quick map)

C# qb-linq
Where / indexed where / where_indexed
Select / indexed select / select_indexed
SelectMany (flatten) use select + concat / loops; select_many = tuple bundle
OrderBy / ThenBy order_by(asc(...), desc(...))
GroupBy group_by
Join / GroupJoin join / group_join
Left / right outer join (DefaultIfEmpty pattern) left_join / right_join (std::optional for missing side)
ToList to_vector / materialize
ToDictionary to_dictionary / to_map / to_unordered_map
Distinct distinct / distinct_by
Union / Except / Intersect union_with / except / intersect
UnionBy / ExceptBy / IntersectBy / CountBy union_by / except_by / intersect_by / count_by
Zip zip / zip(r2,r3)
TryGetNonEnumeratedCount try_get_non_enumerated_count
Aggregate aggregate / reduce / fold
Sum / Average / Min / Max sum, average, min, max, min_max, …
Any / All / Count any, all_if, count, count_if, …
Range / Repeat / Empty iota, repeat, empty

Build options (this repository)

CMake option Default Purpose
QB_BUILD_TESTS ON if top-level GoogleTest → ctest, qb_linq_tests
QB_BUILD_BENCHMARKS OFF Google Benchmark → qb_linq_benchmark
QB_LINQ_BUILD_DOCS OFF Doxygen → qb_linq_docs

Full detail (presets, install, FetchContent versions, flags): docs/BUILDING.md.

cmake -B build -DCMAKE_BUILD_TYPE=Release \
  -DQB_BUILD_TESTS=ON -DQB_BUILD_BENCHMARKS=ON
cmake --build build --parallel
ctest --test-dir build --output-on-failure
./build/benchmarks/qb_linq_benchmark

Presets: cmake --preset dev · cmake --build build/dev · ctest --preset dev
(release, ci, docs — see CMakePresets.json.)


Performance & benchmarks

The benchmarks/ suite (Google Benchmark) compares many qb-linq paths against small hand-written loops in the same translation unit. Enable it with QB_BUILD_BENCHMARKS=ON (see above), then run the qb_linq_benchmark executable. All timings are machine-, compiler-, and flag-specific; use them for local regression checks, not as absolute promises. For scan iterator copies vs a straight range-for, and select with an empty class functor vs a large callable object, see benchmarks/bench_scan_contract.cpp.

What to expect

  • Simple terminals on a plain range (e.g. sum on from(vector)) are usually close to a naive loop: the hot path is still a forward iteration with little extra logic.
  • Lazy stacks (select, where, concat, stride, scan, …) add real work: wrapped iterators, stored callables, and sometimes downgraded iterator categories versus raw pointers or index loops. A noticeable gap versus a single fused loop is normal, especially on MSVC, where heavy template layering does not always optimize away completely.
  • scan stores the fold functor in the scan_view; iterators hold a non-owning pointer to it (no shared_ptr / atomic refcount). Treat it like a standard range: do not keep scan_iterators past the lifetime of the view that produced them.
  • select / where / take_while store callables with empty-base optimization when F is an empty class type (C++17), shrinking iterator objects for stateless function objects. On MSVC, iterator adaptors use __declspec(empty_bases) so an empty F does not double the iterator size under multiple inheritance.
  • Materializers such as to_vector() build a materialized_range backed by std::shared_ptr. You pay for that ownership model, not just element copying—expect a higher cost than reserve + push_back into a bare std::vector with no shared handle.
  • Views that allocate per step (e.g. chunk, which fills a std::vector per chunk) are inherently more expensive than one pass over scalars.
  • Some benchmarks compare different algorithms (e.g. one-pass Welford variance vs a two-pass sum-of-squares formulation). A gap there reflects numerics and work per element, not a broken sum.

Takeaway: qb-linq prioritizes a LINQ-like API, lazy composition, and safe materialization (shared ownership where needed). It is not a guarantee of matching hand-tuned assembly on every pipeline. For hot spots, measure with qb_linq_benchmark (or your own harness) on your target toolchain.


Repository layout

single_header/linq.h   # amalgamated drop-in (regenerate: target qb_linq_single_header)
scripts/               # amalgamate_single_header.{py,ps1,sh}
include/qb/linq.h
include/qb/linq/
  enumerable.h
  query.h
  iterators.h
  detail/
    query_range.h
    extra_views.h
    group_by.h
    order.h
    type_traits.h
tests/                 # GoogleTest — includes randomized reference checks (`linq_property_fuzz_test.cpp`) and edge-case suites (`linq_robustness_parity_test.cpp`, …); see `tests/CMakeLists.txt`
benchmarks/            # Google Benchmark (see benchmarks/CMakeLists.txt)
cmake/                 # qb-linq-config.cmake.in, version.h.in → generated qb/linq/version.h
docs/                  # README (hub), BUILDING, VERSIONING, LLM_CONTEXT, Doxyfile.in, doxygen/
.github/               # CI, issue/PR templates, copilot-instructions

AI assistants & agentic workflows

Documentation


License

MIT License — Copyright (c) 2026 ISNDEV


Contributing

See CONTRIBUTING.md and CODE_OF_CONDUCT.md for expectations. Short version: behaviour changes need tests in tests/; keep benchmark builds green when you touch hot paths. Build help: docs/BUILDING.md. Agents: AGENTS.md, docs/LLM_CONTEXT.md.

About

Lazy Enumerable-style LINQ pipelines for C++17: where, select, order_by, group_by, joins, materializers — header-only, no std::ranges. CMake package qb-linq → qb::linq.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages