Added SQLite storage backend.

This commit is contained in:
flash 2025-04-27 22:31:35 +00:00
parent 999ce86a27
commit 3f6007922c
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
21 changed files with 665 additions and 245 deletions

View file

@ -0,0 +1,56 @@
using System.Data;
using System.Data.Common;
using System.Data.SQLite;
using NativeSQLiteConnection = System.Data.SQLite.SQLiteConnection;
namespace SharpChat.SQLite;
public class SQLiteConnection(NativeSQLiteConnection conn) : IDisposable {
public async Task<int> RunCommand(string command, params SQLiteParameter[] parameters) {
using SQLiteCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return await cmd.ExecuteNonQueryAsync();
}
public async Task<DbDataReader?> RunQuery(string command, params SQLiteParameter[] parameters) {
using SQLiteCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return await cmd.ExecuteReaderAsync();
}
public async Task<T> RunQueryValue<T>(string command, params SQLiteParameter[] parameters)
where T : struct {
using SQLiteCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
await cmd.PrepareAsync();
object? raw = await cmd.ExecuteScalarAsync();
return raw is T value ? value : default;
}
private bool disposed = false;
~SQLiteConnection() {
DoDispose();
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(disposed)
return;
disposed = true;
RunCommand("VACUUM").Wait();
conn.Dispose();
}
}

View file

@ -0,0 +1,126 @@
using SharpChat.Messages;
using System.Data;
using System.Data.Common;
using System.Data.SQLite;
using System.Text;
using System.Text.Json;
namespace SharpChat.SQLite;
public class SQLiteMessageStorage(SQLiteConnection conn) : MessageStorage {
public async Task LogMessage(
long id,
string type,
string channelName,
string senderId,
string senderName,
ColourInheritable senderColour,
int senderRank,
string senderNick,
UserPermissions senderPerms,
object? data = null
) {
try {
await conn.RunCommand(
"INSERT INTO messages (msg_id, msg_type, msg_created, msg_channel, msg_sender, msg_sender_name, msg_sender_colour, msg_sender_rank, msg_sender_nick, msg_sender_perms, msg_data)"
+ " VALUES (@id, @type, @created, @channel, @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms, @data)",
new SQLiteParameter("id", id),
new SQLiteParameter("type", type),
new SQLiteParameter("created", $"{DateTimeOffset.UtcNow:s}Z"),
new SQLiteParameter("channel", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
new SQLiteParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
new SQLiteParameter("sender_name", senderName),
new SQLiteParameter("sender_colour", senderColour.Rgb.HasValue ? senderColour.Rgb.Value.Raw : null),
new SQLiteParameter("sender_rank", senderRank),
new SQLiteParameter("sender_nick", string.IsNullOrWhiteSpace(senderNick) ? null : senderNick),
new SQLiteParameter("sender_perms", SQLiteUserPermissionsConverter.To(senderPerms)),
new SQLiteParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data))
);
} catch(SQLiteException ex) {
Logger.Write(ex);
}
}
public async Task DeleteMessage(Message msg) {
try {
await conn.RunCommand(
"UPDATE IGNORE messages SET msg_deleted = NOW() WHERE msg_id = @id AND msg_deleted IS NULL",
new SQLiteParameter("id", msg.Id)
);
} catch(SQLiteException ex) {
Logger.Write(ex);
}
}
private static Message ReadMessage(DbDataReader reader) {
return new Message(
reader.GetInt64("msg_id"),
reader.GetString("msg_type"),
reader.IsDBNull(reader.GetOrdinal("msg_sender")) ? null : reader.GetString("msg_sender"),
reader.IsDBNull(reader.GetOrdinal("msg_sender_name")) ? string.Empty : reader.GetString("msg_sender_name"),
ColourInheritable.FromMisuzu(reader.GetInt32("msg_sender_colour")),
reader.GetInt32("msg_sender_rank"),
SQLiteUserPermissionsConverter.From((SQLiteUserPermissions)reader.GetInt32("msg_sender_perms")),
reader.IsDBNull(reader.GetOrdinal("msg_sender_nick")) ? string.Empty : reader.GetString("msg_sender_nick"),
DateTimeOffset.Parse(reader.GetString("msg_created")),
reader.IsDBNull(reader.GetOrdinal("msg_deleted")) ? null : DateTimeOffset.Parse(reader.GetString("msg_deleted")),
reader.IsDBNull(reader.GetOrdinal("msg_channel")) ? null : reader.GetString("msg_channel"),
JsonDocument.Parse(reader.GetString("msg_data"))
);
}
public async Task<Message?> GetMessage(long id) {
try {
using DbDataReader? reader = await conn.RunQuery(
"SELECT msg_id, msg_type, msg_created, msg_deleted, msg_channel, msg_sender, msg_sender_name, msg_sender_colour, msg_sender_rank, msg_sender_nick, msg_sender_perms, msg_data"
+ " FROM messages WHERE msg_id = @id",
new SQLiteParameter("id", id)
);
if(reader is null)
return null;
while(reader.Read()) {
Message evt = ReadMessage(reader);
if(evt != null)
return evt;
}
} catch(SQLiteException ex) {
Logger.Write(ex);
}
return null;
}
public async Task<IEnumerable<Message>> GetMessages(string channelName, int amount = 20, int offset = 0) {
List<Message> msgs = [];
try {
using DbDataReader? reader = await conn.RunQuery(
"SELECT msg_id, msg_type, msg_created, msg_deleted, msg_channel, msg_sender, msg_sender_name, msg_sender_colour, msg_sender_rank, msg_sender_nick, msg_sender_perms, msg_data"
+ " FROM messages"
+ " WHERE msg_deleted IS NULL AND (msg_channel = @channel OR msg_channel IS NULL)"
+ " AND msg_id > @offset"
+ " ORDER BY msg_id DESC"
+ " LIMIT @amount",
new SQLiteParameter("channel", channelName),
new SQLiteParameter("amount", amount),
new SQLiteParameter("offset", offset)
);
if(reader is null)
return msgs;
while(reader.Read()) {
Message evt = ReadMessage(reader);
if(evt != null)
msgs.Add(evt);
}
} catch(SQLiteException ex) {
Logger.Write(ex);
}
msgs.Reverse();
return msgs;
}
}

View file

@ -0,0 +1,45 @@
namespace SharpChat.SQLite;
public class SQLiteMigrations(SQLiteConnection conn) {
public async Task RunMigrations() {
long currentVersion = await conn.RunQueryValue<long>("PRAGMA user_version");
long version = currentVersion;
async Task doMigration(int expect, Func<Task> action) {
if(version < expect) {
await action();
++version;
}
};
await doMigration(1, CreateMessagesTable);
if(currentVersion != version)
await conn.RunCommand($"PRAGMA user_version = {version}");
}
private async Task CreateMessagesTable() {
await conn.RunCommand(
@"CREATE TABLE ""messages"" ("
+ @"""msg_id"" INTEGER NOT NULL,"
+ @"""msg_type"" TEXT NOT NULL COLLATE NOCASE,"
+ @"""msg_created"" TEXT NOT NULL COLLATE NOCASE,"
+ @"""msg_deleted"" TEXT DEFAULT NULL COLLATE NOCASE,"
+ @"""msg_channel"" TEXT DEFAULT NULL COLLATE NOCASE,"
+ @"""msg_sender"" TEXT DEFAULT NULL COLLATE BINARY,"
+ @"""msg_sender_name"" TEXT DEFAULT NULL COLLATE NOCASE,"
+ @"""msg_sender_colour"" INTEGER DEFAULT NULL,"
+ @"""msg_sender_rank"" INTEGER DEFAULT NULL,"
+ @"""msg_sender_nick"" TEXT DEFAULT NULL COLLATE NOCASE,"
+ @"""msg_sender_perms"" INTEGER DEFAULT NULL,"
+ @"""msg_data"" BLOB DEFAULT NULL CHECK(JSON_VALID(""msg_data"") AND JSON_TYPE(""msg_data"") = ""object"") COLLATE BINARY,"
+ @"PRIMARY KEY(""msg_id"")"
+ @");"
);
await conn.RunCommand(@"CREATE INDEX ""messages_channel_index"" ON ""messages"" (""msg_channel"");");
await conn.RunCommand(@"CREATE INDEX ""messages_created_index"" ON ""messages"" (""msg_created"");");
await conn.RunCommand(@"CREATE INDEX ""messages_deleted_index"" ON ""messages"" (""msg_deleted"");");
await conn.RunCommand(@"CREATE INDEX ""messages_event_type"" ON ""messages"" (""msg_type"");");
await conn.RunCommand(@"CREATE INDEX ""messages_sender_index"" ON ""messages"" (""msg_sender"");");
}
}

