diff --git a/Feature/EntityCache/EntityCache.cs b/Feature/EntityCache/EntityCache.cs
index 467e2ca..b871c10 100644
--- a/Feature/EntityCache/EntityCache.cs
+++ b/Feature/EntityCache/EntityCache.cs
@@ -26,7 +26,7 @@ namespace Noikoio.RegexBot.Feature.EntityCache
if (_db.Enabled)
{
- CreateCacheTables();
+ Sql.CreateCacheTables();
client.GuildAvailable += Client_GuildAvailable;
client.GuildUpdated += Client_GuildUpdated;
@@ -67,45 +67,7 @@ namespace Noikoio.RegexBot.Feature.EntityCache
#endregion
#region Table setup
- public const string TableGuild = "cache_guild";
- public const string TableUser = "cache_users";
-
- private void CreateCacheTables()
- {
- using (var db = _db.GetOpenConnectionAsync().GetAwaiter().GetResult())
- {
- using (var c = db.CreateCommand())
- {
- c.CommandText = "CREATE TABLE IF NOT EXISTS " + TableGuild + "("
- + "guild_id bigint primary key, "
- + "current_name text not null, "
- + "display_name text null"
- + ")";
- // TODO determine if other columns might be needed?
- c.ExecuteNonQuery();
- }
-
- using (var c = db.CreateCommand())
- {
- c.CommandText = "CREATE TABLE IF NOT EXISTS " + TableUser + "("
- + "user_id bigint not null, "
- + $"guild_id bigint not null references {TableGuild}, "
- + "cache_date timestamptz not null, "
- + "username text not null, "
- + "discriminator text not null, "
- + "nickname text null, "
- + "avatar_url text null"
- + ")";
- c.ExecuteNonQuery();
- }
- using (var c = db.CreateCommand())
- {
- c.CommandText = "CREATE UNIQUE INDEX IF NOT EXISTS "
- + $"{TableUser}_idx on {TableUser} (user_id, guild_id)";
- c.ExecuteNonQuery();
- }
- }
- }
+
#endregion
private async Task UpdateGuild(SocketGuild g)
@@ -116,7 +78,7 @@ namespace Noikoio.RegexBot.Feature.EntityCache
{
using (var c = db.CreateCommand())
{
- c.CommandText = "INSERT INTO " + TableGuild + " (guild_id, current_name) "
+ c.CommandText = "INSERT INTO " + Sql.TableGuild + " (guild_id, current_name) "
+ "VALUES (@GuildId, @CurrentName) "
+ "ON CONFLICT (guild_id) DO UPDATE SET "
+ "current_name = EXCLUDED.current_name";
@@ -141,7 +103,7 @@ namespace Noikoio.RegexBot.Feature.EntityCache
{
using (var c = db.CreateCommand())
{
- c.CommandText = "INSERT INTO " + TableUser
+ c.CommandText = "INSERT INTO " + Sql.TableUser
+ " (user_id, guild_id, cache_date, username, discriminator, nickname, avatar_url)"
+ " VALUES (@Uid, @Gid, @Date, @Uname, @Disc, @Nname, @Url) "
+ "ON CONFLICT (user_id, guild_id) DO UPDATE SET "
diff --git a/Feature/EntityCache/Sql.cs b/Feature/EntityCache/Sql.cs
new file mode 100644
index 0000000..0cf3f81
--- /dev/null
+++ b/Feature/EntityCache/Sql.cs
@@ -0,0 +1,56 @@
+using Npgsql;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Noikoio.RegexBot.Feature.EntityCache
+{
+ ///
+ /// Contains common constants and static methods for cache access.
+ ///
+ class Sql
+ {
+ public const string TableGuild = "cache_guild";
+ public const string TableUser = "cache_users";
+
+ private static NpgsqlConnection OpenDB() =>
+ RegexBot.Config.Database.GetOpenConnectionAsync().GetAwaiter().GetResult();
+
+ public static void CreateCacheTables()
+ {
+ using (var db = OpenDB())
+ {
+ using (var c = db.CreateCommand())
+ {
+ c.CommandText = "CREATE TABLE IF NOT EXISTS " + TableGuild + "("
+ + "guild_id bigint primary key, "
+ + "current_name text not null, "
+ + "display_name text null"
+ + ")";
+ c.ExecuteNonQuery();
+ }
+
+ using (var c = db.CreateCommand())
+ {
+ c.CommandText = "CREATE TABLE IF NOT EXISTS " + TableUser + "("
+ + "user_id bigint not null, "
+ + $"guild_id bigint not null references {TableGuild}, "
+ + "cache_date timestamptz not null, "
+ + "username text not null, "
+ + "discriminator text not null, "
+ + "nickname text null, "
+ + "avatar_url text null"
+ + ")";
+ c.ExecuteNonQuery();
+ }
+ using (var c = db.CreateCommand())
+ {
+ c.CommandText = "CREATE UNIQUE INDEX IF NOT EXISTS "
+ + $"{TableUser}_idx on {TableUser} (user_id, guild_id)";
+ c.ExecuteNonQuery();
+ }
+ // TODO create indexes for string-based queries
+ }
+ }
+ }
+}
diff --git a/Feature/EntityCache/UserCacheItem.cs b/Feature/EntityCache/UserCacheItem.cs
new file mode 100644
index 0000000..9d0da3a
--- /dev/null
+++ b/Feature/EntityCache/UserCacheItem.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.EntityCache
+{
+ ///
+ /// Represents a cached user.
+ ///
+ class UserCacheItem
+ {
+ readonly ulong _userId;
+ readonly ulong _guildId;
+ readonly DateTime _cacheDate;
+ readonly string _username;
+ readonly string _discriminator;
+ readonly string _nickname;
+ readonly string _avatarUrl;
+
+ ///
+ /// The cached user's ID (snowflake) value.
+ ///
+ public ulong UserId => _userId;
+ ///
+ /// The guild ID (snowflake) for which this user information corresponds to.
+ ///
+ public ulong GuildId => _guildId;
+ ///
+ /// Timestamp value for when this cache item was last updated, in universal time.
+ ///
+ public DateTime CacheDate => _cacheDate;
+
+ ///
+ /// Display name, including discriminator. Shows the nickname, if available.
+ ///
+ public string DisplayName => (_nickname ?? _username) + "#" + _discriminator;
+ ///
+ /// String useful for tagging the user.
+ ///
+ public string Mention => $"<@{_userId}>";
+ ///
+ /// User's cached nickname in the guild, if any.
+ ///
+ public string Nickname => _nickname;
+ ///
+ /// User's cached username.
+ ///
+ public string Username => _username;
+ ///
+ /// User's cached discriminator value.
+ ///
+ public string Discriminator => _discriminator;
+ ///
+ /// URL for user's last known avatar. May be null or invalid.
+ ///
+ public string AvatarUrl => _avatarUrl;
+
+
+ private UserCacheItem(DbDataReader r)
+ {
+ // Double-check ordinals if making changes to QueryColumns
+ unchecked
+ {
+ // PostgreSQL does not support unsigned 64-bit numbers. Must convert.
+ _userId = (ulong)r.GetInt64(0);
+ _guildId = (ulong)r.GetInt64(1);
+ }
+ _cacheDate = r.GetDateTime(2).ToUniversalTime();
+ _username = r.GetString(3);
+ _discriminator = r.GetString(4);
+ _nickname = r.GetString(5);
+ _avatarUrl = r.GetString(6);
+ }
+
+ public override string ToString() => DisplayName;
+
+ #region Queries
+ const string QueryColumns = "user_id, guild_id, cache_date, username, discriminator, nickname, avatar_url";
+
+ ///
+ /// Attempts to query for an exact result with the given parameters.
+ ///
+ /// Null on no result.
+ public static async Task QueryAsync(ulong guild, ulong user)
+ {
+ using (var db = await RegexBot.Config.Database.GetOpenConnectionAsync())
+ {
+ using (var c = db.CreateCommand())
+ {
+ c.CommandText = $"SELECT {QueryColumns} FROM {Sql.TableUser} WHERE "
+ + "user_id = @Uid AND guild_id = @Gid";
+ c.Parameters.Add("@Uid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = user;
+ c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = guild;
+ c.Prepare();
+ using (var r = await c.ExecuteReaderAsync())
+ {
+ if (await r.ReadAsync())
+ {
+ return new UserCacheItem(r);
+ }
+ else
+ {
+ return null;
+ }
+ }
+ }
+ }
+ }
+
+ private static Regex DiscriminatorSearch = new Regex(@"(.+)#(\d{4}(?!\d))", RegexOptions.Compiled);
+
+ ///
+ /// Attempts to look up the user given a search string.
+ /// This string looks up case-insensitive, exact matches of nicknames and usernames.
+ ///
+ ///
+ /// An containing zero or more query results, sorted by cache date.
+ public static async Task> QueryAsync(ulong guild, string search)
+ {
+ // Is search just a number? It's an ID.
+ if (ulong.TryParse(search, out var presult))
+ {
+ return new UserCacheItem[] { await QueryAsync(guild, presult) };
+ }
+
+ string name;
+ string disc;
+ var split = DiscriminatorSearch.Match(search);
+ if (split.Success)
+ {
+ name = split.Groups[1].Value;
+ disc = split.Groups[2].Value;
+ }
+ else
+ {
+ name = search;
+ disc = null;
+ }
+
+ // Storing in HashSet to enforce uniqueness
+ HashSet result = new HashSet(_uc);
+
+ using (var db = await RegexBot.Config.Database.GetOpenConnectionAsync())
+ {
+ using (var c = db.CreateCommand())
+ {
+ c.CommandText = $"SELECT {QueryColumns} FROM {Sql.TableUser} WHERE "
+ + "( lower(username) = lower(@NameSearch) OR lower(nickname) = lower(@NameSearch) )";
+ c.Parameters.Add("@NameSearch", NpgsqlTypes.NpgsqlDbType.Text).Value = name;
+ if (disc != null)
+ {
+ c.CommandText += " AND discriminator = @DiscSearch";
+ c.Parameters.Add("@DiscSearch", NpgsqlTypes.NpgsqlDbType.Text).Value = disc;
+ }
+ c.Prepare();
+
+ using (var r = await c.ExecuteReaderAsync())
+ {
+ while (await r.ReadAsync())
+ {
+ result.Add(new UserCacheItem(r));
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ private static UniqueChecker _uc = new UniqueChecker();
+ class UniqueChecker : IEqualityComparer
+ {
+ public bool Equals(UserCacheItem x, UserCacheItem y) => x.UserId == y.UserId && x.GuildId == y.GuildId;
+
+ public int GetHashCode(UserCacheItem obj) => unchecked((int)(obj.UserId ^ obj.GuildId));
+ }
+ #endregion
+ }
+}