Reading and Querying - SmithSoft-Studios/JAZMIN GitHub Wiki

Reading and Querying

Opening a file reads only its catalog. Everything after that reads as little as it can: the index pages that help, the chunks that can match, and in them only the columns you use.

The reader at a glance

JavaScript .NET Returns
open(pathOrBytes, { key }) JazminReader.Open(path, new() { Key = key }) A reader (reads the catalog only)
reader.find(filter, { select, offset, limit }) reader.Find(filter, new JazminQueryOptions { ... }) Matching rows, streamed
reader.rows({ select, offset, limit }) reader.Rows(options) Every row, streamed
reader.get(rowId) reader.Get(rowId) One row by position (0-based)
reader.count(filter) reader.Count(filter) Number of matching rows, often without reading rows
reader.columnArrays(filter, { select }) One typed array per column, for charts and totals
reader.explain(filter, { analyze }) reader.Explain(filter, analyze) How the query runs, and what it read
reader.advise({ columns }) Layout advice from the statistics
reader.Query<T>(predicate), reader.AsQueryable<T>() Typed objects through LINQ (.NET Serializer and LINQ)
reader.findAsync(), openAsync() reader.FindAsync(), JazminReader.OpenAsync() Non-blocking versions (Server Side)
reader.close() reader.Dispose() Closes the file

Reader properties: columns, metadata, rowCount, chunkCount, sortedBy, indexes, encrypted, writtenAt, tables, files, access (.NET: Columns, Metadata, RowCount, and so on).

Every kind of query

import { open, write } from '@smithsoft-studios/jazmin';

const regions = ['ZA', 'NA', 'BW', 'KE'];
write('orders.jzm', Array.from({ length: 50_000 }, (_, i) => ({
  id: i + 1,
  region: regions[i % 4],
  customer: `C-${String((i * 7919) % 5000).padStart(4, '0')}`,
  amount: ((i * 104729) % 100_000) / 100,
  paid: i % 5 !== 0,
})), {
  columns: [
    { name: 'id', type: 'int', nullable: false },
    { name: 'region', type: 'string', index: 'sorted' },
    { name: 'customer', type: 'string', index: 'sorted' },
    { name: 'amount', type: 'float' },
    { name: 'paid', type: 'bool' },
  ],
  sortedBy: ['id'],
});

const reader = open('orders.jzm');
console.log(reader.rowCount, 'rows,', reader.chunkCount, 'chunks');

// One row by position, and the rows of one customer
console.log(reader.get(41));
console.log([...reader.find({ customer: 'C-0042' }, { select: ['id', 'amount'], limit: 3 })]);

// Paging: the third page of 3 big unpaid orders in ZA
const where = { region: 'ZA', paid: false, amount: { gte: 900 } };
console.log([...reader.find(where, { select: ['id', 'amount'], offset: 6, limit: 3 })]);

// Counting and totals
console.log(reader.count(where), 'matching orders');
const { values } = reader.columnArrays({ region: 'NA' }, { select: ['amount'] });
console.log('NA total:', Math.round(values.amount.reduce((a, b) => a + b, 0)));

// How a query runs, and what it read
console.log(reader.explain({ customer: 'C-0042' }));
console.log(reader.explain({ id: { gte: 30_000, lt: 30_010 } }));
reader.close();
50000 rows, 13 chunks
{
  id: 42,
  region: 'NA',
  customer: 'C-4679',
  amount: 938.89,
  paid: true
}
[
  { id: 2519, amount: 76.22 },
  { id: 7519, amount: 526.22 },
  { id: 12519, amount: 976.22 }
]
[
  { id: 1141, amount: 910.6 },
  { id: 1481, amount: 989.2 },
  { id: 1501, amount: 935 }
]
251 matching orders
NA total: 6249875
{ strategy: 'index', candidateRows: 10 }
{ strategy: 'scan', chunks: 13, chunksSkipped: 12 }
  • find() and rows() are generators. Rows come one at a time, and only one chunk is held in memory. Loop with for…of to stream, or spread with [...] to collect.
  • select decodes only the columns you list (plus the ones the filter needs). Rows come back with just those.
  • get(rowId) reads the chunk holding the row; the reader keeps the last chunk it decoded, so fetching rows in position order is fast. Sort a list of row ids before fetching them.
  • count() answers from an index or from chunk statistics where it can, without reading rows.
  • columnArrays() gives a Float64Array per number or date column: about a tenth of the memory of row objects.

