diff --git a/PluralKit.API/Controllers/v1/MemberController.cs b/PluralKit.API/Controllers/v1/MemberController.cs index 40213aad..6a893b4c 100644 --- a/PluralKit.API/Controllers/v1/MemberController.cs +++ b/PluralKit.API/Controllers/v1/MemberController.cs @@ -57,6 +57,7 @@ namespace PluralKit.API return BadRequest(e.Message); } + // TODO: retire SaveMember await _data.SaveMember(member); return Ok(member.ToJson(User.ContextFor(member))); } @@ -80,6 +81,7 @@ namespace PluralKit.API return BadRequest(e.Message); } + // TODO: retire SaveMember await _data.SaveMember(member); return Ok(member.ToJson(User.ContextFor(member))); } diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 06a998e0..3f8375d5 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -7,6 +7,8 @@ using Dapper; using DSharpPlus.Entities; +using NodaTime; + using PluralKit.Core; namespace PluralKit.Bot @@ -40,8 +42,8 @@ namespace PluralKit.Bot } // Rename the member - target.Name = newName; - await _data.SaveMember(target); + var patch = new MemberPatch {Name = Partial.Present(newName)}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); await ctx.Reply($"{Emojis.Success} Member renamed."); if (newName.Contains(" ")) await ctx.Reply($"{Emojis.Note} Note that this member's name now contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it."); @@ -67,9 +69,9 @@ namespace PluralKit.Bot if (MatchClear(ctx)) { CheckEditMemberPermission(ctx, target); - target.Description = null; - - await _data.SaveMember(target); + + var patch = new MemberPatch {Description = Partial.Null()}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); await ctx.Reply($"{Emojis.Success} Member description cleared."); } else if (!ctx.HasNext()) @@ -100,7 +102,8 @@ namespace PluralKit.Bot throw Errors.DescriptionTooLongError(description.Length); target.Description = description; - await _data.SaveMember(target); + var patch = new MemberPatch {Description = Partial.Present(description)}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); await ctx.Reply($"{Emojis.Success} Member description changed."); } } @@ -109,9 +112,8 @@ namespace PluralKit.Bot if (MatchClear(ctx)) { CheckEditMemberPermission(ctx, target); - target.Pronouns = null; - - await _data.SaveMember(target); + var patch = new MemberPatch {Pronouns = Partial.Null()}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); await ctx.Reply($"{Emojis.Success} Member pronouns cleared."); } else if (!ctx.HasNext()) @@ -134,9 +136,10 @@ namespace PluralKit.Bot var pronouns = ctx.RemainderOrNull().NormalizeLineEndSpacing(); if (pronouns.IsLongerThan(Limits.MaxPronounsLength)) throw Errors.MemberPronounsTooLongError(pronouns.Length); - target.Pronouns = pronouns; - - await _data.SaveMember(target); + + var patch = new MemberPatch {Pronouns = Partial.Present(pronouns)}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); + await ctx.Reply($"{Emojis.Success} Member pronouns changed."); } } @@ -147,8 +150,10 @@ namespace PluralKit.Bot if (MatchClear(ctx)) { CheckEditMemberPermission(ctx, target); - target.Color = null; - await _data.SaveMember(target); + + var patch = new MemberPatch {Color = Partial.Null()}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); + await ctx.Reply($"{Emojis.Success} Member color cleared."); } else if (!ctx.HasNext()) @@ -177,12 +182,13 @@ namespace PluralKit.Bot if (color.StartsWith("#")) color = color.Substring(1); if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); - target.Color = color.ToLower(); - await _data.SaveMember(target); + + var patch = new MemberPatch {Color = Partial.Present(color.ToLowerInvariant())}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); await ctx.Reply(embed: new DiscordEmbedBuilder() .WithTitle($"{Emojis.Success} Member color changed.") - .WithColor(target.Color.ToDiscordColor().Value) + .WithColor(color.ToDiscordColor().Value) .WithThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20") .Build()); } @@ -192,8 +198,10 @@ namespace PluralKit.Bot if (MatchClear(ctx)) { CheckEditMemberPermission(ctx, target); - target.Birthday = null; - await _data.SaveMember(target); + + var patch = new MemberPatch {Birthday = Partial.Null()}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); + await ctx.Reply($"{Emojis.Success} Member birthdate cleared."); } else if (!ctx.HasNext()) @@ -215,8 +223,10 @@ namespace PluralKit.Bot var birthdayStr = ctx.RemainderOrNull(); var birthday = DateUtils.ParseDate(birthdayStr, true); if (birthday == null) throw Errors.BirthdayParseError(birthdayStr); - target.Birthday = birthday; - await _data.SaveMember(target); + + var patch = new MemberPatch {Birthday = Partial.Present(birthday)}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); + await ctx.Reply($"{Emojis.Success} Member birthdate changed."); } } @@ -275,8 +285,9 @@ namespace PluralKit.Bot { CheckEditMemberPermission(ctx, target); - target.DisplayName = null; - await _data.SaveMember(target); + var patch = new MemberPatch {DisplayName = Partial.Null()}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); + await PrintSuccess($"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx)}\"."); } else if (!ctx.HasNext()) @@ -292,8 +303,9 @@ namespace PluralKit.Bot CheckEditMemberPermission(ctx, target); var newDisplayName = ctx.RemainderOrNull(); - target.DisplayName = newDisplayName; - await _data.SaveMember(target); + + var patch = new MemberPatch {DisplayName = Partial.Present(newDisplayName)}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); await PrintSuccess($"{Emojis.Success} Member display name changed. This member will now be proxied using the name \"{newDisplayName}\"."); } @@ -356,8 +368,8 @@ namespace PluralKit.Bot return; }; - target.KeepProxy = newValue; - await _data.SaveMember(target); + var patch = new MemberPatch {KeepProxy = Partial.Present(newValue)}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); if (newValue) await ctx.Reply($"{Emojis.Success} Member proxy tags will now be included in the resulting message when proxying."); @@ -430,8 +442,9 @@ namespace PluralKit.Bot newLevel = PopPrivacyLevel(subject.Name()); // Set the level on the given subject - target.SetPrivacy(subject, newLevel); - await _data.SaveMember(target); + var patch = new MemberPatch(); + patch.SetPrivacy(subject, newLevel); + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); // Print response var explanation = (subject, newLevel) switch @@ -460,8 +473,10 @@ namespace PluralKit.Bot else if (ctx.Match("all") || newValueFromCommand != null) { newLevel = newValueFromCommand ?? PopPrivacyLevel("all"); - target.SetAllPrivacy(newLevel); - await _data.SaveMember(target); + + var patch = new MemberPatch(); + patch.SetAllPrivacy(newLevel); + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); if(newLevel == PrivacyLevel.Private) await ctx.Reply($"All {target.NameFor(ctx)}'s privacy settings have been set to **{newLevel.LevelName()}**. Other accounts will now see nothing on the member card."); @@ -490,7 +505,9 @@ namespace PluralKit.Bot await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete \"{target.NameFor(ctx)}\"? If so, reply to this message with the member's ID (`{target.Hid}`). __***This cannot be undone!***__"); if (!await ctx.ConfirmWithReply(target.Hid)) throw Errors.MemberDeleteCancelled; - await _data.DeleteMember(target); + + await _db.Execute(conn => conn.DeleteMember(target.Id)); + await ctx.Reply($"{Emojis.Success} Member deleted."); } } diff --git a/PluralKit.Bot/Commands/MemberProxy.cs b/PluralKit.Bot/Commands/MemberProxy.cs index ac7541b4..2ab0e482 100644 --- a/PluralKit.Bot/Commands/MemberProxy.cs +++ b/PluralKit.Bot/Commands/MemberProxy.cs @@ -1,17 +1,19 @@ using System.Linq; using System.Threading.Tasks; +using Dapper; + using PluralKit.Core; namespace PluralKit.Bot { public class MemberProxy { - private IDataStore _data; + private readonly IDatabase _db; - public MemberProxy(IDataStore data) + public MemberProxy(IDatabase db) { - _data = data; + _db = db; } public async Task Proxy(Context ctx, PKMember target) @@ -30,10 +32,10 @@ namespace PluralKit.Bot async Task WarnOnConflict(ProxyTag newTag) { - var conflicts = (await _data.GetConflictingProxies(ctx.System, newTag)) - .Where(m => m.Id != target.Id) - .ToList(); - + var query = "select * from (select *, (unnest(proxy_tags)).prefix as prefix, (unnest(proxy_tags)).suffix as suffix from members where system = @System) as _ where prefix = @Prefix and suffix = @Suffix and id != @Existing"; + var conflicts = (await _db.Execute(conn => conn.QueryAsync(query, + new {Prefix = newTag.Prefix, Suffix = newTag.Suffix, Existing = target.Id}))).ToList(); + if (conflicts.Count <= 0) return true; var conflictList = conflicts.Select(m => $"- **{m.NameFor(ctx)}**"); @@ -56,7 +58,9 @@ namespace PluralKit.Bot target.ProxyTags = new ProxyTag[] { }; - await _data.SaveMember(target); + var patch = new MemberPatch {ProxyTags = Partial.Present(new ProxyTag[0])}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); + await ctx.Reply($"{Emojis.Success} Proxy tags cleared."); } // "Sub"command: no arguments; will print proxy tags @@ -83,11 +87,11 @@ namespace PluralKit.Bot if (!await WarnOnConflict(tagToAdd)) throw Errors.GenericCancelled(); - // It's not guaranteed the list's mutable, so we force it to be - target.ProxyTags = target.ProxyTags.ToList(); - target.ProxyTags.Add(tagToAdd); - - await _data.SaveMember(target); + var newTags = target.ProxyTags.ToList(); + newTags.Add(tagToAdd); + var patch = new MemberPatch {ProxyTags = Partial.Present(newTags.ToArray())}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); + await ctx.Reply($"{Emojis.Success} Added proxy tags `{tagToAdd.ProxyString}`."); } // Subcommand: "remove" @@ -100,11 +104,11 @@ namespace PluralKit.Bot if (!target.ProxyTags.Contains(tagToRemove)) throw Errors.ProxyTagDoesNotExist(tagToRemove, target); - // It's not guaranteed the list's mutable, so we force it to be - target.ProxyTags = target.ProxyTags.ToList(); - target.ProxyTags.Remove(tagToRemove); - - await _data.SaveMember(target); + var newTags = target.ProxyTags.ToList(); + newTags.Remove(tagToRemove); + var patch = new MemberPatch {ProxyTags = Partial.Present(newTags.ToArray())}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); + await ctx.Reply($"{Emojis.Success} Removed proxy tags `{tagToRemove.ProxyString}`."); } // Subcommand: bare proxy tag given @@ -125,9 +129,10 @@ namespace PluralKit.Bot if (!await WarnOnConflict(requestedTag)) throw Errors.GenericCancelled(); - target.ProxyTags = new[] {requestedTag}; + var newTags = new[] {requestedTag}; + var patch = new MemberPatch {ProxyTags = Partial.Present(newTags)}; + await _db.Execute(conn => conn.UpdateMember(target.Id, patch)); - await _data.SaveMember(target); await ctx.Reply($"{Emojis.Success} Member proxy tags set to `{requestedTag.ProxyString}`."); } } diff --git a/PluralKit.Core/Models/ModelQueryExt.cs b/PluralKit.Core/Models/ModelQueryExt.cs index 2e093cbd..eb6a5594 100644 --- a/PluralKit.Core/Models/ModelQueryExt.cs +++ b/PluralKit.Core/Models/ModelQueryExt.cs @@ -3,6 +3,8 @@ using System.Threading.Tasks; using Dapper; +using PluralKit.Core; + namespace PluralKit.Core { public static class ModelQueryExt @@ -26,5 +28,16 @@ namespace PluralKit.Core conn.QueryFirstAsync( "insert into member_guild (guild, member) values (@guild, @member) on conflict (guild, member) do update set guild = @guild, member = @member returning *", new {guild, member}); + + public static Task UpdateMember(this IPKConnection conn, MemberId id, MemberPatch patch) + { + var (query, pms) = patch.Apply(new UpdateQueryBuilder("members", "id = @id")) + .WithConstant("id", id) + .Build("returning *"); + return conn.QueryFirstAsync(query, pms); + } + + public static Task DeleteMember(this IPKConnection conn, MemberId id) => + conn.ExecuteAsync("delete from members where id = @Id", new {Id = id}); } } \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/MemberPatch.cs b/PluralKit.Core/Models/Patch/MemberPatch.cs new file mode 100644 index 00000000..f1f63e56 --- /dev/null +++ b/PluralKit.Core/Models/Patch/MemberPatch.cs @@ -0,0 +1,44 @@ +#nullable enable + +using NodaTime; + +namespace PluralKit.Core +{ + public class MemberPatch: PatchObject + { + public Partial Name { get; set; } + public Partial DisplayName { get; set; } + public Partial Color { get; set; } + public Partial Birthday { get; set; } + public Partial Pronouns { get; set; } + public Partial Description { get; set; } + public Partial ProxyTags { get; set; } + public Partial KeepProxy { get; set; } + public Partial MessageCount { get; set; } + public Partial Visibility { get; set; } + public Partial NamePrivacy { get; set; } + public Partial DescriptionPrivacy { get; set; } + public Partial PronounPrivacy { get; set; } + public Partial BirthdayPrivacy { get; set; } + public Partial AvatarPrivacy { get; set; } + public Partial MetadataPrivacy { get; set; } + + public override UpdateQueryBuilder Apply(UpdateQueryBuilder b) => b + .With("name", Name) + .With("display_name", DisplayName) + .With("color", Color) + .With("birthday", Birthday) + .With("pronouns", Pronouns) + .With("description", Description) + .With("proxy_tags", ProxyTags) + .With("keep_proxy", KeepProxy) + .With("message_count", MessageCount) + .With("member_visibility", Visibility) + .With("name_privacy", NamePrivacy) + .With("description_privacy", DescriptionPrivacy) + .With("pronoun_privacy", PronounPrivacy) + .With("birthday_privacy", BirthdayPrivacy) + .With("avatar_privacy", AvatarPrivacy) + .With("metadata_privacy", MetadataPrivacy); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/PatchObject.cs b/PluralKit.Core/Models/Patch/PatchObject.cs new file mode 100644 index 00000000..9105cf6e --- /dev/null +++ b/PluralKit.Core/Models/Patch/PatchObject.cs @@ -0,0 +1,9 @@ +using PluralKit.Core; + +namespace PluralKit.Core +{ + public abstract class PatchObject + { + public abstract UpdateQueryBuilder Apply(UpdateQueryBuilder b); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Services/IDataStore.cs b/PluralKit.Core/Services/IDataStore.cs index 9d16eb4c..93f40748 100644 --- a/PluralKit.Core/Services/IDataStore.cs +++ b/PluralKit.Core/Services/IDataStore.cs @@ -80,16 +80,6 @@ namespace PluralKit.Core { /// /// Whether the returned count should include private members. Task GetSystemMemberCount(SystemId system, bool includePrivate); - - /// - /// Gets a list of members with proxy tags that conflict with the given tags. - /// - /// A set of proxy tags A conflict with proxy tags B if both A's prefix and suffix - /// are a "subset" of B's. In other words, if A's prefix *starts* with B's prefix - /// and A's suffix *ends* with B's suffix, the tag pairs are considered conflicting. - /// - /// The system to check in. - Task> GetConflictingProxies(PKSystem system, ProxyTag tag); /// /// Creates a system, auto-generating its corresponding IDs. @@ -127,12 +117,6 @@ namespace PluralKit.Core { /// Task DeleteSystem(PKSystem system); - /// - /// Gets a system by its internal member ID. - /// - /// The with the given internal ID, or null if no member was found. - Task GetMemberById(MemberId memberId); - /// /// Gets a member by its user-facing human ID. /// diff --git a/PluralKit.Core/Utils/Partial.cs b/PluralKit.Core/Utils/Partial.cs new file mode 100644 index 00000000..99eef13d --- /dev/null +++ b/PluralKit.Core/Utils/Partial.cs @@ -0,0 +1,80 @@ +#nullable enable +using System; +using System.Collections; +using System.Collections.Generic; + +using Dapper; + +using Newtonsoft.Json; + +namespace PluralKit.Core +{ + [JsonConverter(typeof(PartialConverter))] + public struct Partial: IEnumerable + { + public bool IsPresent { get; } + public T Value { get; } + + private Partial(bool isPresent, T value) + { + IsPresent = isPresent; + Value = value; + } + + public static Partial Null() => new Partial(true, default!); + public static Partial Present(T obj) => new Partial(true, obj); + public static Partial Absent = new Partial(false, default!); + + public IEnumerable ToArray() => IsPresent ? new[] {Value} : new T[0]; + + public IEnumerator GetEnumerator() => ToArray().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ToArray().GetEnumerator(); + } + + public class PartialConverter: JsonConverter + { + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, + JsonSerializer serializer) + { + var innerType = objectType.GenericTypeArguments[0]; + var innerValue = serializer.Deserialize(reader, innerType); + + return typeof(Partial<>) + .MakeGenericType(innerType) + .GetMethod(nameof(Partial.Present))! + .Invoke(null, new[] {innerValue}); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) => + throw new NotImplementedException(); + + public override bool CanConvert(Type objectType) => true; + + public override bool CanRead => true; + public override bool CanWrite => false; + } + + public static class PartialExt + { + public static bool TryGet(this Partial pt, out T value) + { + value = pt.IsPresent ? pt.Value : default!; + return pt.IsPresent; + } + + public static T Or(this Partial pt, T fallback) => pt.IsPresent ? pt.Value : fallback; + public static T Or(this Partial pt, Func fallback) => pt.IsPresent ? pt.Value : fallback.Invoke(); + + public static Partial Map(this Partial pt, Func fn) => + pt.IsPresent ? Partial.Present(fn.Invoke(pt.Value)) : Partial.Absent; + + public static void Apply(this Partial pt, DynamicParameters bag, QueryBuilder qb, string fieldName) + { + if (!pt.IsPresent) return; + + bag.Add(fieldName, pt.Value); + qb.Variable(fieldName, $"@{fieldName}"); + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Utils/PrivacyUtils.cs b/PluralKit.Core/Utils/PrivacyUtils.cs index c3b7312b..0c1b0a1e 100644 --- a/PluralKit.Core/Utils/PrivacyUtils.cs +++ b/PluralKit.Core/Utils/PrivacyUtils.cs @@ -26,31 +26,31 @@ namespace PluralKit.Core _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") }; - public static void SetPrivacy(this PKMember member, MemberPrivacySubject subject, PrivacyLevel level) + public static void SetPrivacy(this MemberPatch member, MemberPrivacySubject subject, PrivacyLevel level) { // what do you mean switch expressions can't be statements >.> _ = subject switch { - MemberPrivacySubject.Name => member.NamePrivacy = level, - MemberPrivacySubject.Description => member.DescriptionPrivacy = level, - MemberPrivacySubject.Avatar => member.AvatarPrivacy = level, - MemberPrivacySubject.Pronouns => member.PronounPrivacy = level, - MemberPrivacySubject.Birthday => member.BirthdayPrivacy= level, - MemberPrivacySubject.Metadata => member.MetadataPrivacy = level, - MemberPrivacySubject.Visibility => member.MemberVisibility = level, + MemberPrivacySubject.Name => member.NamePrivacy = Partial.Present(level), + MemberPrivacySubject.Description => member.DescriptionPrivacy = Partial.Present(level), + MemberPrivacySubject.Avatar => member.AvatarPrivacy = Partial.Present(level), + MemberPrivacySubject.Pronouns => member.PronounPrivacy = Partial.Present(level), + MemberPrivacySubject.Birthday => member.BirthdayPrivacy= Partial.Present(level), + MemberPrivacySubject.Metadata => member.MetadataPrivacy = Partial.Present(level), + MemberPrivacySubject.Visibility => member.Visibility = Partial.Present(level), _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") }; } - public static void SetAllPrivacy(this PKMember member, PrivacyLevel level) + public static void SetAllPrivacy(this MemberPatch member, PrivacyLevel level) { - member.NamePrivacy = level; - member.DescriptionPrivacy = level; - member.AvatarPrivacy = level; - member.PronounPrivacy = level; - member.BirthdayPrivacy = level; - member.MetadataPrivacy = level; - member.MemberVisibility = level; + member.NamePrivacy = Partial.Present(level); + member.DescriptionPrivacy = Partial.Present(level); + member.AvatarPrivacy = Partial.Present(level); + member.PronounPrivacy = Partial.Present(level); + member.BirthdayPrivacy = Partial.Present(level); + member.MetadataPrivacy = Partial.Present(level); + member.Visibility = Partial.Present(level); } public static bool TryParseMemberPrivacy(string input, out MemberPrivacySubject subject) diff --git a/PluralKit.Core/Utils/UpdateQueryBuilder.cs b/PluralKit.Core/Utils/UpdateQueryBuilder.cs new file mode 100644 index 00000000..549e2e32 --- /dev/null +++ b/PluralKit.Core/Utils/UpdateQueryBuilder.cs @@ -0,0 +1,51 @@ +using System.Text; + +using Dapper; + +namespace PluralKit.Core +{ + public class UpdateQueryBuilder + { + private readonly string _table; + private readonly string _condition; + private readonly DynamicParameters _params = new DynamicParameters(); + + private bool _hasFields = false; + private readonly StringBuilder _setClause = new StringBuilder(); + + public UpdateQueryBuilder(string table, string condition) + { + _table = table; + _condition = condition; + } + + public UpdateQueryBuilder WithConstant(string name, T value) + { + _params.Add(name, value); + return this; + } + + public UpdateQueryBuilder With(string columnName, T value) + { + _params.Add(columnName, value); + + if (_hasFields) + _setClause.Append(", "); + else _hasFields = true; + + _setClause.Append($"{columnName} = @{columnName}"); + return this; + } + + public UpdateQueryBuilder With(string columnName, Partial partialValue) + { + return partialValue.IsPresent ? With(columnName, partialValue.Value) : this; + } + + public (string Query, DynamicParameters Parameters) Build(string append = "") + { + var query = $"update {_table} set {_setClause} where {_condition} {append}"; + return (query, _params); + } + } +} \ No newline at end of file