View file

@ -0,0 +1,61 @@
using SharpChat.Configuration;
using SharpChat.Messages;
using System.Data.SQLite;
using NativeSQLiteConnection = System.Data.SQLite.SQLiteConnection;
namespace SharpChat.SQLite;
public class SQLiteStorage(string connString) : Storage, IDisposable {
public const string MEMORY = "file::memory:?cache=shared";
public const string DEFAULT = "sharpchat.db";
public SQLiteConnection Connection { get; } = new SQLiteConnection(new NativeSQLiteConnection(connString).OpenAndReturn());
public MessageStorage CreateMessageStorage() {
return new SQLiteMessageStorage(Connection);
}
public async Task UpgradeStorage() {
await new SQLiteMigrations(Connection).RunMigrations();
}
public static string BuildConnectionString(Config config) {
return BuildConnectionString(
config.ReadValue("path", DEFAULT)!,
config.ReadValue("pass")
);
}
public static string BuildConnectionString(string path, string? password) {
return new SQLiteConnectionStringBuilder {
DataSource = string.IsNullOrWhiteSpace(path) ? MEMORY : path,
DateTimeFormat = SQLiteDateFormats.ISO8601,
DateTimeKind = DateTimeKind.Utc,
FailIfMissing = false,
ForeignKeys = true,
JournalMode = SQLiteJournalModeEnum.Wal,
LegacyFormat = false,
Password = string.IsNullOrWhiteSpace(password) ? null : password,
ReadOnly = false,
UseUTF16Encoding = false,
}.ToString();
}
private bool disposed = false;
~SQLiteStorage() {
DoDispose();
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(disposed)
return;
disposed = true;
Connection.Dispose();
}
}

View file

@ -0,0 +1,26 @@
namespace SharpChat.SQLite;
[Flags]
public enum SQLiteUserPermissions : long {
SendMessage = 0x1L,
DeleteOwnMessage = 0x2L,
DeleteAnyMessage = 0x4L,
EditOwnMessage = 0x8L,
EditAnyMessage = 0x10L,
SendBroadcast = 0x20L,
ViewLogs = 0x40L,
KickUser = 0x80L,
BanUser = 0x100L,
PardonUser = 0x200L,
PardonIPAddress = 0x400L,
ViewIPAddress = 0x800L,
ViewBanList = 0x1000L,
CreateChannel = 0x2000L,
SetChannelPermanent = 0x4000L,
SetChannelPassword = 0x8000L,
SetChannelMinimumRank = 0x10000L,
DeleteChannel = 0x20000L,
JoinAnyChannel = 0x40000L,
SetOwnNickname = 0x80000L,
SetOthersNickname = 0x100000L,
}

View file

