Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,8 @@ IReadOnlyList<string> indexes = orders.ListIndexes();
orders.DropIndex("idx_status");
```

A unique index rejects a second document with the same value (`InvalidOperationException`); `DynamicCollection` indexes an explicit BSON `null` and rejects a second one, whereas typed collections do not index `null` keys, so any number of documents may hold `null` in a unique-indexed property.

### Reading BsonDocument fields

```csharp
Expand Down
7 changes: 7 additions & 0 deletions src/BLite.Core/Collections/DocumentCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2194,6 +2194,9 @@ private async Task InsertDataCore(TId id, T entity, byte[] docData, ITransaction
: default;
// ────────────────────────────────────────────────────────────────────

// Unique secondary indexes are checked before any page is written.
_indexManager.EnsureUniqueInAll(entity, null, transaction);

DocumentLocation location;
if (_isTimeSeries)
{
Expand Down Expand Up @@ -2782,6 +2785,10 @@ private async Task<bool> UpdateAtLocationCore(TId id, T entity, byte[] docData,
var oldEntity = await FindByLocation(oldLocation, transaction);
if (oldEntity == null) return false;

// Unique secondary indexes are checked before any page is written; only those whose
// key changed, so documents that already share a value can still be updated.
_indexManager.EnsureUniqueInAll(entity, oldEntity, transaction);

// Read old page
var pageBuffer = ArrayPool<byte>.Shared.Rent(_storage.PageSize);
try
Expand Down
43 changes: 43 additions & 0 deletions src/BLite.Core/Indexing/CollectionIndexManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public sealed class CollectionIndexManager<TId, T> : IDisposable where T : class
private readonly string _collectionName;
private CollectionMetadata _metadata;
private volatile IReadOnlyList<CollectionIndexInfo>? _cachedIndexInfo;
// Whether any index is a unique BTree index, so EnsureUniqueInAll can skip the loop.
// Refreshed under _lock whenever _indexes changes.
private volatile bool _hasUniqueBTreeIndex;

[RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")]
[RequiresUnreferencedCode("Index creation uses reflection (Expression.PropertyOrField) to access type members. Ensure all entity types and their members are preserved.")]
Expand Down Expand Up @@ -54,10 +57,20 @@ public CollectionIndexManager(StorageEngine storage, IDocumentMapper<TId, T> map
rootAllocated |= index.RootPageId != idxMeta.RootPageId;
}

RefreshHasUniqueBTreeIndex();

if (rootAllocated)
SaveMetadata();
}

private void RefreshHasUniqueBTreeIndex()
{
var any = false;
foreach (var index in _indexes.Values)
any |= index.IsUniqueBTree;
_hasUniqueBTreeIndex = any;
}

private void UpdateMetadata()
{
_metadata.Indexes.Clear();
Expand Down Expand Up @@ -101,6 +114,7 @@ public CollectionSecondaryIndex<TId, T> CreateIndex(CollectionIndexDefinition<T>
var secondaryIndex = new CollectionSecondaryIndex<TId, T>(definition, _storage, _mapper);
_indexes[definition.Name] = secondaryIndex;
_cachedIndexInfo = null; // invalidate cache
RefreshHasUniqueBTreeIndex();

// Persist metadata
UpdateMetadata();
Expand Down Expand Up @@ -279,6 +293,7 @@ public bool DropIndex(string name)
index.Dispose();
_indexes.Remove(name);
_cachedIndexInfo = null; // invalidate cache
RefreshHasUniqueBTreeIndex();

SaveMetadata(); // Save metadata after dropping index
return true;
Expand Down Expand Up @@ -307,6 +322,7 @@ internal CollectionSecondaryIndex<TId, T> RecreateIndex(string name)
};
_indexes[name] = fresh;
_cachedIndexInfo = null; // invalidate cache
RefreshHasUniqueBTreeIndex();

// Persist the new root before freeing the old tree: a crash in between leaks the old
// pages, whereas the reverse order would let the next open free them a second time.
Expand Down Expand Up @@ -453,6 +469,32 @@ public void InsertIntoAll(T document, DocumentLocation location, ITransaction tr
}
}

/// <summary>
/// Throws if <paramref name="document"/> would violate any unique index.
/// Call before any page is written, so a rejected write leaves nothing behind.
/// On update, pass the stored version as <paramref name="oldDocument"/>: only indexes whose
/// key changed are checked, so documents that already share a value can still be updated.
/// </summary>
/// <param name="document">Document about to be inserted or written by an update</param>
/// <param name="oldDocument">Stored version of the document on update; null on insert</param>
/// <param name="transaction">Transaction context</param>
public void EnsureUniqueInAll(T document, T? oldDocument, ITransaction transaction)
{
if (document == null)
throw new ArgumentNullException(nameof(document));

if (!_hasUniqueBTreeIndex)
return;

lock (_lock)
{
foreach (var index in _indexes.Values)
{
index.EnsureUnique(document, oldDocument, transaction);
}
}
}

/// <summary>
/// Updates a document in all indexes
/// </summary>
Expand Down Expand Up @@ -599,6 +641,7 @@ public void Dispose()
}

_indexes.Clear();
_hasUniqueBTreeIndex = false;
_disposed = true;
}

Expand Down
67 changes: 67 additions & 0 deletions src/BLite.Core/Indexing/CollectionSecondaryIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ public sealed class CollectionSecondaryIndex<TId, T> : IDisposable, ICollectionI
private static readonly IndexKey s_maxBoundaryKey;
// 16 bytes of 0xFF covers any GUID or ObjectId suffix in composite keys.
private static readonly IndexKey s_maxIdBoundaryKey;
// Id types whose index key always has the same length (used by EnsureUnique).
private static readonly bool s_fixedSizeId =
typeof(TId) == typeof(ObjectId) || typeof(TId) == typeof(Guid) ||
typeof(TId) == typeof(int) || typeof(TId) == typeof(long) ||
typeof(TId) == typeof(double) || typeof(TId) == typeof(decimal);

static CollectionSecondaryIndex()
{
Expand Down Expand Up @@ -403,6 +408,68 @@ public CollectionIndexInfo GetInfo()
};
}

