Several Tables - SmithSoft-Studios/JAZMIN GitHub Wiki
Several Tables
A file can hold several tables, like the sheets of a workbook. Use this when details would otherwise repeat: in a
statement file, each client's name and address would sit on every transaction row. With two tables, clients holds
those details once, and transactions refers to them by clientId.
erDiagram
clients ||--o{ transactions : "clientId"
clients { string clientId PK string name string address string email }
transactions { string clientId FK datetime date string description decimal amount }
rates { string currency PK float rate }
- Each table has its own columns (with indexes),
sortedBy,chunkRowsandchunkBytes, and in access-controlled files its ownpartitionByandcolumnGroups. - Shared by the file: the key or password, codec, metadata, embedded files and grants.
- Memory while writing: only the current table's chunk, however many tables there are.
Writing and reading
import { JazminKey, open, write } from '@smithsoft-studios/jazmin';
const clients = Array.from({ length: 200 }, (_, i) => ({
clientId: `C${String(i + 1).padStart(4, '0')}`,
name: `Client ${i + 1}`,
address: `${i + 1} Main Road, Cape Town`,
email: `client${i + 1}@example.com`,
}));
const transactions = clients.flatMap((c, i) => Array.from({ length: 15 }, (_, n) => ({
clientId: c.clientId,
date: new Date(Date.UTC(2026, 2, 1 + n)),
description: n % 4 === 0 ? 'Debit order' : 'Card purchase',
amount: `${((i * 31 + n * 17) % 500) + 10}.50`,
})));
const key = JazminKey.generate();
write('statements.jzm', { clients, transactions }, {
key,
tables: [
// Small chunks suit a table looked up by key: a lookup decodes one chunk
{ name: 'clients', columns: [
{ name: 'clientId', type: 'string', nullable: false },
{ name: 'name', type: 'string' },
{ name: 'address', type: 'string' },
{ name: 'email', type: 'string' },
], sortedBy: ['clientId'], chunkRows: 256 },
{ name: 'transactions', columns: [
{ name: 'clientId', type: 'string', nullable: false },
{ name: 'date', type: 'datetime', nullable: false },
{ name: 'description', type: 'string' },
{ name: 'amount', type: 'decimal' },
], sortedBy: ['clientId', 'date'], chunkRows: 1024 },
],
});
const clientTable = open('statements.jzm', { key: key.export() }); // the first table
console.log(clientTable.tables, '- this reader shows', clientTable.table);
const lines = clientTable.openTable('transactions'); // the same open file: no second open, no second key check
const client = clientTable.find({ clientId: 'C0042' }).next().value;
const statement = [...lines.find({ clientId: 'C0042', date: { lt: new Date('2026-03-04') } }, { select: ['date', 'amount'] })];
console.log(client.name, '|', client.address);
for (const l of statement) console.log(' ', l.date.toISOString().slice(0, 10), l.amount);
console.log(lines.rowCount, 'transactions for', clientTable.rowCount, 'clients');
lines.close();
clientTable.close(); // the file closes with its last reader
[ 'clients', 'transactions' ] - this reader shows clients
Client 42 | 42 Main Road, Cape Town
2026-03-01 281.50
2026-03-02 298.50
2026-03-03 315.50
3000 transactions for 200 clients
open(path, { key, table: 'transactions' }) opens a named table directly. With the streaming writer, declare the
tables, write the first table's rows, call writer.startTable('transactions'), write its rows, then finish().
Tables can be started in any order, each once; a table you never start is written empty.
In .NET
using Jazmin;
using Jazmin.Query;
var clientColumns = new[] { new JazminColumn("clientId", JazminType.String) { Nullable = false }, new JazminColumn("name", JazminType.String) };
var lineColumns = new[] { new JazminColumn("clientId", JazminType.String) { Nullable = false }, new JazminColumn("amount", JazminType.Decimal) };
using (var writer = JazminWriter.Create("statements.jzm", new JazminWriteOptions
{
Tables =
[
new JazminTable("clients", clientColumns) { SortedBy = ["clientId"], ChunkRows = 256 },
new JazminTable("transactions", lineColumns) { SortedBy = ["clientId"], ChunkRows = 1024 },
],
}))
{
writer.WriteValues("C1", "ABC Corp");
writer.WriteValues("C2", "Test Ltd");
writer.StartTable("transactions");
writer.WriteValues("C1", 1000m);
writer.WriteValues("C1", 250.50m);
writer.WriteValues("C2", 20000m);
}
using var clients = JazminReader.Open("statements.jzm");
using var transactions = clients.OpenTable("transactions"); // or Table = "transactions" in the read options
Console.WriteLine(string.Join(", ", clients.Tables));
foreach (var c in clients.Rows())
{
var total = transactions.Find(JazminFilter.Eq("clientId", c["clientId"])).Sum(t => decimal.Parse((string)t["amount"]!));
Console.WriteLine($"{c["clientId"]} {c["name"]}: {total}");
}
clients, transactions
C1 ABC Corp: 1250.50
C2 Test Ltd: 20000
Nested output across tables
- Export shapes (1.3.0) nest rows from several tables in one JSON or XML export: each customer with their orders, each order's lines and each line's product. Sort each linked table by its link and every table is read once. See Export Shapes.
- .NET LINQ works on each table through
AsQueryable<T>(), in lambda or query syntax. See Across tables.
Documents that read several tables (1.6.0)
A file's document reads every table. jazmin.columns, rowCount and rows() describe
its own table (jazmin.table); query and count read any table by name:
// not run: a page inside a file
jazmin.tables; // [{ name: 'clients', columns, rowCount }, { name: 'transactions', columns, rowCount }]
const lines = await jazmin.query({ clientId: 'C00042' }, { table: 'transactions', orderBy: 'date' });
const unpaid = await jazmin.count({ paid: false }, { table: 'transactions' });
An access key sees only its own partitions in each table. In renderPdf, a filter limits every table it fits;
other tables need tableFilters (Server Side).
Access control across tables
Partition names and column-group names are shared by every table of a file, so a key granted client C1 sees
C1's rows in every table partitioned by client: access follows the link.
import { JazminKey, open, write } from '@smithsoft-studios/jazmin';
const owner = JazminKey.generate();
const bob = owner.createAccessKey();
write('shared.jzm', {
clients: [{ clientId: 'C1', name: 'ABC Corp', email: '[email protected]' }, { clientId: 'C2', name: 'Test Ltd', email: '[email protected]' }],
transactions: [{ clientId: 'C1', amount: 1000 }, { clientId: 'C1', amount: 250.5 }, { clientId: 'C2', amount: 20000 }],
rates: [{ currency: 'USD', rate: 18.2 }, { currency: 'EUR', rate: 19.9 }],
}, {
key: owner,
access: { grants: [{ key: bob, rows: ['C1', '*'], columns: ['*'] }] }, // '*' here: the unpartitioned rates table
tables: [
{ name: 'clients', columns: [{ name: 'clientId', type: 'string', nullable: false }, { name: 'name', type: 'string' }, { name: 'email', type: 'string' }],
partitionBy: 'clientId', columnGroups: { contact: ['email'] } },
{ name: 'transactions', columns: [{ name: 'clientId', type: 'string', nullable: false }, { name: 'amount', type: 'float' }], partitionBy: 'clientId' },
{ name: 'rates', columns: [{ name: 'currency', type: 'string' }, { name: 'rate', type: 'float' }] }, // no partitionBy: one partition, '*'
],
});
const view = open('shared.jzm', { key: bob.export() });
for (const name of view.tables) {
const t = view.openTable(name);
console.log(name.padEnd(13), JSON.stringify([...t.rows()]));
t.close();
}
clients [{"clientId":"C1","name":"ABC Corp"}]
transactions [{"clientId":"C1","amount":1000},{"clientId":"C1","amount":250.5}]
rates [{"currency":"USD","rate":18.2},{"currency":"EUR","rate":19.9}]
Bob sees client C1 in both partitioned tables, without the email column (its contact group wasn't granted),
and the whole rates table.
A table without partitionBy (here, exchange rates) is one partition named *. A grant of rows: '*' covers it; a
grant of rows: ['C1'] doesn't. To share it, add '*' to the grant's rows, as above.
Changing one table
append, update and compact take a table option (.NET Table); the default is the first table. Only that
table's rows change.
append('statements.jzm', { key, table: 'transactions', insert: newLines });
update('statements.jzm', { key, table: 'clients', upsert: [changedClient], keyColumns: ['clientId'] });
- An append writes new sections for that table only; the other tables stay exactly as they are.
- A full rewrite (
update,compact, granting or revoking) copies the other tables into the new version, because every new version gets fresh secrets.
Adding, removing and renaming tables (1.6.0)
update() and compact() change a file's tables in one rewrite that keeps everything else:
update('statements.jzm', { key,
addTables: [{ name: 'payments', columns: paymentColumns, sortedBy: ['clientId', 'date'], partitionBy: 'clientId', rows: payments }],
removeTables: ['old'],
renameTables: { tx: 'transactions' },
});
// fragment
JazminFile.Update("statements.jzm", new JazminUpdate
{
Key = owner,
AddTables = [new JazminAddedTable(new JazminTable("payments", paymentColumns) { SortedBy = ["clientId", "date"], PartitionBy = "clientId" }, payments)],
RemoveTables = ["old"],
RenameTables = new Dictionary<string, string> { ["tx"] = "transactions" },
});
- An added table's rows can be any iterable (an array, or a generator reading another file). They are read once and
never all held; with
sortedBythey are put in that order within the memory budget. - Shared files: a table partitioned by the same column uses the same partition names, so a key granted client
C1seesC1's rows in the new table with no change to its grant. - Saved shapes, the document's edits and links follow a rename. A table they read can't be removed until they go too; the error names them.
- A file's single unnamed table is named first:
renameTables: { '': 'clients' }.
Links between tables (1.6.0)
Say once how the tables refer to each other. The file keeps the links in its metadata (jazmin.links: names, tables
and columns, never values), and checkLinks() checks them against the rows:
write('statements.jzm', { clients, transactions }, { tables, links: [
{ name: 'client', from: { table: 'transactions', columns: ['clientId'] }, to: { table: 'clients', columns: ['clientId'] } },
] });
const r = open('statements.jzm', { key });
r.checkLinks();
// [{ name: 'client', rows: 300000, empty: 0, missing: 2, examples: ['C0999'], ['C1203'](/SmithSoft-Studios/JAZMIN/wiki/'C0999'],-['C1203'), duplicateTargets: 0, method: 'merge' }]
// fragment
var options = new JazminWriteOptions { Tables = tables, Links = [new JazminLink("client",
new JazminLinkSide("transactions", ["clientId"]), new JazminLinkSide("clients", ["clientId"]))] };
var checks = reader.CheckLinks(); // Rows, Empty, Missing, Examples, DuplicateTargets, Method
- Checked when written: the tables and columns exist and their types are equal. Older readers ignore the links: no format change.
missingcounts rows whose link finds nothing,emptyrows with no value,duplicateTargetstarget values that repeat. A key sees only its own rows, so a shared file is checked as that key sees it.- Fast: tables sorted by the link columns are walked side by side, holding almost nothing. 1,000,000 rows check in
about 0.1 to 0.3 s (
npm run bench:links).
Changing several tables at once (1.6.0)
One update() can change rows in several tables. The usual options change the main table (table); tables gives
the others theirs. set with where gives the rows a filter matches new column values. Together they re-key across
tables, for example a client's number changed everywhere it is used:
const result = update('statements.jzm', { key,
table: 'clients', set: { clientId: 'C0999' }, where: { clientId: 'C0123' },
tables: { transactions: { set: { clientId: 'C0999' }, where: { clientId: 'C0123' } } },
});
// result.tables.transactions.updated === 20
// fragment
var change = new JazminTableChanges { Set = new Dictionary<string, object?> { ["clientId"] = "C0999" }, Where = JazminFilter.Eq("clientId", "C0123") };
JazminFile.Update("statements.jzm", new JazminUpdate
{
Key = key, Tables = new Dictionary<string, JazminTableChanges> { ["clients"] = change, ["transactions"] = change },
});
- Each table takes
insert,upsertwithkeyColumns,delete,setwithwhere,indexesandsortedBy. Tables not named are copied as they are.tablesnames a table as the file has it now, even whilerenameTablesrenames it; the result uses the names afterwards. Eachsetvalue is checked against its column before the file is read. - Sorted tables stay sorted. When a few rows' sort column is set, they are found by the indexes and merged into place: a re-key of 20 rows in a 1,000,000-row table took 2.1–2.2 s (a plain rewrite: 1.7–1.8 s; through the full sort: 3.8–4.4 s). Many rows are re-sorted within the memory budget.
- Shared files: the partition column can't be set, since the rows would leave the partitions keys are granted.
Is it worth it?
Measured on 20,000 clients with 11 detail columns and 300,000 transactions (15 per client), Node.js, with clients in 256-row chunks and transactions in 1,024-row chunks in both files:
| Flat (details on every row) | Two tables | |
|---|---|---|
| File size | 3.70 MB | 3.39 MB (−8%) |
| Write | 1.28 s | 0.94 s (−27%) |
| One client's statement (open, client, 15 lines) | 1.14 ms | 1.03 ms with openTable |
| Read every transaction | 176 ms | 145 ms (−18%) |
The size gain is modest because dictionary encoding already stores a chunk's repeated values once. The bigger gains: each table is read without decoding the other's columns, a client's details change in one row, and each table's chunk size can suit how it is read.