@ -0,0 +1,101 @@
namespace SharpChat.SQLite;
public static class SQLiteUserPermissionsConverter {
public static UserPermissions From(SQLiteUserPermissions sup) {
UserPermissions up = 0;
if(sup.HasFlag(SQLiteUserPermissions.SendMessage))
up |= UserPermissions.SendMessage;
if(sup.HasFlag(SQLiteUserPermissions.DeleteOwnMessage))
up |= UserPermissions.DeleteOwnMessage;
if(sup.HasFlag(SQLiteUserPermissions.DeleteAnyMessage))
up |= UserPermissions.DeleteAnyMessage;
if(sup.HasFlag(SQLiteUserPermissions.EditOwnMessage))
up |= UserPermissions.EditOwnMessage;
if(sup.HasFlag(SQLiteUserPermissions.EditAnyMessage))
up |= UserPermissions.EditAnyMessage;
if(sup.HasFlag(SQLiteUserPermissions.SendBroadcast))
up |= UserPermissions.SendBroadcast;
if(sup.HasFlag(SQLiteUserPermissions.ViewLogs))
up |= UserPermissions.ViewLogs;
if(sup.HasFlag(SQLiteUserPermissions.KickUser))
up |= UserPermissions.KickUser;
if(sup.HasFlag(SQLiteUserPermissions.BanUser))
up |= UserPermissions.BanUser;
if(sup.HasFlag(SQLiteUserPermissions.PardonUser))
up |= UserPermissions.PardonUser;
if(sup.HasFlag(SQLiteUserPermissions.PardonIPAddress))
up |= UserPermissions.PardonIPAddress;
if(sup.HasFlag(SQLiteUserPermissions.ViewIPAddress))
up |= UserPermissions.ViewIPAddress;
if(sup.HasFlag(SQLiteUserPermissions.ViewBanList))
up |= UserPermissions.ViewBanList;
if(sup.HasFlag(SQLiteUserPermissions.CreateChannel))
up |= UserPermissions.CreateChannel;
if(sup.HasFlag(SQLiteUserPermissions.SetChannelPermanent))
up |= UserPermissions.SetChannelPermanent;
if(sup.HasFlag(SQLiteUserPermissions.SetChannelPassword))
up |= UserPermissions.SetChannelPassword;
if(sup.HasFlag(SQLiteUserPermissions.SetChannelMinimumRank))
up |= UserPermissions.SetChannelMinimumRank;
if(sup.HasFlag(SQLiteUserPermissions.DeleteChannel))
up |= UserPermissions.DeleteChannel;
if(sup.HasFlag(SQLiteUserPermissions.JoinAnyChannel))
up |= UserPermissions.JoinAnyChannel;
if(sup.HasFlag(SQLiteUserPermissions.SetOwnNickname))
up |= UserPermissions.SetOwnNickname;
if(sup.HasFlag(SQLiteUserPermissions.SetOthersNickname))
up |= UserPermissions.SetOthersNickname;
return up;
}
public static SQLiteUserPermissions To(UserPermissions up) {
SQLiteUserPermissions sup = 0;
if(up.HasFlag(UserPermissions.SendMessage))
sup |= SQLiteUserPermissions.SendMessage;
if(up.HasFlag(UserPermissions.DeleteOwnMessage))
sup |= SQLiteUserPermissions.DeleteOwnMessage;
if(up.HasFlag(UserPermissions.DeleteAnyMessage))
sup |= SQLiteUserPermissions.DeleteAnyMessage;
if(up.HasFlag(UserPermissions.EditOwnMessage))
sup |= SQLiteUserPermissions.EditOwnMessage;
if(up.HasFlag(UserPermissions.EditAnyMessage))
sup |= SQLiteUserPermissions.EditAnyMessage;
if(up.HasFlag(UserPermissions.SendBroadcast))
sup |= SQLiteUserPermissions.SendBroadcast;
if(up.HasFlag(UserPermissions.ViewLogs))
sup |= SQLiteUserPermissions.ViewLogs;
if(up.HasFlag(UserPermissions.KickUser))
sup |= SQLiteUserPermissions.KickUser;
if(up.HasFlag(UserPermissions.BanUser))
sup |= SQLiteUserPermissions.BanUser;
if(up.HasFlag(UserPermissions.PardonUser))
sup |= SQLiteUserPermissions.PardonUser;
if(up.HasFlag(UserPermissions.PardonIPAddress))
sup |= SQLiteUserPermissions.PardonIPAddress;
if(up.HasFlag(UserPermissions.ViewIPAddress))
sup |= SQLiteUserPermissions.ViewIPAddress;
if(up.HasFlag(UserPermissions.ViewBanList))
sup |= SQLiteUserPermissions.ViewBanList;
if(up.HasFlag(UserPermissions.CreateChannel))
sup |= SQLiteUserPermissions.CreateChannel;
if(up.HasFlag(UserPermissions.SetChannelPermanent))
sup |= SQLiteUserPermissions.SetChannelPermanent;
if(up.HasFlag(UserPermissions.SetChannelPassword))
sup |= SQLiteUserPermissions.SetChannelPassword;
if(up.HasFlag(UserPermissions.SetChannelMinimumRank))
sup |= SQLiteUserPermissions.SetChannelMinimumRank;
if(up.HasFlag(UserPermissions.DeleteChannel))
sup |= SQLiteUserPermissions.DeleteChannel;
if(up.HasFlag(UserPermissions.JoinAnyChannel))
sup |= SQLiteUserPermissions.JoinAnyChannel;
if(up.HasFlag(UserPermissions.SetOwnNickname))
sup |= SQLiteUserPermissions.SetOwnNickname;
if(up.HasFlag(UserPermissions.SetOthersNickname))
sup |= SQLiteUserPermissions.SetOthersNickname;
return sup;
}
}

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
</ItemGroup>
</Project>