/// <summary>
/// True for a unique BTree index, the only kind <see cref="EnsureUnique"/> checks.
/// </summary>
internal bool IsUniqueBTree => _definition.IsUnique && _btreeIndex != null;

/// <summary>
/// Throws if this is a unique BTree index and another document already holds
/// <paramref name="document"/>'s key. The document's own entry (same id) is ignored.
/// On update, <paramref name="oldDocument"/> is the stored version: if its key equals the
/// new one, <see cref="Update"/> keeps the existing entry and nothing is checked, so a
/// document that already shares its value with another can still be updated.
/// Entries are stored as <c>[user key][document id]</c>, so the BTree's own unique
/// check never sees two equal keys; the collection calls this before writing any page.
/// </summary>
internal void EnsureUnique(T document, T? oldDocument, ITransaction? transaction)
{
if (!IsUniqueBTree)
return;

if (!_definition.TryGetKey(document, out var keyValue) || keyValue == null)
return; // null keys are not indexed, so they never conflict

var userKey = ConvertToIndexKey(keyValue);

if (oldDocument != null
&& _definition.TryGetKey(oldDocument, out var oldKeyValue) && oldKeyValue != null
&& (Equals(oldKeyValue, keyValue) || ConvertToIndexKey(oldKeyValue).Data.SequenceEqual(userKey.Data)))
return; // key unchanged

// Every id key starts with its type's prefix byte, so the entries of this value lie in
// [user key][prefix] .. [user key][prefix + 1]. Bounding the scan there skips the entries
// of longer values starting with this one (e.g. "10", "11", ... when checking "1").
var ownIdKey = _mapper.ToIndexKey(_mapper.GetId(document));
var idPrefix = ownIdKey.Data[0];
var minComposite = CreateIdPrefixBoundary(userKey, idPrefix);
var maxComposite = CreateIdPrefixBoundary(userKey, (byte)(idPrefix + 1));

foreach (var entry in _btreeIndex!.Range(minComposite, maxComposite, IndexDirection.Forward, transaction?.TransactionId))
{
var idPart = entry.Key.Data.Slice(userKey.Data.Length);
if (idPart.SequenceEqual(ownIdKey.Data))
continue; // this document's own entry

// A longer value whose next byte is the prefix byte itself still falls in the range
// (e.g. "ab\u0002c" when checking "ab"); for fixed-size ids its id part has the wrong length.
if (s_fixedSizeId && idPart.Length != ownIdKey.Data.Length)
continue;

throw new InvalidOperationException(
$"Duplicate key violation for unique index '{_definition.Name}'");
}
}

// [user key][one byte]: a range bound between the entries of user key and those of longer values.
private static IndexKey CreateIdPrefixBoundary(IndexKey userKey, byte idPrefixBound)
{
var bytes = new byte[userKey.Data.Length + 1];
userKey.Data.CopyTo(bytes);
bytes[^1] = idPrefixBound;
return IndexKey.FromOwnedArray(bytes);
}

#region Composite Key Support (SQLite-style for Duplicate Keys)

/// <summary>
Expand Down
Loading
Loading