mirror of
https://github.com/NoiTheCat/WorldTime.git
synced 2024-11-21 14:34:36 +00:00
Switch to Entity Framework
Turned existing database setup into a migration, and all helper methods were moved to the new class for easy migration to the new system.
This commit is contained in:
parent
5edc7ce9ab
commit
745fba06ab
7 changed files with 285 additions and 145 deletions
115
Data/BotDatabaseContext.cs
Normal file
115
Data/BotDatabaseContext.cs
Normal file
|
@ -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<UserEntry> 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<UserEntry>(entity => {
|
||||
entity.HasKey(e => new { e.GuildId, e.UserId }).HasName("userdata_pkey");
|
||||
entity.Property(e => e.LastUpdate).HasDefaultValueSql("now()");
|
||||
});
|
||||
}
|
||||
|
||||
#region Helper methods / abstractions
|
||||
/// <summary>
|
||||
/// Checks if a given guild contains at least one user data entry with recent enough activity.
|
||||
/// <br />To be used within a <see langword="using"/> context.
|
||||
/// </summary>
|
||||
internal bool HasAnyUsers(SocketGuild guild) => UserEntries.Where(u => u.GuildId == (long)guild.Id).Any();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of unique time zones in the database.
|
||||
/// <br />To be used within a <see langword="using"/> context.
|
||||
/// </summary>
|
||||
internal int GetDistinctZoneCount() => UserEntries.Select(u => u.TimeZone).Distinct().Count();
|
||||
|
||||
/// <summary>
|
||||
/// Removes the specified user from the database.
|
||||
/// <br />To be used within a <see langword="using"/> context.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if the removal was successful.
|
||||
/// <see langword="false"/> if the user did not exist.
|
||||
/// </returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts/updates the specified user in the database.
|
||||
/// <br />To be used within a <see langword="using"/> context.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the time zone name of a single user.
|
||||
/// <br />To be used within a <see langword="using"/> context.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all known user time zones for the given guild.
|
||||
/// <br />To be used within a <see langword="using"/> context.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// An unsorted dictionary. Keys are time zones, values are user IDs representative of those zones.
|
||||
/// </returns>
|
||||
internal Dictionary<string, List<ulong>> 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<string, List<ulong>>();
|
||||
foreach (var (tz, user) in query) {
|
||||
if (!resultSet.ContainsKey(tz)) resultSet.Add(tz, new List<ulong>());
|
||||
resultSet[tz].Add(user);
|
||||
}
|
||||
return resultSet;
|
||||
}
|
||||
#endregion
|
||||
}
|
56
Data/Migrations/20220628235704_InitialMigration.Designer.cs
generated
Normal file
56
Data/Migrations/20220628235704_InitialMigration.Designer.cs
generated
Normal file
|
@ -0,0 +1,56 @@
|
|||
// <auto-generated />
|
||||
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<long>("GuildId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("guild_id");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<DateTime>("LastUpdate")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_active")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("TimeZone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("zone");
|
||||
|
||||
b.HasKey("GuildId", "UserId")
|
||||
.HasName("userdata_pkey");
|
||||
|
||||
b.ToTable("userdata", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
33
Data/Migrations/20220628235704_InitialMigration.cs
Normal file
33
Data/Migrations/20220628235704_InitialMigration.cs
Normal file
|
@ -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<long>(type: "bigint", nullable: false),
|
||||
user_id = table.Column<long>(type: "bigint", nullable: false),
|
||||
zone = table.Column<string>(type: "text", nullable: false),
|
||||
last_active = table.Column<DateTime>(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");
|
||||
}
|
||||
}
|
||||
}
|
54
Data/Migrations/BotDatabaseContextModelSnapshot.cs
Normal file
54
Data/Migrations/BotDatabaseContextModelSnapshot.cs
Normal file
|
@ -0,0 +1,54 @@
|
|||
// <auto-generated />
|
||||
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<long>("GuildId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("guild_id");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<DateTime>("LastUpdate")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_active")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("TimeZone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("zone");
|
||||
|
||||
b.HasKey("GuildId", "UserId")
|
||||
.HasName("userdata_pkey");
|
||||
|
||||
b.ToTable("userdata", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
18
Data/UserEntry.cs
Normal file
18
Data/UserEntry.cs
Normal file
|
@ -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; }
|
||||
}
|
144
Database.cs
144
Database.cs
|
@ -1,144 +0,0 @@
|
|||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace WorldTime;
|
||||
|
||||
/// <summary>
|
||||
/// Database abstractions
|
||||
/// </summary>
|
||||
public class Database {
|
||||
private const string UserDatabase = "userdata";
|
||||
|
||||
private readonly string _connectionString;
|
||||
|
||||
internal Database(string connectionString) {
|
||||
_connectionString = connectionString;
|
||||
DoInitialDatabaseSetupAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up and opens a database connection.
|
||||
/// </summary>
|
||||
private async Task<NpgsqlConnection> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a given guild contains at least one user data entry with recent enough activity.
|
||||
/// </summary>
|
||||
internal async Task<bool> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of unique time zones in the database.
|
||||
/// </summary>
|
||||
internal async Task<int> 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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the specified user from the database.
|
||||
/// </summary>
|
||||
/// <returns>True if the removal was successful. False typically if the user did not exist.</returns>
|
||||
internal async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts/updates the specified user in the database.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the time zone name of a single user.
|
||||
/// </summary>
|
||||
internal async Task<string?> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all known user time zones for the given guild.
|
||||
/// Further filtering should be handled by the consumer.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// An unsorted dictionary. Keys are time zones, values are user IDs representative of those zones.
|
||||
/// </returns>
|
||||
internal async Task<Dictionary<string, List<ulong>>> 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<string, List<ulong>>();
|
||||
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<ulong>());
|
||||
resultSet[tz].Add(user);
|
||||
}
|
||||
return resultSet;
|
||||
}
|
||||
}
|
|
@ -12,10 +12,18 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="CommandLineParser" Version="2.8.0" />
|
||||
<PackageReference Include="Discord.Net" Version="3.5.0" />
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.6">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="NodaTime" Version="3.1.0" />
|
||||
<PackageReference Include="Npgsql" Version="6.0.4" />
|
||||
<PackageReference Include="Npgsql" Version="6.0.5" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.5" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
Loading…
Reference in a new issue