diff --git a/src/Ellie/Db/EllieContext.cs b/src/Ellie/Db/EllieContext.cs new file mode 100644 index 0000000..965ad87 --- /dev/null +++ b/src/Ellie/Db/EllieContext.cs @@ -0,0 +1,488 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Db.Models; +using Ellie.Services.Database.Models; + +namespace Ellie.Services.Database; + +public abstract class EllieContext : DbContext +{ + public DbSet GuildConfigs { get; set; } + + public DbSet Quotes { get; set; } + public DbSet Reminders { get; set; } + public DbSet SelfAssignableRoles { get; set; } + public DbSet MusicPlaylists { get; set; } + public DbSet Expressions { get; set; } + public DbSet CurrencyTransactions { get; set; } + public DbSet WaifuUpdates { get; set; } + public DbSet WaifuItem { get; set; } + public DbSet Warnings { get; set; } + public DbSet UserXpStats { get; set; } + public DbSet Clubs { get; set; } + public DbSet ClubBans { get; set; } + public DbSet ClubApplicants { get; set; } + + + //logging + public DbSet LogSettings { get; set; } + public DbSet IgnoredVoicePresenceCHannels { get; set; } + public DbSet IgnoredLogChannels { get; set; } + + public DbSet RotatingStatus { get; set; } + public DbSet Blacklist { get; set; } + public DbSet AutoCommands { get; set; } + public DbSet RewardedUsers { get; set; } + public DbSet PlantedCurrency { get; set; } + public DbSet BanTemplates { get; set; } + public DbSet DiscordPermOverrides { get; set; } + public DbSet DiscordUser { get; set; } + public DbSet MusicPlayerSettings { get; set; } + public DbSet Repeaters { get; set; } + public DbSet Poll { get; set; } + public DbSet WaifuInfo { get; set; } + public DbSet ImageOnlyChannels { get; set; } + public DbSet NsfwBlacklistedTags { get; set; } + public DbSet AutoTranslateChannels { get; set; } + public DbSet AutoTranslateUsers { get; set; } + + public DbSet Permissions { get; set; } + + public DbSet BankUsers { get; set; } + + public DbSet ReactionRoles { get; set; } + + public DbSet Patrons { get; set; } + + public DbSet PatronQuotas { get; set; } + + public DbSet StreamOnlineMessages { get; set; } + + + #region Mandatory Provider-Specific Values + + protected abstract string CurrencyTransactionOtherIdDefaultValue { get; } + + #endregion + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region QUOTES + + var quoteEntity = modelBuilder.Entity(); + quoteEntity.HasIndex(x => x.GuildId); + quoteEntity.HasIndex(x => x.Keyword); + + #endregion + + #region GuildConfig + + var configEntity = modelBuilder.Entity(); + configEntity.HasIndex(c => c.GuildId) + .IsUnique(); + + configEntity.Property(x => x.VerboseErrors) + .HasDefaultValue(true); + + modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.AntiSpamSetting); + + modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.AntiRaidSetting); + + modelBuilder.Entity() + .HasOne(x => x.AntiAltSetting) + .WithOne() + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasAlternateKey(x => new + { + x.GuildConfigId, + x.Url + }); + + modelBuilder.Entity().HasIndex(x => x.MessageId).IsUnique(); + + modelBuilder.Entity().HasIndex(x => x.ChannelId); + + configEntity.HasIndex(x => x.WarnExpireHours).IsUnique(false); + + #endregion + + #region streamrole + + modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.StreamRole); + + #endregion + + #region Self Assignable Roles + + var selfassignableRolesEntity = modelBuilder.Entity(); + + selfassignableRolesEntity.HasIndex(s => new + { + s.GuildId, + s.RoleId + }) + .IsUnique(); + + selfassignableRolesEntity.Property(x => x.Group).HasDefaultValue(0); + + #endregion + + #region MusicPlaylists + + var musicPlaylistEntity = modelBuilder.Entity(); + + musicPlaylistEntity.HasMany(p => p.Songs).WithOne().OnDelete(DeleteBehavior.Cascade); + + #endregion + + #region Waifus + + var wi = modelBuilder.Entity(); + wi.HasOne(x => x.Waifu).WithOne(); + + wi.HasIndex(x => x.Price); + wi.HasIndex(x => x.ClaimerId); + // wi.HasMany(x => x.Items) + // .WithOne() + // .OnDelete(DeleteBehavior.Cascade); + + #endregion + + #region DiscordUser + + modelBuilder.Entity(du => + { + du.Property(x => x.IsClubAdmin) + .HasDefaultValue(false); + + du.Property(x => x.NotifyOnLevelUp) + .HasDefaultValue(XpNotificationLocation.None); + + du.Property(x => x.TotalXp) + .HasDefaultValue(0); + + du.Property(x => x.CurrencyAmount) + .HasDefaultValue(0); + + du.HasAlternateKey(w => w.UserId); + du.HasOne(x => x.Club) + .WithMany(x => x.Members) + .IsRequired(false) + .OnDelete(DeleteBehavior.NoAction); + + du.HasIndex(x => x.TotalXp); + du.HasIndex(x => x.CurrencyAmount); + du.HasIndex(x => x.UserId); + }); + + #endregion + + #region Warnings + + modelBuilder.Entity(warn => + { + warn.HasIndex(x => x.GuildId); + warn.HasIndex(x => x.UserId); + warn.HasIndex(x => x.DateAdded); + warn.Property(x => x.Weight).HasDefaultValue(1); + }); + + #endregion + + #region XpStats + + var xps = modelBuilder.Entity(); + xps.HasIndex(x => new + { + x.UserId, + x.GuildId + }) + .IsUnique(); + + xps.HasIndex(x => x.UserId); + xps.HasIndex(x => x.GuildId); + xps.HasIndex(x => x.Xp); + xps.HasIndex(x => x.AwardedXp); + + #endregion + + #region XpSettings + + modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.XpSettings); + + #endregion + + #region XpRoleReward + + modelBuilder.Entity() + .HasIndex(x => new + { + x.XpSettingsId, + x.Level + }) + .IsUnique(); + + #endregion + + #region Club + + var ci = modelBuilder.Entity(); + ci.HasOne(x => x.Owner) + .WithOne() + .HasForeignKey(x => x.OwnerId) + .OnDelete(DeleteBehavior.SetNull); + + ci.HasAlternateKey(x => new + { + x.Name + }); + + #endregion + + #region ClubManytoMany + + modelBuilder.Entity() + .HasKey(t => new + { + t.ClubId, + t.UserId + }); + + modelBuilder.Entity() + .HasOne(pt => pt.User) + .WithMany(); + + modelBuilder.Entity() + .HasOne(pt => pt.Club) + .WithMany(x => x.Applicants); + + modelBuilder.Entity() + .HasKey(t => new + { + t.ClubId, + t.UserId + }); + + modelBuilder.Entity() + .HasOne(pt => pt.User) + .WithMany(); + + modelBuilder.Entity() + .HasOne(pt => pt.Club) + .WithMany(x => x.Bans); + + #endregion + + #region Polls + + modelBuilder.Entity().HasIndex(x => x.GuildId).IsUnique(); + + #endregion + + #region CurrencyTransactions + + modelBuilder.Entity(e => + { + e.HasIndex(x => x.UserId) + .IsUnique(false); + + e.Property(x => x.OtherId) + .HasDefaultValueSql(CurrencyTransactionOtherIdDefaultValue); + + e.Property(x => x.Type) + .IsRequired(); + + e.Property(x => x.Extra) + .IsRequired(); + }); + + #endregion + + #region Reminders + + modelBuilder.Entity().HasIndex(x => x.When); + + #endregion + + #region GroupName + + modelBuilder.Entity() + .HasIndex(x => new + { + x.GuildConfigId, + x.Number + }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(x => x.GuildConfig) + .WithMany(x => x.SelfAssignableRoleGroupNames) + .IsRequired(); + + #endregion + + #region BanTemplate + + modelBuilder.Entity().HasIndex(x => x.GuildId).IsUnique(); + modelBuilder.Entity() + .Property(x => x.PruneDays) + .HasDefaultValue(null) + .IsRequired(false); + + #endregion + + #region Perm Override + + modelBuilder.Entity() + .HasIndex(x => new + { + x.GuildId, + x.Command + }) + .IsUnique(); + + #endregion + + #region Music + + modelBuilder.Entity().HasIndex(x => x.GuildId).IsUnique(); + + modelBuilder.Entity().Property(x => x.Volume).HasDefaultValue(100); + + #endregion + + #region Reaction roles + + modelBuilder.Entity(rr2 => + { + rr2.HasIndex(x => x.GuildId) + .IsUnique(false); + + rr2.HasIndex(x => new + { + x.MessageId, + x.Emote + }) + .IsUnique(); + }); + + #endregion + + #region LogSettings + + modelBuilder.Entity(ls => ls.HasIndex(x => x.GuildId).IsUnique()); + + modelBuilder.Entity(ls => ls + .HasMany(x => x.LogIgnores) + .WithOne(x => x.LogSetting) + .OnDelete(DeleteBehavior.Cascade)); + + modelBuilder.Entity(ili => ili + .HasIndex(x => new + { + x.LogSettingId, + x.LogItemId, + x.ItemType + }) + .IsUnique()); + + #endregion + + modelBuilder.Entity(ioc => ioc.HasIndex(x => x.ChannelId).IsUnique()); + + modelBuilder.Entity(nbt => nbt.HasIndex(x => x.GuildId).IsUnique(false)); + + var atch = modelBuilder.Entity(); + atch.HasIndex(x => x.GuildId).IsUnique(false); + + atch.HasIndex(x => x.ChannelId).IsUnique(); + + atch.HasMany(x => x.Users).WithOne(x => x.Channel).OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity(atu => atu.HasAlternateKey(x => new + { + x.ChannelId, + x.UserId + })); + + #region BANK + + modelBuilder.Entity(bu => bu.HasIndex(x => x.UserId).IsUnique()); + + #endregion + + + #region Patron + + // currency rewards + var pr = modelBuilder.Entity(); + pr.HasIndex(x => x.PlatformUserId).IsUnique(); + + // patrons + // patrons are not identified by their user id, but by their platform user id + // as multiple accounts (even maybe on different platforms) could have + // the same account connected to them + modelBuilder.Entity(pu => + { + pu.HasIndex(x => x.UniquePlatformUserId).IsUnique(); + pu.HasKey(x => x.UserId); + }); + + // quotes are per user id + modelBuilder.Entity(pq => + { + pq.HasIndex(x => x.UserId).IsUnique(false); + pq.HasKey(x => new + { + x.UserId, + x.FeatureType, + x.Feature + }); + }); + + #endregion + + #region Xp Item Shop + + modelBuilder.Entity( + x => + { + // user can own only one of each item + x.HasIndex(model => new + { + model.UserId, + model.ItemType, + model.ItemKey + }) + .IsUnique(); + }); + + #endregion + + #region AutoPublish + + modelBuilder.Entity(apc => apc + .HasIndex(x => x.GuildId) + .IsUnique()); + + #endregion + + #region GamblingStats + + modelBuilder.Entity(gs => gs + .HasIndex(x => x.Feature) + .IsUnique()); + + #endregion + } + +// #if DEBUG +// private static readonly ILoggerFactory _debugLoggerFactory = LoggerFactory.Create(x => x.AddConsole()); +// +// protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) +// => optionsBuilder.UseLoggerFactory(_debugLoggerFactory); +// #endif +} \ No newline at end of file diff --git a/src/Ellie/Db/EllieDbService.cs b/src/Ellie/Db/EllieDbService.cs new file mode 100644 index 0000000..349ddd6 --- /dev/null +++ b/src/Ellie/Db/EllieDbService.cs @@ -0,0 +1,76 @@ +using LinqToDB.Common; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Ellie.Services.Database; + +public class EllieDbService : DbService +{ + private readonly IBotCredsProvider _creds; + + // these are props because creds can change at runtime + private string DbType => _creds.GetCreds().Db.Type.ToLowerInvariant().Trim(); + private string ConnString => _creds.GetCreds().Db.ConnectionString; + + public EllieDbService(IBotCredsProvider creds) + { + LinqToDBForEFTools.Initialize(); + Configuration.Linq.DisableQueryCache = true; + + _creds = creds; + } + + public override async Task SetupAsync() + { + var dbType = DbType; + var connString = ConnString; + + await using var context = CreateRawDbContext(dbType, connString); + + // make sure sqlite db is in wal journal mode + if (context is SqliteContext) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL"); + } + + await context.Database.MigrateAsync(); + } + + public override EllieContext CreateRawDbContext(string dbType, string connString) + { + switch (dbType) + { + case "postgresql": + case "postgres": + case "pgsql": + return new PostgreSqlContext(connString); + case "mysql": + return new MysqlContext(connString); + case "sqlite": + return new SqliteContext(connString); + default: + throw new NotSupportedException($"The database provide type of '{dbType}' is not supported."); + } + } + + private EllieContext GetDbContextInternal() + { + var dbType = DbType; + var connString = ConnString; + + var context = CreateRawDbContext(dbType, connString); + if (context is SqliteContext) + { + var conn = context.Database.GetDbConnection(); + conn.Open(); + using var com = conn.CreateCommand(); + com.CommandText = "PRAGMA synchronous=OFF"; + com.ExecuteNonQuery(); + } + + return context; + } + + public override EllieContext GetDbContext() + => GetDbContextInternal(); +} \ No newline at end of file diff --git a/src/Ellie/Db/MysqlContext.cs b/src/Ellie/Db/MysqlContext.cs new file mode 100644 index 0000000..ec3394b --- /dev/null +++ b/src/Ellie/Db/MysqlContext.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Ellie.Db.Models; + +namespace Ellie.Services.Database; + +public sealed class MysqlContext : EllieContext +{ + private readonly string _connStr; + private readonly string _version; + + protected override string CurrencyTransactionOtherIdDefaultValue + => "NULL"; + + public MysqlContext(string connStr = "Server=localhost", string version = "8.0") + { + _connStr = connStr; + _version = version; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + optionsBuilder + .UseLowerCaseNamingConvention() + .UseMySql(_connStr, ServerVersion.Parse(_version)); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // mysql is case insensitive by default + // we can set binary collation to change that + modelBuilder.Entity() + .Property(x => x.Name) + .UseCollation("utf8mb4_bin"); + } +} \ No newline at end of file diff --git a/src/Ellie/Db/PostgreSqlContext.cs b/src/Ellie/Db/PostgreSqlContext.cs new file mode 100644 index 0000000..14c59b8 --- /dev/null +++ b/src/Ellie/Db/PostgreSqlContext.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; + +namespace Ellie.Services.Database; + +public sealed class PostgreSqlContext : EllieContext +{ + private readonly string _connStr; + + protected override string CurrencyTransactionOtherIdDefaultValue + => "NULL"; + + public PostgreSqlContext(string connStr = "Host=localhost") + { + _connStr = connStr; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + + base.OnConfiguring(optionsBuilder); + optionsBuilder + .UseLowerCaseNamingConvention() + .UseNpgsql(_connStr); + } +} \ No newline at end of file diff --git a/src/Ellie/Db/SqliteContext.cs b/src/Ellie/Db/SqliteContext.cs new file mode 100644 index 0000000..96cce01 --- /dev/null +++ b/src/Ellie/Db/SqliteContext.cs @@ -0,0 +1,26 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Ellie.Services.Database; + +public sealed class SqliteContext : EllieContext +{ + private readonly string _connectionString; + + protected override string CurrencyTransactionOtherIdDefaultValue + => "NULL"; + + public SqliteContext(string connectionString = "Data Source=data/Ellie.db", int commandTimeout = 60) + { + _connectionString = connectionString; + Database.SetCommandTimeout(commandTimeout); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + var builder = new SqliteConnectionStringBuilder(_connectionString); + builder.DataSource = Path.Combine(AppContext.BaseDirectory, builder.DataSource); + optionsBuilder.UseSqlite(builder.ToString()); + } +} \ No newline at end of file