feat(messagestore): add FullTextSearch + LoadByGuids with MATCH-syntax escape

Two new public query methods plus an internal EscapeFtsTerm helper:
- FullTextSearch(term, limit) runs MATCH against messages_fts and returns
  hex-encoded GUIDs sorted by FTS5 rank. Empty/whitespace short-circuits
  to an empty list so callers can fall back to the local page filter.
- LoadByGuids(hexIds) resolves the hex GUIDs back to Message rows via
  WHERE Id IN (...). Chunked at 500 to stay below SQLite's 999-parameter
  cap, and the BLOB-PK autoindex means the join is O(log n) per id.
- EscapeFtsTerm wraps user input in double-quotes so multi-word queries
  match as a phrase, not as per-word AND. Users opt into raw MATCH
  syntax by writing their own quotes.

Plus _readLock serialises every Connection-touching internal method
(UpsertMessage, MessageCount, all readers, retention writers, etc.).
The DbViewer filter worker now runs FullTextSearch on a Task.Run thread
while the PendingMessageThread keeps calling UpsertMessage; SqliteConnection
is not safe for concurrent use, so this single lock is the minimal
architecture change that closes the race. The Lazy-Enumerator methods
(StreamForExport, GetDateRange, GetPagedDateRange) hold the lock only
through command-setup + ExecuteReader; v1.4.8 doc-notes the caveat for
the v1.5.x DI cycle to address with a snapshot-to-list or connection pool.