Paging

offset and limit page through results. A chunk that lies wholly before the offset is counted, not read, as long as every row in it is known to match: with no filter, or when chunk statistics prove it (such as a date range on the file's first sortedBy column). So the last page of a large file costs about the same as the first.

For deep pages of a filtered query, use keyset paging: remember the last row of a page and ask for the rows after it. It stays fast however deep, and pages don't shift when rows are added.

import { open, write } from '@smithsoft-studios/jazmin';

write('events.jzm', Array.from({ length: 200_000 }, (_, i) => ({ id: i + 1, kind: i % 3 === 0 ? 'error' : 'info' })), {
  sortedBy: ['id'],
});

const cost = (filter, options) => {
  const reader = open('events.jzm');
  const { rows, chunksRead } = reader.explain(filter, { ...options, analyze: true });
  reader.close();
  return `${rows} rows, ${chunksRead} chunks read`;
};
console.log('first page        ', cost(null, { limit: 50 }));
console.log('page at 190,000   ', cost(null, { offset: 190_000, limit: 50 }));
console.log('errors, offset    ', cost({ kind: 'error' }, { offset: 60_000, limit: 50 }));
console.log('errors, keyset    ', cost({ kind: 'error', id: { gt: 180_000 } }, { limit: 50 }));
first page         50 rows, 1 chunks read
page at 190,000    50 rows, 1 chunks read
errors, offset     50 rows, 44 chunks read
errors, keyset     50 rows, 1 chunks read

The filtered offset query had to read every chunk before the offset to count its errors; the keyset query went straight to the right chunk because the file is sorted by id.

Reading last first: reverse: true (1.7.0)

With reverse: true the reader starts at the last chunk and works back, so in a file sorted by date the newest rows come first, as fast as the oldest. offset and limit count from the end. It works with any filter, an index lookup and an access key, in find, rows, findAsync, findBatchesAsync, the exports (toJSON(reader, { reverse: true, limit: 20 })) and the browser reader's find and query. No format change: every file can be read this way.

import { from, open, write } from '@smithsoft-studios/jazmin';

const rows = Array.from({ length: 10_000 }, (_, i) => ({ id: i, at: new Date(Date.UTC(2026, 0, 1) + i * 60_000), account: `A${i % 50}` }));
write('journal.jzm', rows, {
  columns: [{ name: 'id', type: 'int' }, { name: 'at', type: 'datetime' }, { name: 'account', type: 'string', index: ['sorted'] }],
  sortedBy: ['at'],
});

const journal = open('journal.jzm');
console.log('newest 3 of A7:', [...journal.find({ account: 'A7' }, { reverse: true, limit: 3 })].map((t) => t.id));
console.log('newest 2:      ', [...journal.rows({ reverse: true, limit: 2 })].map((t) => t.id));
console.log('from():        ', from(journal).where((t) => t.account === 'A7').reverse().first().id);
journal.close();
newest 3 of A7: [ 9957, 9907, 9857 ]
newest 2:       [ 9999, 9998 ]
from():         9957
using Jazmin;
using Jazmin.Query;

using (var writer = JazminWriter.Create("journal.jzm",
    [new JazminColumn("id", JazminType.Int), new JazminColumn("at", JazminType.DateTime), new JazminColumn("account", JazminType.String) { Indexes = [JazminIndexKind.Sorted] }],
    new JazminWriteOptions { SortedBy = ["at"] }))
{
    for (var i = 0; i < 10_000; i++) writer.WriteValues((long)i, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMinutes(i), $"A{i % 50}");
}
using var journal = JazminReader.Open("journal.jzm");
var newest = journal.Find(JazminFilter.Eq("account", "A7"), new JazminQueryOptions { Reverse = true, Limit = 3 }).Select(r => r["id"]);
Console.WriteLine($"newest 3 of A7: {string.Join(", ", newest)}");
newest 3 of A7: 9957, 9907, 9857
  • Queries: from(reader)…reverse() in JavaScript and the browser, and LINQ's Reverse() in .NET (reader.AsQueryable<Txn>().Where(t => t.Account == "A7").Reverse().Take(20)), read last first when they come straight after the where functions, or after orderBy on the file's own sort order. Elsewhere they reverse in memory, as LINQ does.
  • Why it matters: before, "the latest 20" meant counting the matches and then reading the last page. On 1,000,000 rows sorted by time, one account's newest 20 (through an index) took 5.2 ms instead of 46.5 ms.
  • Rows with the same sort value come last first too. To keep those in file order, sort instead (orderByDescending), which reads every match.
  • Float columns: a filter on a float column can't skip chunks this way, because their statistics leave out NaN.

Reading many rows by position

If you hold a list of row positions (from your own pointer table, or a join between tables), sort it first. The reader keeps only the last chunk it decoded, so random order can read the same chunk again and again. On 200,000 transactions, fetching one day's 549 rows took 673 ms in time order and 62 ms sorted by position.

const rows = [...rowIds].sort((a, b) => a - b).map((id) => reader.get(id)); // one pass over the file

Queries with arrow functions (1.5.0)

from(reader) queries a table LINQ-style, with arrow functions, in Node and the browser (await JazminBrowser.from(reader)…). The reader still uses its indexes, statistics and sort order, and reads only the columns the functions use:

const big = from(transactions)
  .where((t) => t.category === 'Travel' && t.amount < -20000)   // read as { category: 'Travel', amount: { lt: -20000 } }
  .join(from(clients), (t) => t.client, (c) => c.id, (t, c) => ({ name: c.name, amount: t.amount }))
  .orderBy((x) => Number(x.amount))
  .toArray();

const city = 'Durban';
from(clients).where((c, $) => c.city === $.city, { city }).count(); // values from outside: the second argument
  • How: where reads the function's text (JavaScript gives a library no description of a function, as C# does for LINQ) and turns comparisons, && / || / !, .includes / .startsWith / .toLowerCase().includes on text, […].includes(t.column) and == null into the filter language.
  • Always the function's answer: the filter only narrows the rows read; the function still runs on them, unless the filter says exactly what it says. A function it can't fully read (a helper, a regular expression) reads more rows, but gives the right ones. explain() shows the filter, the columns, and what wasn't translated.
  • The rest runs in memory, as LINQ to Objects: select, orderBy / thenBy, skip / take, join, groupJoin, groupBy, count, sum, min, max, average, first, any.
  • Measured on 1,000,000 transactions: 0.12-0.21 s per query, against 0.6 s for plain JavaScript over every row.

Reading in .NET

using Jazmin;
using Jazmin.Query;

var regions = new[] { "ZA", "NA", "BW", "KE" };
var columns = new[]
{
    new JazminColumn("id", JazminType.Int) { Nullable = false },
    new JazminColumn("region", JazminType.String) { Indexes = [JazminIndexKind.Sorted] },
    new JazminColumn("amount", JazminType.Float),
};
using (var writer = JazminWriter.Create("orders.jzm", columns, new JazminWriteOptions { SortedBy = ["id"] }))
    for (int i = 0; i < 50_000; i++) writer.WriteValues(i + 1L, regions[i % 4], (i * 104729L % 100_000) / 100.0);

using var reader = JazminReader.Open("orders.jzm");

// The filter builder: & is AND, | is OR, ! is NOT
var bigZa = JazminFilter.Eq("region", "ZA") & JazminFilter.Gte("amount", 990.0);
Console.WriteLine($"{reader.Count(bigZa)} big ZA orders");

// The same filter as JSON: the language JavaScript and GraphQL use
foreach (var row in reader.Find("""{ "region": "ZA", "amount": { "gte": 990 } }""", new JazminQueryOptions { Select = ["id", "amount"], Limit = 3 }))
    Console.WriteLine($"row {row.RowId}: id {row["id"]}, amount {row["amount"]}");

Console.WriteLine(reader.Get(41)["region"]);

var plan = reader.Explain(JazminFilter.Gte("id", 30_000L) & JazminFilter.Lt("id", 30_010L), analyze: true);
Console.WriteLine($"{plan.Strategy}: {plan.ChunksSkipped} of {plan.Chunks} chunks skipped, {plan.Cost!.Rows} rows, {plan.Cost.ChunksRead} chunk read");
130 big ZA orders
row 148: id 149, amount 998.92
row 296: id 297, amount 997.84
row 444: id 445, amount 996.76
NA
scan: 12 of 13 chunks skipped, 10 rows, 1 chunk read

JazminRow is a read-only dictionary: row["amount"], row.RowId, row.TryGetValue(...), and column names are looked up case-insensitively when there is no exact match. For typed objects, use Query<T> or AsQueryable<T>: see .NET Serializer and LINQ.

Seeing what a query reads

explain(filter) says how a query will run: strategy: 'index' with the candidate row count, or strategy: 'scan' with the chunks the statistics skip. With { analyze: true } it also runs the query and reports:

Field Meaning
rows Rows returned
bytesRead Bytes read: chunks, index pages, statistics and directories
chunksRead Chunks read and decoded
indexPagesRead Index sections read
columnsDecoded Column streams decoded (one per column per chunk)
ms (.NET Elapsed) Time taken

Use a freshly opened reader for analyze: a reader keeps the indexes it has loaded and its last chunk, so on a reused reader the numbers show only what that query added. A query that reads many chunks to return a few rows needs a better layout: sort by the filtered column, add an index, or use smaller chunks. reader.advise() suggests one from the statistics alone; see Tuning Speed and Memory.

Before a query runs: estimate() (1.7.0)

reader.estimate(filter, { offset }) (.NET reader.Estimate(filter, offset)) says what a read would cost without reading a row. It uses only the chunk directories, the statistics and the indexes, so it is quick, and a program can warn before a slow query or ask first. Both libraries give the same figures.

Field Meaning
chunks, rows, bytes What find() reads at most (a limit can end it sooner)
countChunks, countRows, countBytes What count() reads: 0 when indexes or statistics answer it
import { open, write } from '@smithsoft-studios/jazmin';

const cities = ['Durban', 'Cape Town', 'Pretoria'];
const rows = Array.from({ length: 20_000 }, (_, i) => ({
  id: i, at: new Date(Date.UTC(2026, 0, 1) + i * 60_000), account: `A${i % 50}`, city: cities[i % 3],
}));
write('ledger.jzm', rows, {
  columns: [
    { name: 'id', type: 'int' }, { name: 'at', type: 'datetime' },
    { name: 'account', type: 'string', index: ['sorted'] }, { name: 'city', type: 'string' },
  ],
  sortedBy: ['at'], chunkRows: 1000,
});

const reader = open('ledger.jzm');
const show = (what, e) => console.log(what, `find: ${e.chunks} chunks, ${e.rows} rows; count: ${e.countChunks} chunks`);
show('no index:      ', reader.estimate({ city: 'Durban' }));
show('an index:      ', reader.estimate({ account: 'A7' }));
show('the sort order:', reader.estimate({ at: { gte: new Date(Date.UTC(2026, 0, 14)) } }));
reader.close();
no index:       find: 20 chunks, 20000 rows; count: 20 chunks
an index:       find: 20 chunks, 400 rows; count: 0 chunks
the sort order: find: 2 chunks, 2000 rows; count: 1 chunks

The index answers the count of A7 on its own; the date range skips 18 of the 20 chunks, and statistics show that every row of one of the two matches, so a count reads only the other. explain() is unchanged: it describes the plan, and with analyze runs it.

// fragment
var estimate = reader.Estimate(JazminFilter.Eq("city", "Durban"));   // JazminReadEstimate { Chunks, Rows, Bytes, CountChunks, CountRows, CountBytes }

One case costs more: a range an index answers with millions of rows takes about as long to plan as the read itself would (id >= 20,000,000 in a file of 25,000,000 rows: about 1 s), because the reader lists the index's rows to plan the read.

Progress and Stop on long reads (1.7.0)

A long read can say how far it has got, and be stopped, as the browser reader already could:

  • JavaScript: onProgress is called after each chunk with { done, total, matches } (chunks read, chunks planned, matching rows so far), and signal, an AbortSignal, stops the read before its next chunk. count, countAsync, find, findAsync, findBatchesAsync, columnArrays, explain({ analyze: true }) and the exports take both. A stopped read throws an AbortError.
  • .NET: JazminQueryOptions.Progress (an IProgress<JazminReadProgress>: Done, Total, Matches) and JazminQueryOptions.CancellationToken for Find, FindAsync and Explain(filter, true, options), and Count(filter, progress, token) / CountAsync(filter, progress, token). A stopped read throws an OperationCanceledException.
  • Without the options, reads take the same time as before. A read that indexes or statistics answer without reading a chunk reports nothing.
import { open, write } from '@smithsoft-studios/jazmin';

const cities = ['Durban', 'Cape Town', 'Pretoria'];
write('ledger.jzm', Array.from({ length: 20_000 }, (_, i) => ({ id: i, city: cities[i % 3] })), { chunkRows: 1000 });
const reader = open('ledger.jzm');

let last;
const matches = reader.count({ city: 'Durban' }, { onProgress: (p) => { last = p; } });
console.log(matches, 'matches; last report:', last);

// countAsync gives the event loop a turn between chunks, so a Stop button (here: after 5 chunks) can stop it
const stop = new AbortController();
try {
  await reader.countAsync({ city: 'Durban' }, { signal: stop.signal, onProgress: ({ done }) => { if (done === 5) stop.abort(); } });
} catch (e) {
  console.log('stopped:', e.name);
}
reader.close();
6667 matches; last report: { done: 20, total: 20, matches: 6667 }
stopped: AbortError
using Jazmin;
using Jazmin.Query;

using (var writer = JazminWriter.Create("ledger.jzm", [new JazminColumn("id", JazminType.Int), new JazminColumn("city", JazminType.String)],
    new JazminWriteOptions { ChunkRows = 1000 }))
{
    string[] cities = ["Durban", "Cape Town", "Pretoria"];
    for (var i = 0; i < 20_000; i++) writer.WriteValues((long)i, cities[i % 3]);
}
using var reader = JazminReader.Open("ledger.jzm");

var last = new JazminReadProgress();
var matches = reader.Count(JazminFilter.Eq("city", "Durban"), new Reporter(p => last = p));
Console.WriteLine($"{matches} matches; last report: {last.Done} of {last.Total} chunks, {last.Matches} matches");

using var stop = new CancellationTokenSource();
try
{
    reader.Count(JazminFilter.Eq("city", "Durban"), new Reporter(p => { if (p.Done == 5) stop.Cancel(); }), stop.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("stopped");
}

// Progress<T> reports on another thread; this reports at once, on the reading thread
sealed class Reporter(Action<JazminReadProgress> report) : IProgress<JazminReadProgress>
{
    public void Report(JazminReadProgress value) => report(value);
}
6667 matches; last report: 20 of 20 chunks, 6667 matches
stopped

count and find hold their thread until they finish, so only onProgress itself can stop them in JavaScript. Use the async forms when a button or a timeout must be able to stop the read. In .NET, cancel the token from any thread. The time left can be worked out from the speed so far: elapsed / done * (total - done). Rewrites report progress too: see Updating, Appending and Compacting.

Good to know

  • Readers are not thread-safe. Open one per thread or worker; opening is cheap (it reads the catalog).
  • Open once, query many times. The reader caches the index pages and statistics it has read.
  • Closing: close() in JavaScript, using / Dispose() in .NET. A reader over a Buffer or byte[] holds no file handle.
  • Encrypted files read at about the same speed as plain ones.
⚠️ **GitHub.com Fallback** ⚠️