Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,40 @@ index); batch writes **−44%**; database file with three indexes **20.2 → 8.4

### Added

- **`AddTypeRegistration<T>()` now detects the id property by convention, as documented.** It
previously did nothing of the kind: it recorded no selector, so `WriteObjectAsync(obj)`,
`ReadObjectAsync(obj)`, `ObjectExistsAsync(obj)`, `DeleteObjectAsync(obj)` and `GetIdFor(obj)`
all threw `TychoException: An id mapping has not been provided`, on a type whose property was
literally named `Id`. The property is now found by name — `Id`, then `<TypeName>Id`, matched
case-insensitively, and it must be public, readable and non-indexed. When no such property
exists the type is still registered but without an id mapping, exactly as before, so
registering a key-less type and supplying keys at the call site keeps working.
- **Strict registration now rejects a key that diverges from the registered id property.**
`WriteObjectsAsync(objs, keySelector, …)` takes a key at the call site and overrides the
registration. A row written under a key the registration would not produce is unreachable by
every by-object overload, and the delete failure is silent — `DeleteObjectAsync(obj)` returns
false while the row survives. With `requireTypeRegistration: true` and a type registered by id
property, such a write now throws `TychoException` naming both keys. Delegate registrations
(`AddTypeRegistrationWithCustomKeySelector`) have no property to compare against and are
unaffected, as is everything outside strict mode. The check wraps the selector rather than
pre-scanning, so a lazy sequence is still enumerated exactly once.
- **Filters on the id property are answered from the `Key` column where that is provably
correct.** `Filter(Equals, x => x.Id, …)` and `Filter(In, x => x.Id, …)` previously went
through `JSON_EXTRACT` and scanned. Under `requireTypeRegistration` with a type registered by
id property, they are now emitted against the indexed `Key` column instead:

| filter on the id property, 250,000 rows | scan | rewritten |
|---|---:|---:|
| `Equals` | 79.3 ms | **0.0 ms** |
| `In`, 100 keys | 101.2 ms | **0.2 ms** |

Soundness comes from two things together: the write guard above means no row written through
this instance can diverge, and rows already in the database are checked once per type with a
divergence probe before the rewrite is used (~92 ms on that store, on the first such query
only, then cached for the connection). A single divergent row disables the rewrite for that
type and the ordinary predicate is emitted, so the worst case is the behaviour that was there
before. Negated forms (`NotEquals`, `NotIn`) are deliberately left alone — they cannot use an
index either way — as is a null comparison value, since `Key` is `NOT NULL`.
- **`ReadObjectsByKeysAsync<T>(keys, partition, sort, …)`.** Reads a batch of keys in one round
trip. The key set is bound as a **single JSON array** expanded by `JSON_EACH`, not as one
parameter per key, so there is no `SQLITE_MAX_VARIABLE_NUMBER` ceiling (999 on older SQLite
Expand Down Expand Up @@ -243,15 +277,21 @@ index); batch writes **−44%**; database file with three indexes **20.2 → 8.4

### Breaking changes

- **`AddTypeRegistration<T>()` on a type with a conventional id property now supplies a key.**
Previously every by-object operation on such a type threw; they now work. Code that caught
that exception, or that relied on `WriteObjectsAsync(objs, keySelector)` disagreeing with a
conventionally-named `Id` property, changes behaviour — under `requireTypeRegistration` the
disagreement is now an error rather than a silently unreachable row.

- **An ungrouped `Or()` now means what it reads as.** Code that (unknowingly) depended on the
leaked rows — most plausibly a query written against a single-partition, single-type database
where the bug was invisible — returns fewer rows now. This is the fix, not a regression.
- **Passing a collection to a scalar `FilterType` now throws `ArgumentException`.** Adding the
`IEnumerable` overloads changes overload resolution for a collection argument, which
previously bound to `object` and was rendered as `ToString()` (`"System.Int32[]"`), matching
nothing silently. Use `FilterType.In`. A literal `null` argument also now binds to the new
overload, but keeps its old meaning — `Filter(Equals, x => x.Value, null)` is still the
null comparison.
- **An ungrouped `Or()` now means what it reads as.** Code that (unknowingly) depended on the
leaked rows — most plausibly a query written against a single-partition, single-type database
where the bug was invisible — returns fewer rows now. This is the fix, not a regression.
- **Enum, `DateOnly` and `TimeOnly` filter values now compare against their JSON form.** Code
that worked around the enum mismatch by casting to `(int)` keeps working. Code that relied on
a string-enum converter's name matching by coincidence also keeps working, and now stays
Expand Down Expand Up @@ -304,6 +344,7 @@ index); batch writes **−44%**; database file with three indexes **20.2 → 8.4
receives rows as UTF-8 spans. Deserialization dominates any large read: of the 67.3 ms
`ReadObjectsByKeysAsync` takes for 23,784 keys, only 27.5 ms is the query.


- Performance guidance: prefer `WriteObjectsAsync` for writing many objects — it is
~10× faster and ~6× lower-allocation than looping `WriteObjectAsync`, and
`withTransaction: true` is faster than `false` for bulk writes.
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,13 @@ var count = await db.CountObjectsAsync<Person>();
var people = await db.ReadObjectsByKeysAsync<Person>(new object[] { "id-1", "id-2", "id-3" });
```

> **Filtering on the property that is also the Tycho key** (`x => x.Id`) goes through
> `JSON_EXTRACT` and scans — it does not use the primary key, because Tycho stores the key in
> its own `Key` column and cannot assume the property still matches it (a write may supply its
> own key selector). Reach those rows through `ReadObjectAsync` / `ReadObjectsByKeysAsync`, or
> index the property like any other. On a 250,000-row store, one equality lookup measured
> 71.6 ms as an unindexed filter and 0.0 ms all three other ways.
> **Filtering on the property that is also the Tycho key** (`x => x.Id`) normally goes through
> `JSON_EXTRACT` and scans, because a write may supply its own key selector and Tycho cannot
> assume the property still matches the stored key. Under `requireTypeRegistration: true`, with
> the type registered by id property, that assumption *is* enforced, and `Equals` / `In` filters
> on the id property are answered from the indexed `Key` column instead — 79.3 ms to 0.0 ms on a
> 250,000-row store. Otherwise, reach those rows through `ReadObjectAsync` /
> `ReadObjectsByKeysAsync`, or index the property like any other.

### Filtering

Expand Down
Loading