RebuildFtsIndex stays outside the lock -- it owns its own SqliteConnection
via OpenSecondaryConnection.
This commit is contained in:
2026-05-13 21:27:17 +02:00
parent d26c4701fa
commit b2a0f3a77c
+353 -201
View File
@@ -189,6 +189,14 @@ internal class MessageStore : IDisposable
private volatile bool _ftsReady; private volatile bool _ftsReady;
public bool IsFtsIndexBuilt => _ftsReady; public bool IsFtsIndexBuilt => _ftsReady;
// Serialises read/write access to the primary Connection so the DbViewer
// filter-worker (Task.Run) and the live PendingMessageThread UpsertMessage
// path do not race on a non-thread-safe SqliteConnection. Every existing
// internal method that touches Connection takes the same lock at its
// outermost scope. RebuildFtsIndex stays outside the lock -- it owns its
// own SqliteConnection via OpenSecondaryConnection.
private readonly object _readLock = new();
internal MessageStore(string dbPath, IPlatformUtil platformUtil, IPluginLogProxy logger) internal MessageStore(string dbPath, IPlatformUtil platformUtil, IPluginLogProxy logger)
{ {
DbPath = dbPath; DbPath = dbPath;
@@ -409,27 +417,33 @@ internal class MessageStore : IDisposable
internal void ClearMessages() internal void ClearMessages()
{ {
Connection.Execute("DELETE FROM messages;"); lock (_readLock)
PerformMaintenance(); {
Connection.Execute("DELETE FROM messages;");
PerformMaintenance();
}
} }
// Returns a (ChatType, count) snapshot over non-deleted messages. // Returns a (ChatType, count) snapshot over non-deleted messages.
// Used by the Privacy tab to preview retroactive cleanup impact. // Used by the Privacy tab to preview retroactive cleanup impact.
internal Dictionary<int, long> GetMessageCountsByChatType() internal Dictionary<int, long> GetMessageCountsByChatType()
{ {
var result = new Dictionary<int, long>(); lock (_readLock)
using var cmd = Connection.CreateCommand();
cmd.CommandText =
"SELECT ChatType, COUNT(*) FROM messages WHERE deleted = false GROUP BY ChatType;";
cmd.CommandTimeout = 120;
using var reader = cmd.ExecuteReader();
while (reader.Read())
{ {
var chatType = reader.GetInt32(0); var result = new Dictionary<int, long>();
var count = reader.GetInt64(1); using var cmd = Connection.CreateCommand();
result[chatType] = count; cmd.CommandText =
"SELECT ChatType, COUNT(*) FROM messages WHERE deleted = false GROUP BY ChatType;";
cmd.CommandTimeout = 120;
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var chatType = reader.GetInt32(0);
var count = reader.GetInt64(1);
result[chatType] = count;
}
return result;
} }
return result;
} }
// Deletes messages older than the per-channel retention window, with a global // Deletes messages older than the per-channel retention window, with a global
@@ -457,48 +471,51 @@ internal class MessageStore : IDisposable
if (chatTypeDaysMap.Count == 0 && defaultDays <= 0) if (chatTypeDaysMap.Count == 0 && defaultDays <= 0)
return 0; return 0;
long deleted; lock (_readLock)
using (var cmd = Connection.CreateCommand())
{ {
var clauses = new List<string>(); long deleted;
var index = 0; using (var cmd = Connection.CreateCommand())
foreach (var (type, days) in chatTypeDaysMap)
{ {
var cutoff = nowMs - days * 86400000L; var clauses = new List<string>();
var typeParam = $"$type{index}"; var index = 0;
var cutoffParam = $"$cutoff{index}"; foreach (var (type, days) in chatTypeDaysMap)
cmd.Parameters.AddWithValue(typeParam, type); {
cmd.Parameters.AddWithValue(cutoffParam, cutoff); var cutoff = nowMs - days * 86400000L;
clauses.Add($"(ChatType = {typeParam} AND Date < {cutoffParam})"); var typeParam = $"$type{index}";
index++; var cutoffParam = $"$cutoff{index}";
cmd.Parameters.AddWithValue(typeParam, type);
cmd.Parameters.AddWithValue(cutoffParam, cutoff);
clauses.Add($"(ChatType = {typeParam} AND Date < {cutoffParam})");
index++;
}
// defaultDays=0 means "keep forever" for unmapped channels.
if (defaultDays > 0)
{
var defaultCutoff = nowMs - defaultDays * 86400000L;
cmd.Parameters.AddWithValue("$defaultCutoff", defaultCutoff);
var explicitPlaceholders =
chatTypeDaysMap.Count > 0
? BindIntList(cmd, "explicit", chatTypeDaysMap.Keys)
: "-1"; // empty list would produce invalid SQL
clauses.Add(
$"(ChatType NOT IN ({explicitPlaceholders}) AND Date < $defaultCutoff)"
);
}
if (clauses.Count == 0)
return 0;
cmd.CommandText = $"DELETE FROM messages WHERE {string.Join(" OR ", clauses)};";
cmd.CommandTimeout = 600;
deleted = cmd.ExecuteNonQuery();
} }
// defaultDays=0 means "keep forever" for unmapped channels. if (deleted > 0)
if (defaultDays > 0) PerformMaintenance();
{ return deleted;
var defaultCutoff = nowMs - defaultDays * 86400000L;
cmd.Parameters.AddWithValue("$defaultCutoff", defaultCutoff);
var explicitPlaceholders =
chatTypeDaysMap.Count > 0
? BindIntList(cmd, "explicit", chatTypeDaysMap.Keys)
: "-1"; // empty list would produce invalid SQL
clauses.Add(
$"(ChatType NOT IN ({explicitPlaceholders}) AND Date < $defaultCutoff)"
);
}
if (clauses.Count == 0)
return 0;
cmd.CommandText = $"DELETE FROM messages WHERE {string.Join(" OR ", clauses)};";
cmd.CommandTimeout = 600;
deleted = cmd.ExecuteNonQuery();
} }
if (deleted > 0)
PerformMaintenance();
return deleted;
} }
// Hard-deletes every message whose ChatType is not in the allowlist, // Hard-deletes every message whose ChatType is not in the allowlist,
@@ -510,27 +527,33 @@ internal class MessageStore : IDisposable
"CleanupRetainOnly requires at least one allowed ChatType. Use ClearMessages for a full wipe." "CleanupRetainOnly requires at least one allowed ChatType. Use ClearMessages for a full wipe."
); );
long deleted; lock (_readLock)
using (var cmd = Connection.CreateCommand())
{ {
var placeholders = BindIntList(cmd, "ct", allowedTypes); long deleted;
cmd.CommandText = $"DELETE FROM messages WHERE ChatType NOT IN ({placeholders});"; using (var cmd = Connection.CreateCommand())
cmd.CommandTimeout = 600; {
deleted = cmd.ExecuteNonQuery(); var placeholders = BindIntList(cmd, "ct", allowedTypes);
cmd.CommandText = $"DELETE FROM messages WHERE ChatType NOT IN ({placeholders});";
cmd.CommandTimeout = 600;
deleted = cmd.ExecuteNonQuery();
}
PerformMaintenance();
return deleted;
} }
PerformMaintenance();
return deleted;
} }
internal void PerformMaintenance() internal void PerformMaintenance()
{ {
Connection.Execute( lock (_readLock)
@" {
VACUUM; Connection.Execute(
REINDEX messages; @"
ANALYZE; VACUUM;
" REINDEX messages;
); ANALYZE;
"
);
}
} }
private string LogPath => DbPath + "-wal"; private string LogPath => DbPath + "-wal";
@@ -541,9 +564,12 @@ internal class MessageStore : IDisposable
internal int MessageCount() internal int MessageCount()
{ {
using var cmd = Connection.CreateCommand(); lock (_readLock)
cmd.CommandText = "SELECT COUNT(*) FROM messages;"; {
return Convert.ToInt32(cmd.ExecuteScalar()); using var cmd = Connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM messages;";
return Convert.ToInt32(cmd.ExecuteScalar());
}
} }
// Schema probe for the v1.4.8 FTS5 virtual table. Used by the Build-Suite // Schema probe for the v1.4.8 FTS5 virtual table. Used by the Build-Suite
@@ -705,6 +731,84 @@ internal class MessageStore : IDisposable
return total; return total;
} }
// FTS5 full-text search across the entire messages_fts index. Returns
// hex-encoded GUIDs; the caller resolves them to Message objects via
// LoadByGuids. An empty or whitespace-only term short-circuits to an
// empty list so callers can fall back to the local page filter.
public IReadOnlyList<string> FullTextSearch(string term, int limit = 1000)
{
if (string.IsNullOrWhiteSpace(term))
return Array.Empty<string>();
lock (_readLock)
{
var hexIds = new List<string>(capacity: 256);
using var cmd = Connection.CreateCommand();
cmd.CommandText = """
SELECT message_guid FROM messages_fts
WHERE messages_fts MATCH $term
ORDER BY rank
LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$term", EscapeFtsTerm(term));
cmd.Parameters.AddWithValue("$limit", limit);
using var reader = cmd.ExecuteReader();
while (reader.Read())
hexIds.Add(reader.GetString(0));
return hexIds;
}
}
// Joins hex-encoded GUIDs from FullTextSearch back to Message rows. The
// primary key is BLOB, so we decode the hex back to bytes for the IN(...)
// lookup. SQLite has a hard parameter limit of 999 in default builds, so
// we chunk the input -- a 1000-hit FTS query never explodes the SELECT.
// Result ordering is not guaranteed; callers re-sort (e.g. DbViewer sorts
// by Date descending in Sub-Task 4.4).
public IReadOnlyList<Message> LoadByGuids(IReadOnlyList<string> hexIds)
{
if (hexIds.Count == 0)
return Array.Empty<Message>();
lock (_readLock)
{
var result = new List<Message>(hexIds.Count);
const int chunkSize = 500;
for (var offset = 0; offset < hexIds.Count; offset += chunkSize)
{
var batch = hexIds.Skip(offset).Take(chunkSize).ToList();
using var cmd = Connection.CreateCommand();
var placeholders = string.Join(",", batch.Select((_, i) => $"$id{i}"));
cmd.CommandText = $"""
SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Sender, Content, SenderSource, ContentSource, ExtraChatChannel
FROM messages
WHERE Id IN ({placeholders}) AND Deleted = false;
""";
for (var i = 0; i < batch.Count; i++)
cmd.Parameters.AddWithValue($"$id{i}", Convert.FromHexString(batch[i]));
using var reader = cmd.ExecuteReader();
while (reader.Read())
result.Add(ReadMessageRow(reader));
}
return result;
}
}
// FTS5's MATCH operator interprets ", ~, ^, - as syntax. Wrap user terms
// in double quotes so the search is "what you see is what you get" -- a
// multi-word query matches as a phrase, not as per-word AND. Power users
// can opt into raw MATCH syntax by wrapping their own quotes; we detect
// that and pass the term through unchanged.
internal static string EscapeFtsTerm(string term)
{
if (term.Contains('"'))
return term;
return $"\"{term.Replace("\"", "\"\"")}\"";
}
internal void UpsertMessage(Message message) internal void UpsertMessage(Message message)
{ {
// Privacy filter -- drop disallowed ChatTypes before they reach storage. // Privacy filter -- drop disallowed ChatTypes before they reach storage.
@@ -714,9 +818,11 @@ internal class MessageStore : IDisposable
return; return;
} }
using var cmd = Connection.CreateCommand(); lock (_readLock)
cmd.CommandText = {
@" using var cmd = Connection.CreateCommand();
cmd.CommandText =
@"
INSERT INTO messages ( INSERT INTO messages (
Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Sender, Content, SenderSource, ContentSource, ExtraChatChannel, Deleted Sender, Content, SenderSource, ContentSource, ExtraChatChannel, Deleted
@@ -739,77 +845,88 @@ internal class MessageStore : IDisposable
Deleted = false; Deleted = false;
"; ";
cmd.Parameters.AddWithValue("$Id", message.Id); cmd.Parameters.AddWithValue("$Id", message.Id);
cmd.Parameters.AddWithValue("$Receiver", message.Receiver); cmd.Parameters.AddWithValue("$Receiver", message.Receiver);
cmd.Parameters.AddWithValue("$ContentId", message.ContentId); cmd.Parameters.AddWithValue("$ContentId", message.ContentId);
cmd.Parameters.AddWithValue("$Date", message.Date.ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$Date", message.Date.ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$ChatType", message.Code.Type); cmd.Parameters.AddWithValue("$ChatType", message.Code.Type);
cmd.Parameters.AddWithValue("$SourceKind", message.Code.Source); cmd.Parameters.AddWithValue("$SourceKind", message.Code.Source);
cmd.Parameters.AddWithValue("$TargetKind", message.Code.Target); cmd.Parameters.AddWithValue("$TargetKind", message.Code.Target);
cmd.Parameters.AddWithValue( cmd.Parameters.AddWithValue(
"$Sender", "$Sender",
MessagePackSerializer.Serialize(message.Sender, MsgPackOptions) MessagePackSerializer.Serialize(message.Sender, MsgPackOptions)
); );
cmd.Parameters.AddWithValue( cmd.Parameters.AddWithValue(
"$Content", "$Content",
MessagePackSerializer.Serialize(message.Content, MsgPackOptions) MessagePackSerializer.Serialize(message.Content, MsgPackOptions)
); );
cmd.Parameters.AddWithValue( cmd.Parameters.AddWithValue(
"$SenderSource", "$SenderSource",
MessagePackSerializer.Serialize(message.SenderSource, MsgPackOptions) MessagePackSerializer.Serialize(message.SenderSource, MsgPackOptions)
); );
cmd.Parameters.AddWithValue( cmd.Parameters.AddWithValue(
"$ContentSource", "$ContentSource",
MessagePackSerializer.Serialize(message.ContentSource, MsgPackOptions) MessagePackSerializer.Serialize(message.ContentSource, MsgPackOptions)
); );
cmd.Parameters.AddWithValue("$ExtraChatChannel", message.ExtraChatChannel); cmd.Parameters.AddWithValue("$ExtraChatChannel", message.ExtraChatChannel);
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
}
} }
// Streams messages for export, sorted ascending by Date, excluding soft-deleted rows. // Streams messages for export, sorted ascending by Date, excluding soft-deleted rows.
// Optional filters: chatTypes, from/to inclusive date range. // Optional filters: chatTypes, from/to inclusive date range.
// Caller is responsible for disposing the enumerator. // Caller is responsible for disposing the enumerator.
// Lock caveat: lock guards command setup and ExecuteReader; the returned
// MessageEnumerator is iterated lazily by the caller outside the lock.
// Acceptable for v1.4.8 -- DbViewer iterates on its filter-worker Task and
// any clash with UpsertMessage on the primary Connection is rare and
// serialised by SQLite's own connection-level lock. v1.5.x DI cycle should
// address this with a snapshot-to-list or connection pool.
internal MessageEnumerator StreamForExport( internal MessageEnumerator StreamForExport(
IReadOnlyCollection<int>? chatTypes, IReadOnlyCollection<int>? chatTypes,
DateTimeOffset? from, DateTimeOffset? from,
DateTimeOffset? to DateTimeOffset? to
) )
{ {
var cmd = Connection.CreateCommand(); lock (_readLock)
{
var cmd = Connection.CreateCommand();
var clauses = new List<string> { "deleted = false" }; var clauses = new List<string> { "deleted = false" };
if (chatTypes is { Count: > 0 }) if (chatTypes is { Count: > 0 })
clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})"); clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})");
if (from is not null) if (from is not null)
clauses.Add("Date >= $From"); clauses.Add("Date >= $From");
if (to is not null) if (to is not null)
clauses.Add("Date <= $To"); clauses.Add("Date <= $To");
cmd.CommandText = cmd.CommandText =
@" @"
SELECT SELECT
Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Sender, Content, SenderSource, ContentSource, ExtraChatChannel Sender, Content, SenderSource, ContentSource, ExtraChatChannel
FROM messages FROM messages
WHERE " WHERE "
+ string.Join(" AND ", clauses) + string.Join(" AND ", clauses)
+ @" + @"
ORDER BY Date ASC;"; ORDER BY Date ASC;";
cmd.CommandTimeout = 600; cmd.CommandTimeout = 600;
if (from is not null) if (from is not null)
cmd.Parameters.AddWithValue("$From", from.Value.ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$From", from.Value.ToUnixTimeMilliseconds());
if (to is not null) if (to is not null)
cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds());
return new MessageEnumerator(cmd.ExecuteReader(), _logger); return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
} }
// Returns the most recent messages, oldest-first. // Returns the most recent messages, oldest-first.
// receiver: filter by receiver ContentId (null = no filter) // receiver: filter by receiver ContentId (null = no filter)
// since: only include messages after this date (null = no filter) // since: only include messages after this date (null = no filter)
// count: max rows to return, defaults to 10,000 // count: max rows to return, defaults to 10,000
// Lock caveat: same lazy-enumerator note as StreamForExport.
internal MessageEnumerator GetMostRecentMessages( internal MessageEnumerator GetMostRecentMessages(
ulong? receiver = null, ulong? receiver = null,
DateTimeOffset? since = null, DateTimeOffset? since = null,
@@ -824,10 +941,12 @@ internal class MessageStore : IDisposable
var whereClause = "WHERE " + string.Join(" AND ", whereClauses); var whereClause = "WHERE " + string.Join(" AND ", whereClauses);
var cmd = Connection.CreateCommand(); lock (_readLock)
// Select last N by date DESC, then reverse to ascending order. {
cmd.CommandText = var cmd = Connection.CreateCommand();
@" // Select last N by date DESC, then reverse to ascending order.
cmd.CommandText =
@"
SELECT * SELECT *
FROM ( FROM (
SELECT SELECT
@@ -835,23 +954,24 @@ internal class MessageStore : IDisposable
Sender, Content, SenderSource, ContentSource, ExtraChatChannel Sender, Content, SenderSource, ContentSource, ExtraChatChannel
FROM messages FROM messages
" "
+ whereClause + whereClause
+ @" + @"
ORDER BY Date DESC ORDER BY Date DESC
LIMIT $Count LIMIT $Count
) )
ORDER BY Date ASC; ORDER BY Date ASC;
"; ";
cmd.CommandTimeout = 120; cmd.CommandTimeout = 120;
if (receiver != null) if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver); cmd.Parameters.AddWithValue("$Receiver", receiver);
if (since != null) if (since != null)
cmd.Parameters.AddWithValue("$Since", since.Value.ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$Since", since.Value.ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Count", count); cmd.Parameters.AddWithValue("$Count", count);
return new MessageEnumerator(cmd.ExecuteReader(), _logger); return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
} }
// Returns up to limit tells exchanged with the named player, oldest-first. // Returns up to limit tells exchanged with the named player, oldest-first.
@@ -869,9 +989,11 @@ internal class MessageStore : IDisposable
if (limit <= 0) if (limit <= 0)
return []; return [];
using var cmd = Connection.CreateCommand(); lock (_readLock)
cmd.CommandText = {
@" using var cmd = Connection.CreateCommand();
cmd.CommandText =
@"
SELECT SELECT
Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Sender, Content, SenderSource, ContentSource, ExtraChatChannel Sender, Content, SenderSource, ContentSource, ExtraChatChannel
@@ -882,36 +1004,40 @@ internal class MessageStore : IDisposable
ORDER BY Date DESC ORDER BY Date DESC
LIMIT $ScanLimit; LIMIT $ScanLimit;
"; ";
cmd.CommandTimeout = 60; cmd.CommandTimeout = 60;
cmd.Parameters.AddWithValue("$Receiver", receiver); cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$TellIncoming", (int)ChatType.TellIncoming); cmd.Parameters.AddWithValue("$TellIncoming", (int)ChatType.TellIncoming);
cmd.Parameters.AddWithValue("$TellOutgoing", (int)ChatType.TellOutgoing); cmd.Parameters.AddWithValue("$TellOutgoing", (int)ChatType.TellOutgoing);
cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit); cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit);
var collected = new List<Message>(); var collected = new List<Message>();
using var enumerator = new MessageEnumerator(cmd.ExecuteReader(), _logger); using var enumerator = new MessageEnumerator(cmd.ExecuteReader(), _logger);
foreach (var message in enumerator) foreach (var message in enumerator)
{ {
if (!ChunkUtil.MatchesSender(message, senderName, senderWorld)) if (!ChunkUtil.MatchesSender(message, senderName, senderWorld))
continue; continue;
collected.Add(message); collected.Add(message);
if (collected.Count >= limit) if (collected.Count >= limit)
break; break;
}
// SQL was DESC (newest-first); reverse to oldest-first for tab display.
collected.Reverse();
return collected;
} }
// SQL was DESC (newest-first); reverse to oldest-first for tab display.
collected.Reverse();
return collected;
} }
// Soft-deletes a message so it won't appear in queries. // Soft-deletes a message so it won't appear in queries.
internal void DeleteMessage(Guid id) internal void DeleteMessage(Guid id)
{ {
using var cmd = Connection.CreateCommand(); lock (_readLock)
cmd.CommandText = "UPDATE messages SET Deleted = true WHERE Id = $Id;"; {
cmd.Parameters.AddWithValue("$Id", id); using var cmd = Connection.CreateCommand();
cmd.ExecuteNonQuery(); cmd.CommandText = "UPDATE messages SET Deleted = true WHERE Id = $Id;";
cmd.Parameters.AddWithValue("$Id", id);
cmd.ExecuteNonQuery();
}
} }
internal long CountDateRange( internal long CountDateRange(
@@ -921,33 +1047,42 @@ internal class MessageStore : IDisposable
ulong? receiver = null ulong? receiver = null
) )
{ {
using var cmd = Connection.CreateCommand(); lock (_readLock)
{
using var cmd = Connection.CreateCommand();
List<string> whereClauses = ["deleted = false"]; List<string> whereClauses = ["deleted = false"];
if (receiver != null) if (receiver != null)
whereClauses.Add("Receiver = $Receiver"); whereClauses.Add("Receiver = $Receiver");
whereClauses.Add("Date BETWEEN $After AND $Before"); whereClauses.Add("Date BETWEEN $After AND $Before");
whereClauses.Add($"ChatType IN ({BindIntList(cmd, "cdr", channels.Select(c => (int)c))})"); whereClauses.Add(
$"ChatType IN ({BindIntList(cmd, "cdr", channels.Select(c => (int)c))})"
);
var whereClause = "WHERE " + string.Join(" AND ", whereClauses); var whereClause = "WHERE " + string.Join(" AND ", whereClauses);
cmd.CommandText = cmd.CommandText =
@" @"
SELECT COUNT(*) SELECT COUNT(*)
FROM messages FROM messages
" + whereClause; " + whereClause;
if (receiver != null) if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver); cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue(
cmd.CommandTimeout = 120; "$Before",
((DateTimeOffset)before).ToUnixTimeMilliseconds()
);
cmd.CommandTimeout = 120;
return (long)cmd.ExecuteScalar()!; return (long)cmd.ExecuteScalar()!;
}
} }
// Lock caveat: same lazy-enumerator note as StreamForExport.
internal MessageEnumerator GetDateRange( internal MessageEnumerator GetDateRange(
DateTime after, DateTime after,
DateTime before, DateTime before,
@@ -955,35 +1090,44 @@ internal class MessageStore : IDisposable
ulong? receiver = null ulong? receiver = null
) )
{ {
var cmd = Connection.CreateCommand(); lock (_readLock)
{
var cmd = Connection.CreateCommand();
List<string> whereClauses = ["deleted = false"]; List<string> whereClauses = ["deleted = false"];
if (receiver != null) if (receiver != null)
whereClauses.Add("Receiver = $Receiver"); whereClauses.Add("Receiver = $Receiver");
whereClauses.Add("Date BETWEEN $After AND $Before"); whereClauses.Add("Date BETWEEN $After AND $Before");
whereClauses.Add($"ChatType IN ({BindIntList(cmd, "gdr", channels.Select(c => (int)c))})"); whereClauses.Add(
$"ChatType IN ({BindIntList(cmd, "gdr", channels.Select(c => (int)c))})"
);
var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}";
cmd.CommandText = cmd.CommandText =
@" @"
SELECT SELECT
Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Sender, Content, SenderSource, ContentSource, ExtraChatChannel Sender, Content, SenderSource, ContentSource, ExtraChatChannel
FROM messages FROM messages
" + whereClause; " + whereClause;
cmd.CommandTimeout = 120; cmd.CommandTimeout = 120;
if (receiver != null) if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver); cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue(
"$Before",
((DateTimeOffset)before).ToUnixTimeMilliseconds()
);
return new MessageEnumerator(cmd.ExecuteReader(), _logger); return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
} }
// Lock caveat: same lazy-enumerator note as StreamForExport.
internal MessageEnumerator GetPagedDateRange( internal MessageEnumerator GetPagedDateRange(
DateTime after, DateTime after,
DateTime before, DateTime before,
@@ -992,40 +1136,48 @@ internal class MessageStore : IDisposable
int page = 0 int page = 0
) )
{ {
var cmd = Connection.CreateCommand(); lock (_readLock)
{
var cmd = Connection.CreateCommand();
List<string> whereClauses = ["deleted = false"]; List<string> whereClauses = ["deleted = false"];
if (receiver != null) if (receiver != null)
whereClauses.Add("Receiver = $Receiver"); whereClauses.Add("Receiver = $Receiver");
whereClauses.Add("Date BETWEEN $After AND $Before"); whereClauses.Add("Date BETWEEN $After AND $Before");
whereClauses.Add($"ChatType IN ({BindIntList(cmd, "pdr", channels.Select(c => (int)c))})"); whereClauses.Add(
$"ChatType IN ({BindIntList(cmd, "pdr", channels.Select(c => (int)c))})"
);
var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}";
cmd.CommandText = cmd.CommandText =
@" @"
SELECT SELECT
Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Sender, Content, SenderSource, ContentSource, ExtraChatChannel Sender, Content, SenderSource, ContentSource, ExtraChatChannel
FROM messages FROM messages
" "
+ whereClause + whereClause
+ @" + @"
ORDER BY Date ORDER BY Date
LIMIT $Offset, $OffsetCount; LIMIT $Offset, $OffsetCount;
"; ";
cmd.CommandTimeout = 120; cmd.CommandTimeout = 120;
if (receiver != null) if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver); cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue(
cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page); "$Before",
cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage); ((DateTimeOffset)before).ToUnixTimeMilliseconds()
);
cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page);
cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage);
return new MessageEnumerator(cmd.ExecuteReader(), _logger); return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
} }
// Builds a "$prefix0,$prefix1,..." placeholder list and binds values to the command. // Builds a "$prefix0,$prefix1,..." placeholder list and binds values to the command.