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
14 changes: 8 additions & 6 deletions src/SharpCoreDB/DataStructures/Table.CRUD.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2356,15 +2356,16 @@ private bool TryBulkUpdateContiguousFixedWidth(
}

// Narrow, conservative gate: fixed-width columnar table with an explicit PK, plaintext
// records only (a raw contiguous read must equal the logical record bytes), no buffered
// overwrites for this file, and no CHECK constraints (mirrors the generic fastPatch gate).
// records only (no per-record encryption magic — a raw contiguous read must equal the
// logical record bytes), no buffered overwrites for this file, and no CHECK constraints
// (mirrors the generic fastPatch gate).
if (!_fixedWidthRecords ||
StorageMode != StorageMode.Columnar ||
this.PrimaryKeyIndex < 0 ||
this.TableCheckConstraints.Count > 0 ||
HasColumnCheckConstraints() ||
this.storage is null ||
this._config is not { NoEncryptMode: true } ||
this.storage.AreRecordsEncrypted(DataFile) ||
this.storage.HasBufferedOverwrite(DataFile))
{
return false;
Expand Down Expand Up @@ -3047,13 +3048,14 @@ private bool TryBulkDeleteContiguousFixedWidth(List<string> whereConditions)
}

// Identical safety gate to the UPDATE fast path: fixed-width columnar table with an explicit
// PK, plaintext records only, and no buffered overwrites (a raw range read must equal the
// logical record bytes). DeleteMultiple loads every registered hash index before calling this.
// PK, plaintext records only (no per-record encryption magic — a raw range read must equal
// the logical record bytes), and no buffered overwrites. DeleteMultiple loads every
// registered hash index before calling this.
if (!_fixedWidthRecords ||
StorageMode != StorageMode.Columnar ||
this.PrimaryKeyIndex < 0 ||
this.storage is null ||
this._config is not { NoEncryptMode: true } ||
this.storage.AreRecordsEncrypted(DataFile) ||
this.storage.HasBufferedOverwrite(DataFile))
{
return false;
Expand Down
7 changes: 7 additions & 0 deletions src/SharpCoreDB/Interfaces/IStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) =>
/// </summary>
byte[]? ReadBytesRange(string path, long offset, int length) => null;

/// <summary>
/// True when the file at <paramref name="path"/> stores per-record encrypted (ciphertext)
/// payloads (it carries the encrypted-table magic header). Raw range reads must never be used on
/// such files; the default returns false (plaintext / legacy layouts).
/// </summary>
bool AreRecordsEncrypted(string path) => false;

/// <summary>
/// Enumerates every record in a table data file, yielding the literal file offset of the
/// 4-byte length prefix (the offset returned by <see cref="AppendBytes"/>) together with the
Expand Down
3 changes: 3 additions & 0 deletions src/SharpCoreDB/Services/Storage.Append.cs
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,9 @@ public bool OverwriteRecordAtSameLength(string path, long offset, byte[] data)
public bool HasBufferedOverwrite(string path) =>
!bufferedOverwrites.IsEmpty && bufferedOverwrites.ContainsKey(path);

/// <inheritdoc />
public bool AreRecordsEncrypted(string path) => UseRecordEncryption && FileHasEncryptedHeader(path);

/// <inheritdoc />
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public long[] AppendBytesMultiple(string path, List<byte[]> dataBlocks)
Expand Down
33 changes: 30 additions & 3 deletions tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public void Dispose()
private IDatabase CreateDb(bool noEncrypt = true) => _factory.Create(_dirPath, "pw", isReadOnly: false,
config: new DatabaseConfig { NoEncryptMode = noEncrypt });

private IDatabase CreateEncryptedDb() => _factory.Create(_dirPath, "pw", isReadOnly: false,
config: new DatabaseConfig { NoEncryptMode = false, EnableAtRestRecordEncryption = true });

private static Table TableOf(IDatabase db, string tableName)
{
Assert.True(db.TryGetTable(tableName, out var t));
Expand Down Expand Up @@ -209,9 +212,11 @@ public void IndexedColumnUpdate_FallsBack_AndRepointsIndex()
}

[Fact]
public void EncryptedOrDefaultConfig_FallsBack_AndStaysCorrect()
public void DefaultPlaintextConfig_EngagesBulkPath_AndStaysCorrect()
{
var db = CreateDb(noEncrypt: false); // default config: the gate requires NoEncryptMode
// Default config (no NoEncryptMode) stores plaintext records unless per-record encryption
// is opted in, so the runtime magic-header gate now admits the fast path here too.
var db = CreateDb(noEncrypt: false);
try
{
db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)");
Expand All @@ -221,7 +226,29 @@ public void EncryptedOrDefaultConfig_FallsBack_AndStaysCorrect()
var table = TableOf(db, "docs");
db.ExecuteBatchSQL(BuildUpdates(1, 300, "score = 4.25"));
db.Flush();
Assert.Equal(0, table.BulkContiguousUpdateBatches);
Assert.Equal(1, table.BulkContiguousUpdateBatches);

var rows = db.ExecuteQuery("SELECT score FROM docs WHERE id = 300");
Assert.Single(rows);
Assert.Equal(4.25, Convert.ToDouble(rows[0]["score"]));
}
finally { (db as IDisposable)?.Dispose(); }
}

[Fact]
public void PerRecordEncryption_FallsBackToGenericLoop_AndStaysCorrect()
{
var db = CreateEncryptedDb();
try
{
db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)");
InsertDocs(db, 1, 300);
db.Flush();

var table = TableOf(db, "docs");
db.ExecuteBatchSQL(BuildUpdates(1, 300, "score = 4.25"));
db.Flush();
Assert.Equal(0, table.BulkContiguousUpdateBatches); // ciphertext records → generic loop

var rows = db.ExecuteQuery("SELECT score FROM docs WHERE id = 300");
Assert.Single(rows);
Expand Down
Loading