Repository navigation
DotNet Serializer and LINQ
The Jazmin package gives .NET three levels of control:
| Level | API | Use when |
|---|---|---|
| Newtonsoft-style one-liners |
JazminConvert.SerializeObject(list), DeserializeObject<T>(bytes)
|
Replacing JSON serialization with minimal changes |
| Serializer with settings |
new JazminSerializer(settings).Serialize(path, items), DeserializeEnumerable<T>(stream)
|
Files, encryption, indexes, streaming |
| Reader with LINQ |
reader.Query<T>(predicate), reader.AsQueryable<T>()
|
Querying a file without loading it |
// fragment: the namespaces used on this page
using Jazmin; // JazminReader, JazminWriter, JazminKey, JazminFile
using Jazmin.Query; // JazminFilter
using Jazmin.Serialization; // JazminConvert, JazminSerializer, attributes, settingsThe column list comes from your class's public properties. Attributes refine it.
using System.ComponentModel;
using Jazmin;
using Jazmin.Serialization;
var customers = new List<Customer>
{
new() { Id = 1, Name = "Ann Johnson", Country = "ZA", Tier = Tier.Gold, Balance = 1520.75m,
Joined = new DateTime(2024, 1, 2, 0, 0, 0, DateTimeKind.Utc), Tags = ["vip"], PasswordHash = "secret" },
};
byte[] bytes = JazminConvert.SerializeObject(customers);
using var reader = JazminReader.Open(bytes);
foreach (var c in reader.Columns) Console.WriteLine($"{c,-22} {c.Description}");
Console.WriteLine(string.Join(", ", reader.Indexes.Select(i => $"{i.Column}: {i.Kind}")));
var back = JazminConvert.DeserializeObject<List<Customer>>(bytes)![0];
Console.WriteLine($"{back.Name}, {back.Tier}, {back.Balance}, tags [{string.Join(", ", back.Tags)}], hash {back.PasswordHash ?? "(not stored)"}");
public enum Tier { Standard, Gold }
public class Customer
{
[JazminIndex] // a sorted index
public int Id { get; set; }
[JazminIndex(JazminIndexKind.Sorted, JazminIndexKind.Trigram)]
[Description("Full name")] // System.ComponentModel
public string Name { get; set; } = "";
[JazminProperty("country_code", Description = "ISO 3166 alpha-2")]
public string? Country { get; set; }
public Tier Tier { get; set; } // enums are stored as their names
public decimal Balance { get; set; } // exact
public DateTime Joined { get; set; }
public List<string> Tags { get; set; } = []; // stored in a json column
[JazminIgnore] // or System.Text.Json's [JsonIgnore]
public string? PasswordHash { get; set; }
}Id: int
Name: string? Full name
country_code: string? ISO 3166 alpha-2
Tier: string
Balance: decimal
Joined: datetime
Tags: json?
Id: Sorted, Name: Sorted, Name: Trigram
Ann Johnson, Gold, 1520.75, tags [vip], hash (not stored)
| .NET property type | Column type |
|---|---|
bool |
bool |
| integer types | int |
float, double
|
float |
decimal |
decimal |
string, char, Guid, TimeSpan, TimeOnly, enums |
string |
DateTime, DateTimeOffset, DateOnly
|
datetime |
byte[] |
binary |
| anything else (lists, dictionaries, nested classes) | json |
-
Nullability: value types (
int,decimal,DateTime, enums) make non-nullable columns unless declared nullable (int?). Reference types (string, lists, classes) make nullable columns. -
Names:
[JazminProperty("name")]or System.Text.Json's[JsonPropertyName]. Column names are matched case-insensitively when reading, as Newtonsoft does. - Records and immutable classes are created through their constructor.
var settings = new JazminSerializerSettings
{
Key = JazminKey.Parse(Environment.GetEnvironmentVariable("JAZMIN_KEY")!), // or Password = "..."
Codec = JazminCodec.Brotli,
ChunkRows = 4096,
Metadata = new JsonObject { ["source"] = "crm" },
Indexes = new() { ["Country"] = [JazminIndexKind.Sorted] }, // indexes without attributes
Formatting = Formatting.Indented, // JSON output (ToJson)
NullValueHandling = NullValueHandling.Ignore, // JSON output (ToJson)
NamingStrategy = JazminNamingStrategy.CamelCase, // FirstName -> firstName (also SnakeCase, KebabCase)
Converters = [new MoneyConverter()], // your own types
DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, // defaults stored as null, filled in on reading
PreserveReferencesHandling = PreserveReferencesHandling.Objects, // a repeated object is stored once ($id / $ref)
};Settings that change how types map to columns (naming, converters, default values, references) are cached per settings instance. Create the settings once and reuse them, as with Newtonsoft.
A converter stores a type of your own in a column, like Newtonsoft's JsonConverter. Register it in Converters,
or put [JazminConverter(typeof(...))] on a property or on the type.
using Jazmin;
using Jazmin.Serialization;
var settings = new JazminSerializerSettings
{
NamingStrategy = JazminNamingStrategy.SnakeCase,
Converters = [new MoneyConverter()],
};
var invoices = new[] { new Invoice { InvoiceNumber = "INV-1", TotalDue = new Money(152075, "ZAR") } };
byte[] bytes = JazminConvert.SerializeObject(invoices, settings);
using var reader = JazminReader.Open(bytes);
Console.WriteLine(string.Join(", ", reader.Columns));
Console.WriteLine(reader.Get(0)["total_due"]);
var back = JazminConvert.DeserializeObject<List<Invoice>>(bytes, settings)![0];
Console.WriteLine($"{back.InvoiceNumber}: {back.TotalDue}");
public readonly record struct Money(long Cents, string Currency);
public sealed class MoneyConverter : JazminConverter<Money>
{
public override JazminType ColumnType => JazminType.String; // how the column stores it
public override object? Write(Money value) => $"{value.Cents} {value.Currency}";
public override Money Read(object stored)
{
var parts = ((string)stored).Split(' ');
return new Money(long.Parse(parts[0]), parts[1]);
}
}
public class Invoice
{
public string InvoiceNumber { get; set; } = "";
public Money TotalDue { get; set; }
}invoice_number: string?, total_due: string
152075 ZAR
INV-1: Money { Cents = 152075, Currency = ZAR }
Filters and LINQ conditions on a converted property run in memory after reading, because the file stores the converted form.
Opt-in, with a closed list: name the derived types on the base type with System.Text.Json's [JsonDerivedType].
Each row records its type in a $type column.
using System.Text.Json.Serialization;
using Jazmin;
using Jazmin.Serialization;
List<Animal> animals = [new Dog { Name = "Rex", Barks = true }, new Cat { Name = "Tom", Lives = 9 }];
byte[] bytes = JazminConvert.SerializeObject(animals);
foreach (var a in JazminConvert.DeserializeObject<List<Animal>>(bytes)!)
Console.WriteLine(a switch { Dog d => $"Dog {d.Name}, barks: {d.Barks}", Cat c => $"Cat {c.Name}, lives: {c.Lives}", _ => "?" });
[JsonDerivedType(typeof(Dog), "dog")]
[JsonDerivedType(typeof(Cat), "cat")]
public abstract class Animal { public string Name { get; set; } = ""; }
public class Dog : Animal { public bool Barks { get; set; } }
public class Cat : Animal { public int Lives { get; set; } }Dog Rex, barks: True
Cat Tom, lives: 9
Newtonsoft's TypeNameHandling loads whatever type the data names, which has caused serious security bugs. JAZMIN
creates only the types listed on the base type, and refuses any other name.
reader.Query<T>(predicate) always returns exactly what the predicate selects. The library translates as much of the
predicate as it can into an index-aware filter, then runs your compiled predicate on each candidate. Translation
affects only speed, never results.
| Expression | Uses indexes and statistics |
|---|---|
==, !=, <, <=, >, >= against constants or captured variables (bool, numbers, strings, enums, dates) |
✔ |
&&, ||, ! on comparisons |
✔ |
x.Name.Contains("a"), x.Name.StartsWith("a")
|
✔ |
list.Contains(x.Country) |
✔ |
x.IsActive (a bool property) |
✔ |
decimal comparisons, method calls, arithmetic (x.Id % 7 == 0) |
✘ scanned, still correct |
Write the query with ordinary LINQ operators. The reader runs as much of it as it can, so less of the file is read and fewer objects are built. Results are always those LINQ gives over a list of the same objects.
using Jazmin;
using Jazmin.Serialization;
var regions = new[] { "ZA", "NA", "BW", "KE" };
var all = Enumerable.Range(1, 100_000).Select(i => new Order
{
Id = i, Region = regions[i % 4], Customer = $"C-{i * 7919 % 5000:D4}", Amount = i % 1000 / 4m, Paid = i % 5 != 0,
});
new JazminSerializer(new JazminSerializerSettings { Indexes = new() { ["Customer"] = [JazminIndexKind.Sorted] } })
.Serialize("orders.jzm", all);
using var reader = JazminReader.Open("orders.jzm");
var orders = reader.AsQueryable<Order>();
var page = orders.Where(o => o.Region == "ZA").Skip(40).Take(3).Select(o => o.Id).ToList(); // filter, offset, limit in the reader
int za = orders.Count(o => o.Region == "ZA"); // no objects built
bool any = orders.Any(o => o.Customer == "C-1042"); // an index lookup
var lines = orders.Where(o => !o.Paid && o.Amount > 248m).Select(o => new { o.Id, o.Amount }).Take(2).ToList(); // reads 3 columns
decimal total = orders.Where(o => o.Region == "NA").Sum(o => o.Amount); // reads 2 columns
var top = orders.Where(o => o.Region == "KE").OrderByDescending(o => o.Amount).ThenBy(o => o.Id).Select(o => o.Id).Take(3).ToList(); // sorting runs in memory
Console.WriteLine($"page: {string.Join(", ", page)}");
Console.WriteLine($"ZA orders: {za}, any for C-1042: {any}");
Console.WriteLine($"unpaid over 248: {string.Join("; ", lines)}");
Console.WriteLine($"NA total: {total}");
Console.WriteLine($"top KE: {string.Join(", ", top)}");
public class Order
{
public int Id { get; set; }
public string Region { get; set; } = "";
public string Customer { get; set; } = "";
public decimal Amount { get; set; }
public bool Paid { get; set; }
}page: 164, 168, 172
ZA orders: 25000, any for C-1042: True
unpaid over 248: { Id = 995, Amount = 248.75 }; { Id = 1995, Amount = 248.75 }
NA total: 3118750.00
top KE: 999, 1999, 2999
| Operator | What the reader does |
|---|---|
Where, and the condition of Count, Any, First, Single, Last
|
Becomes a filter, as in Query<T>: indexes and chunk statistics, with parts it can't translate checked on each object |
Skip, Take
|
The filter's offset and limit: whole chunks before the offset are skipped unread when nothing is checked per object |
Count, LongCount, Any
|
Answered without building objects, from indexes or chunk statistics where the filter allows |
OrderBy / ThenBy along the file's sortedBy columns |
Nothing to do: rows are stored in that order (ascending, non-nullable columns; strings ordinal) |
Select; Sum, Average, Min, Max with a selector |
Only the columns they use are read |
Everything from the first operator it can't do (OrderByDescending, GroupBy, Join, a Where after Take...) |
Runs in memory on the rows read, as LINQ to Objects. From 1.3.0 the rows are read with only the columns the whole query uses (below) |
Each table of a file with several tables has its own AsQueryable<T>(). Lambda (method) syntax and
query syntax both work, and the reader runs the filter, the columns and the order it can.
using Jazmin;
using Jazmin.Query;
using Jazmin.Serialization;
using (var writer = JazminWriter.Create("clients.jzm", new JazminWriteOptions
{
Tables =
[
new JazminTable("clients", [new JazminColumn("clientId", JazminType.String), new JazminColumn("name", JazminType.String)]) { SortedBy = ["clientId"] },
new JazminTable("transactions", [new JazminColumn("clientId", JazminType.String), new JazminColumn("amount", JazminType.Float)]) { SortedBy = ["clientId"] },
],
}))
{
writer.WriteValues("C1", "Acme");
writer.WriteValues("C2", "Bolt");
writer.StartTable("transactions");
writer.WriteValues("C1", 10.0);
writer.WriteValues("C1", 5.0);
writer.WriteValues("C2", 7.0);
}
using var clients = JazminReader.Open("clients.jzm");
using var transactions = clients.OpenTable("transactions"); // the same open file
var camel = new JazminSerializerSettings { NamingStrategy = JazminNamingStrategy.CamelCase }; // ClientId -> clientId
var clientRows = clients.AsQueryable<Client>(camel);
var transactionRows = transactions.AsQueryable<Transaction>(camel);
string name = clientRows.Where(c => c.ClientId == "C1").Select(c => c.Name).Single(); // lambda syntax
var amounts = (from t in transactionRows where t.ClientId == "C1" select t.Amount).ToList(); // query syntax
var ids = clientRows.Where(c => c.Name.StartsWith("B")).Select(c => c.ClientId).ToList();
var theirs = transactionRows.Where(t => ids.Contains(t.ClientId)).ToList(); // an `in` filter
var totals = from c in clientRows.ToList() // a join runs in memory
join t in transactionRows.ToList() on c.ClientId equals t.ClientId into ts
select $"{c.Name} {ts.Sum(t => t.Amount)}";
Console.WriteLine($"{name}: {string.Join(" + ", amounts)}");
Console.WriteLine($"clients starting with B: {theirs.Count} transaction(s)");
Console.WriteLine(string.Join(", ", totals));
public class Client
{
public string ClientId { get; set; } = "";
public string Name { get; set; } = "";
}
public class Transaction
{
public string ClientId { get; set; } = "";
public double Amount { get; set; }
}Acme: 10 + 5
clients starting with B: 1 transaction(s)
Acme 15, Bolt 7
LINQ runs a sub-query inside a lambda once per outer row. From 1.3.0, JAZMIN reads such a sub-query's table once instead:
-
Paired with the outer row: when the condition pairs a member with a value of the outer row
(
o.CustomerId == c.Id), the table is kept by that member, and each outer row takes its group from the lookup (a hash join). - Anything else runs as LINQ to Objects over the table's rows, read once.
- Adaptive: the first rows still run their own queries, which use indexes, so a query for one customer never reads a whole table.
-
Memory cap: tables over 1,000,000 rows (100,000 with priority
Memory, 10,000,000 withSpeed) keep a query per row.
using Jazmin;
using Jazmin.Serialization;
using (var writer = JazminWriter.Create("shop.jzm", new JazminWriteOptions
{
Tables =
[
new JazminTable("customers", [new JazminColumn("Id", JazminType.Int), new JazminColumn("Name", JazminType.String)]) { SortedBy = ["Id"] },
new JazminTable("orders", [new JazminColumn("Id", JazminType.Int), new JazminColumn("CustomerId", JazminType.Int), new JazminColumn("Total", JazminType.Decimal)]),
],
}))
{
for (var c = 1; c <= 3; c++) writer.WriteValues((long)c, $"Customer {c}");
writer.StartTable("orders");
for (var o = 1; o <= 8; o++) writer.WriteValues((long)o, (long)(1 + o % 3), $"{o * 10}.50");
}
using var customers = JazminReader.Open("shop.jzm");
using var ordersTable = customers.OpenTable("orders");
var orders = ordersTable.AsQueryable<Order>();
var report = customers.AsQueryable<Customer>()
.Select(c => new
{
c.Name,
Latest = orders.Where(o => o.CustomerId == c.Id).OrderByDescending(o => o.Id).Select(o => o.Id).FirstOrDefault(),
Spent = orders.Where(o => o.CustomerId == c.Id).Sum(o => o.Total),
})
.ToList();
foreach (var line in report) Console.WriteLine(line);
public class Customer { public int Id { get; set; } public string Name { get; set; } = ""; }
public class Order { public int Id { get; set; } public int CustomerId { get; set; } public decimal Total { get; set; } }{ Name = Customer 1, Latest = 6, Spent = 91.00 }
{ Name = Customer 2, Latest = 7, Spent = 121.50 }
{ Name = Customer 3, Latest = 8, Spent = 151.50 }
The results are those of LINQ to Objects. On 5,000 customers and 50,000 orders, the latest 5 orders of each customer, with their product and payment, take 0.43 s instead of 64 s. The same code over in-memory lists takes 2.1 s, because each customer scans every order.
A Join or GroupBy across tables runs in memory, as LINQ to Objects. For nested output, such as each client with
their transactions, use an export shape with $from (1.3.0): it
reads each table once when the tables are sorted by the link.
Measured on .NET 10, 200,000 rows × 300 columns, a type mapping 9 of them (1.1.0's Query<T> followed by LINQ to
Objects, against 1.2's AsQueryable):
| Query | 1.1.0 | AsQueryable |
|---|---|---|
| Every row as objects | 3.2–3.7 s | 0.30 s |
A condition and two columns (Where + Select) |
3.6 s | 0.35–0.37 s |
Count(condition) |
3.3–3.9 s, 63 MB | 0.25 s, 56 MB |
Skip(150_000).Take(10) |
2.5–2.7 s, 63 MB | 0.05 s, 35 MB |
OrderBy(o => o.Id).Take(10) (file sorted by id) |
3.5 s, 100 MB | 0.05 s, 35 MB |
Sum(o => o.Qty) |
3.2–3.4 s | 0.25 s |
First(o => o.Id == 123_456) |
0.08–0.09 s | 0.07 s |
- Typed rows read only the columns their type maps: a class of 9 properties over a 300-column file decodes 9.
- Only the columns a query uses (1.3.0), wherever it uses them: see below.
-
Files written with
PreserveReferencesHandling.Objectsrun the whole query in memory, because their rows refer to earlier rows. -
Not thread-safe, like the reader. For async code, use
QueryAsync<T>andRowsAsync<T>.
From 1.3.0, AsQueryable<T>() reads only the columns of the members a query uses, wherever it uses them:
- in
Where,Select,SelectMany,GroupBy,Joinand nested queries; - through anonymous objects and groups (
g.OrderByDescending(x => x.Revenue).First().Project); - in a
Joinwith another table, or the same one: each side reads only the columns used of it.
Before 1.3.0, only queries ending in Select or an aggregate read fewer columns.
A collection member (List<Department> below) is one JSON column. A query that uses it reads that column whole: every
department, employee and project in it. To read nested details piece by piece, keep them in their own
tables.
JSON columns, read straight into your types (1.3.0). A JSON column is deserialized from its stored UTF-8 directly
into the property's type, with no text or JsonNode in between. An untyped row still gives a JsonNode, made when the
value is first read. On 200,000 orders whose customer and items are JSON columns, the report in
Performance went from 6.8 s and 1.6 GB allocated (1.2.0) to 3.1 s and 965 MB.
using Jazmin;
using Jazmin.Serialization;
var companies = new[] { "Acme", "Bolt" }.Select((name, c) => new Company
{
Name = name,
Founded = 1990 + c,
Departments = [.. new[] { "Sales", "Build" }.Select((d, i) => new Department
{
Name = d,
Employees = [.. Enumerable.Range(0, 2).Select(e => new Employee
{
Name = $"{name} {d} {e}",
Role = e == 0 ? "Lead" : "Member",
Salary = 1000m * (e + 1),
Projects = [new() { Name = $"{d} {c}{e}", Category = i == 0 ? "Product" : "Service", Revenue = 6_000m * (c + i + e + 1) }],
})],
})],
}).ToList();
File.WriteAllBytes("companies.jzm", JazminConvert.SerializeObject(companies));
using var reader = JazminReader.Open("companies.jzm");
var report = reader.AsQueryable<Company>() // reads one column: Departments
.SelectMany(c => c.Departments)
.SelectMany(d => d.Employees, (d, e) => new { Department = d.Name, Employee = e })
.SelectMany(x => x.Employee.Projects, (x, p) => new
{
Employee = x.Employee.Name,
x.Department,
x.Employee.Role,
x.Employee.Salary,
Project = p.Name,
p.Category,
p.Revenue,
})
.Where(x => x.Revenue > 10_000)
.GroupBy(x => new { x.Department, x.Category })
.Select(g => new
{
g.Key.Department,
g.Key.Category,
Revenue = g.Sum(x => x.Revenue),
Employees = g.Select(x => x.Employee).Distinct().Count(),
TopProject = g.OrderByDescending(x => x.Revenue).First().Project,
})
.OrderByDescending(x => x.Revenue);
foreach (var line in report) Console.WriteLine(line);
public class Company
{
public string Name { get; set; } = "";
public int Founded { get; set; }
public List<Department> Departments { get; set; } = [];
}
public class Department
{
public string Name { get; set; } = "";
public List<Employee> Employees { get; set; } = [];
}
public class Employee
{
public string Name { get; set; } = "";
public string Role { get; set; } = "";
public decimal Salary { get; set; }
public List<Project> Projects { get; set; } = [];
}
public class Project
{
public string Name { get; set; } = "";
public string Category { get; set; } = "";
public decimal Revenue { get; set; }
}{ Department = Build, Category = Service, Revenue = 72000, Employees = 4, TopProject = Build 11 }
{ Department = Sales, Category = Product, Revenue = 42000, Employees = 3, TopProject = Sales 11 }
Every column is read when the rows themselves may be used in ways the query doesn't show:
- you receive them, or call
ToList()before the rest of the query; - they're passed to a method or formatted, or a method of the row is called;
- they're compared or sorted whole (
Distinct(),Union, aGroupBykey of whole rows), or cast; - a computed property is used.
Results are the same either way. On 200,000 orders of 10 members:
| Query | 1.2.0 | 1.3.0 |
|---|---|---|
GroupBy(o => o.Region).Select(g => new { g.Key, Total = g.Sum(o => o.Amount) }) |
1.1 s, 261 MB | 0.25 s, 57 MB |
Where + GroupBy on two keys, top product per group |
130 ms, 54 MB | 92 ms, 23 MB |
SelectMany over a list member (one JSON column, parsed whole) |
514 ms, 274 MB | 502 ms, 183 MB |
With NestedColumns = true (or [JazminNested] on a property), lists and classes are stored as columns of their own
fields instead of JSON text: smaller files, and reads several times faster. AsQueryable<T>() then reads only the
nested fields a query uses, and conditions such as c.Departments.Any(d => d.Name == "Sales"),
c.Tags.Contains("vip") or c.Head.City == "Durban" are checked as the file is decoded. Reading needs no settings.
See Nested Columns.
The parts of a query that run in memory are compiled the first time a query of that shape runs, then reused with each
call's values: a lookup in a loop, q.Where(c => c.Id == id).Select(c => c.Name), takes 0.34 ms instead of 1.05 ms.
The same holds for reader.Query<T>(predicate). Nothing to set up.
The library runs in the browser with Blazor WebAssembly, LINQ included: the sample
dotnet/samples/Jazmin.Blazor makes a
file of two tables in the browser and queries both, in lambda and query syntax. Unencrypted files only: .NET in the
browser has no AES-GCM or ECDSA, which files locked with a key or password, and shared files, need. For those, use the
viewer or the browser reader (JavaScript, with JazminBrowser.from for LINQ-style queries), or
the library on a server.
// Writing: Serialize takes any IEnumerable<T> and never collects it
new JazminSerializer(settings).Serialize("big.jzm", database.ReadCustomers());
// Reading without a reader: one object at a time
using var stream = File.OpenRead("big.jzm");
foreach (var c in new JazminSerializer(settings).DeserializeEnumerable<Customer>(stream)) Process(c);
// Async sources and consumers
await foreach (var c in reader.QueryAsync<Customer>(c => c.Country == "ZA")) await SendAsync(c);string json = JazminConvert.ToJson(bytes, settings, JazminFilter.Eq("Country", "ZA")); // for an API response
byte[] jzm = JazminConvert.FromJson(json, settings); // from an API requestFor large results, stream them with JazminJsonStream instead of building a string: see
Converting JSON, CSV and XML.
JAZMIN 1.2 · file format 1.0 · Repository · npm · NuGet · MIT license · Examples in this wiki are run and checked by tools/check-examples.mjs
Start
Using files
- Writing Files
- Nested Columns
- Reading and Querying
- Filter Language
- .NET Serializer and LINQ
- Updating and Appending
- Several Tables
- Converting JSON, CSV, XML
- Export Shapes
Security and sharing
- Encryption and Keys
- Access Control
- Masked Columns
- Time-Limited Access
- Sending Records Back
- Security Model
Documents and apps
Performance
Reference