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:
+353
-201
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user