From 25a441fba9db9aeddf03a3740caf5b04e354e0d7 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Tue, 7 Nov 2017 22:36:18 -0800 Subject: [PATCH] Added UserCacheItem This class will be really useful for a feature that is soon to be added. Also moved some commonly used bits from EntityCache over to its own file. --- Feature/EntityCache/EntityCache.cs | 46 +------ Feature/EntityCache/Sql.cs | 56 +++++++++ Feature/EntityCache/UserCacheItem.cs | 180 +++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 42 deletions(-) create mode 100644 Feature/EntityCache/Sql.cs create mode 100644 Feature/EntityCache/UserCacheItem.cs 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 + } +}