diff --git a/Data/BotDatabaseContext.cs b/Data/BotDatabaseContext.cs new file mode 100644 index 0000000..d25be34 --- /dev/null +++ b/Data/BotDatabaseContext.cs @@ -0,0 +1,115 @@ +using Microsoft.EntityFrameworkCore; + +namespace WorldTime.Data; +public class BotDatabaseContext : DbContext { + private static string? _npgsqlConnectionString; + internal static string NpgsqlConnectionString { +#if DEBUG + get { + if (_npgsqlConnectionString != null) return _npgsqlConnectionString; + Program.Log(nameof(BotDatabaseContext), "Using hardcoded connection string!"); + return _npgsqlConnectionString ?? "Host=localhost;Username=worldtime;Password=wt"; + } +#else + get => _npgsqlConnectionString!; +#endif + set => _npgsqlConnectionString ??= value; + } + + public DbSet UserEntries { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseNpgsql(NpgsqlConnectionString) +#if DEBUG + .LogTo((string line) => Program.Log("EF", line), Microsoft.Extensions.Logging.LogLevel.Information) +#endif + .UseSnakeCaseNamingConvention(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(entity => { + entity.HasKey(e => new { e.GuildId, e.UserId }).HasName("userdata_pkey"); + entity.Property(e => e.LastUpdate).HasDefaultValueSql("now()"); + }); + } + + #region Helper methods / abstractions + /// + /// Checks if a given guild contains at least one user data entry with recent enough activity. + ///
To be used within a context. + ///
+ internal bool HasAnyUsers(SocketGuild guild) => UserEntries.Where(u => u.GuildId == (long)guild.Id).Any(); + + /// + /// Gets the number of unique time zones in the database. + ///
To be used within a context. + ///
+ internal int GetDistinctZoneCount() => UserEntries.Select(u => u.TimeZone).Distinct().Count(); + + /// + /// Removes the specified user from the database. + ///
To be used within a context. + ///
+ /// + /// if the removal was successful. + /// if the user did not exist. + /// + internal bool DeleteUser(SocketGuildUser user) { + var tuser = UserEntries.Where(u => u.UserId == (long)user.Id && u.GuildId == (long)user.Guild.Id).SingleOrDefault(); + if (tuser != null) { + Remove(tuser); + SaveChanges(); + return true; + } else { + return false; + } + } + + /// + /// Inserts/updates the specified user in the database. + ///
To be used within a context. + ///
+ internal void UpdateUser(SocketGuildUser user, string timezone) { + var tuser = UserEntries.Where(u => u.UserId == (long)user.Id && u.GuildId == (long)user.Guild.Id).SingleOrDefault(); + if (tuser != null) { + Update(tuser); + } else { + tuser = new UserEntry() { UserId = (long)user.Id, GuildId = (long)user.Guild.Id }; + Add(tuser); + } + tuser.TimeZone = timezone; + SaveChanges(); + } + + /// + /// Retrieves the time zone name of a single user. + ///
To be used within a context. + ///
+ internal string? GetUserZone(SocketGuildUser user) { + var tuser = UserEntries.Where(u => u.UserId == (long)user.Id && u.GuildId == (long)user.Guild.Id).SingleOrDefault(); + return tuser?.TimeZone; + } + + /// + /// Retrieves all known user time zones for the given guild. + ///
To be used within a context. + ///
+ /// + /// An unsorted dictionary. Keys are time zones, values are user IDs representative of those zones. + /// + internal Dictionary> GetGuildZones(ulong guildId) { + // Implementing the query from the previous iteration, in which further filtering is done by the caller. + // TODO consider bringing filtering back to this step, if there may be any advantage + var query = from entry in UserEntries + where entry.GuildId == (long)guildId + orderby entry.UserId + select Tuple.Create(entry.TimeZone, (ulong)entry.UserId); + var resultSet = new Dictionary>(); + foreach (var (tz, user) in query) { + if (!resultSet.ContainsKey(tz)) resultSet.Add(tz, new List()); + resultSet[tz].Add(user); + } + return resultSet; + } + #endregion +} diff --git a/Data/Migrations/20220628235704_InitialMigration.Designer.cs b/Data/Migrations/20220628235704_InitialMigration.Designer.cs new file mode 100644 index 0000000..0d8ada8 --- /dev/null +++ b/Data/Migrations/20220628235704_InitialMigration.Designer.cs @@ -0,0 +1,56 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using WorldTime.Data; + +#nullable disable + +namespace WorldTime.Data.Migrations +{ + [DbContext(typeof(BotDatabaseContext))] + [Migration("20220628235704_InitialMigration")] + partial class InitialMigration + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("WorldTime.Data.UserEntry", b => + { + b.Property("GuildId") + .HasColumnType("bigint") + .HasColumnName("guild_id"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.Property("LastUpdate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_active") + .HasDefaultValueSql("now()"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("zone"); + + b.HasKey("GuildId", "UserId") + .HasName("userdata_pkey"); + + b.ToTable("userdata", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20220628235704_InitialMigration.cs b/Data/Migrations/20220628235704_InitialMigration.cs new file mode 100644 index 0000000..22b1a89 --- /dev/null +++ b/Data/Migrations/20220628235704_InitialMigration.cs @@ -0,0 +1,33 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WorldTime.Data.Migrations +{ + public partial class InitialMigration : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "userdata", + columns: table => new + { + guild_id = table.Column(type: "bigint", nullable: false), + user_id = table.Column(type: "bigint", nullable: false), + zone = table.Column(type: "text", nullable: false), + last_active = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("userdata_pkey", x => new { x.guild_id, x.user_id }); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "userdata"); + } + } +} diff --git a/Data/Migrations/BotDatabaseContextModelSnapshot.cs b/Data/Migrations/BotDatabaseContextModelSnapshot.cs new file mode 100644 index 0000000..abae3e1 --- /dev/null +++ b/Data/Migrations/BotDatabaseContextModelSnapshot.cs @@ -0,0 +1,54 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using WorldTime.Data; + +#nullable disable + +namespace WorldTime.Data.Migrations +{ + [DbContext(typeof(BotDatabaseContext))] + partial class BotDatabaseContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("WorldTime.Data.UserEntry", b => + { + b.Property("GuildId") + .HasColumnType("bigint") + .HasColumnName("guild_id"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.Property("LastUpdate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_active") + .HasDefaultValueSql("now()"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("zone"); + + b.HasKey("GuildId", "UserId") + .HasName("userdata_pkey"); + + b.ToTable("userdata", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/UserEntry.cs b/Data/UserEntry.cs new file mode 100644 index 0000000..ae8ba7f --- /dev/null +++ b/Data/UserEntry.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace WorldTime.Data; +[Table("userdata")] +public class UserEntry { + [Key] + [Column("guild_id")] + public long GuildId { get; set; } + [Key] + [Column("user_id")] + public long UserId { get; set; } + [Column("zone")] + public string TimeZone { get; set; } = null!; + [Obsolete("No longer in use and should be removed promptly.")] + [Column("last_active")] + public DateTime LastUpdate { get; set; } +} \ No newline at end of file diff --git a/Database.cs b/Database.cs deleted file mode 100644 index 5e64666..0000000 --- a/Database.cs +++ /dev/null @@ -1,144 +0,0 @@ -using Npgsql; -using NpgsqlTypes; - -namespace WorldTime; - -/// -/// Database abstractions -/// -public class Database { - private const string UserDatabase = "userdata"; - - private readonly string _connectionString; - - internal Database(string connectionString) { - _connectionString = connectionString; - DoInitialDatabaseSetupAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - } - - /// - /// Sets up and opens a database connection. - /// - private async Task OpenConnectionAsync() { - var db = new NpgsqlConnection(_connectionString); - await db.OpenAsync().ConfigureAwait(false); - return db; - } - - private async Task DoInitialDatabaseSetupAsync() { - using var db = await OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"create table if not exists {UserDatabase} (" - + $"guild_id BIGINT, " - + "user_id BIGINT, " - + "zone TEXT NOT NULL, " - + "last_active TIMESTAMPTZ NOT NULL DEFAULT now(), " - + "PRIMARY KEY (guild_id, user_id)" // index automatically created with this - + ")"; - await c.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - /// - /// Checks if a given guild contains at least one user data entry with recent enough activity. - /// - internal async Task HasAnyAsync(SocketGuild guild) { - using var db = await OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $@" -SELECT true FROM {UserDatabase} -WHERE - guild_id = @Gid -LIMIT 1 -"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guild.Id; - await c.PrepareAsync().ConfigureAwait(false); - using var r = await c.ExecuteReaderAsync().ConfigureAwait(false); - return await r.ReadAsync().ConfigureAwait(false); - } - - /// - /// Gets the number of unique time zones in the database. - /// - internal async Task GetDistinctZoneCountAsync() { - using var db = await OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"SELECT COUNT(DISTINCT zone) FROM {UserDatabase}"; - return (int)((long?)await c.ExecuteScalarAsync() ?? -1); // ExecuteScalarAsync returns a long here - } - - /// - /// Removes the specified user from the database. - /// - /// True if the removal was successful. False typically if the user did not exist. - internal async Task DeleteUserAsync(SocketGuildUser user) { - using var db = await OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"DELETE FROM {UserDatabase} " + - "WHERE guild_id = @Gid AND user_id = @Uid"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)user.Guild.Id; - c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)user.Id; - await c.PrepareAsync().ConfigureAwait(false); - return await c.ExecuteNonQueryAsync().ConfigureAwait(false) > 0; - } - - /// - /// Inserts/updates the specified user in the database. - /// - internal async Task UpdateUserAsync(SocketGuildUser user, string timezone) { - using var db = await OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"INSERT INTO {UserDatabase} (guild_id, user_id, zone) " + - "VALUES (@Gid, @Uid, @Zone) " + - "ON CONFLICT (guild_id, user_id) DO " + - "UPDATE SET zone = EXCLUDED.zone"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)user.Guild.Id; - c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)user.Id; - c.Parameters.Add("@Zone", NpgsqlDbType.Text).Value = timezone; - await c.PrepareAsync().ConfigureAwait(false); - await c.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - /// - /// Retrieves the time zone name of a single user. - /// - internal async Task GetUserZoneAsync(SocketGuildUser user) { - using var db = await OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"SELECT zone FROM {UserDatabase} " + - "WHERE guild_id = @Gid AND user_id = @Uid"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)user.Guild.Id; - c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)user.Id; - await c.PrepareAsync().ConfigureAwait(false); - return (string?)await c.ExecuteScalarAsync(); - } - - /// - /// Retrieves all known user time zones for the given guild. - /// Further filtering should be handled by the consumer. - /// - /// - /// An unsorted dictionary. Keys are time zones, values are user IDs representative of those zones. - /// - internal async Task>> GetGuildZonesAsync(ulong guildId) { - using var db = await OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $@" -- Simpler query than 1.x; most filtering is now done by caller -SELECT zone, user_id FROM {UserDatabase} -WHERE - guild_id = @Gid -ORDER BY RANDOM() -- Randomize results for display purposes"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId; - await c.PrepareAsync().ConfigureAwait(false); - var r = await c.ExecuteReaderAsync().ConfigureAwait(false); - - var resultSet = new Dictionary>(); - while (await r.ReadAsync().ConfigureAwait(false)) { - var tz = r.GetString(0); - var user = (ulong)r.GetInt64(1); - - if (!resultSet.ContainsKey(tz)) resultSet.Add(tz, new List()); - resultSet[tz].Add(user); - } - return resultSet; - } -} diff --git a/WorldTime.csproj b/WorldTime.csproj index 282b871..f39ca13 100644 --- a/WorldTime.csproj +++ b/WorldTime.csproj @@ -12,10 +12,18 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + - + + +