diff --git a/back/src/Kyoo.Abstractions/Models/Attributes/SqlFirstColumnAttribute.cs b/back/src/Kyoo.Abstractions/Models/Attributes/SqlFirstColumnAttribute.cs new file mode 100644 index 00000000..e420a1f3 --- /dev/null +++ b/back/src/Kyoo.Abstractions/Models/Attributes/SqlFirstColumnAttribute.cs @@ -0,0 +1,37 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using Kyoo.Utils; + +namespace Kyoo.Abstractions.Models.Attributes; + +[AttributeUsage(AttributeTargets.Class)] +public class SqlFirstColumnAttribute : Attribute +{ + /// + /// The name of the first column of the element. Used to split multiples + /// items on a single sql query. If not specified, it defaults to "Id". + /// + public string Name { get; set; } + + public SqlFirstColumnAttribute(string name) + { + Name = name.ToSnakeCase(); + } +} diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs index aef11180..1d0a728e 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs @@ -244,7 +244,11 @@ namespace Kyoo.Abstractions.Models /// Metadata of what an user as started/planned to watch. /// [Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] - [LoadableRelation] public EpisodeWatchStatus? WatchStatus { get; set; } + [LoadableRelation( + Sql = "episode_watch_status", + On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]" + )] + public EpisodeWatchStatus? WatchStatus { get; set; } // There is a global query filter to filter by user so we just need to do single. private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs index 820f9470..057601b7 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs @@ -20,6 +20,7 @@ using System; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; +using Kyoo.Abstractions.Models.Attributes; using Newtonsoft.Json; namespace Kyoo.Abstractions.Models @@ -47,6 +48,7 @@ namespace Kyoo.Abstractions.Models } [TypeConverter(typeof(ImageConvertor))] + [SqlFirstColumn(nameof(Source))] public class Image { /// diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs b/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs index 14d9e035..819bc4fd 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs @@ -152,7 +152,11 @@ namespace Kyoo.Abstractions.Models /// Metadata of what an user as started/planned to watch. /// [Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] - [LoadableRelation] public MovieWatchStatus? WatchStatus { get; set; } + [LoadableRelation( + Sql = "movie_watch_status", + On = "movie_id = \"this\".id and \"relation\".user_id = [current_user]" + )] + public MovieWatchStatus? WatchStatus { get; set; } // There is a global query filter to filter by user so we just need to do single. private MovieWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Show.cs b/back/src/Kyoo.Abstractions/Models/Resources/Show.cs index 61906b4d..bb5311fd 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Show.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Show.cs @@ -193,7 +193,11 @@ namespace Kyoo.Abstractions.Models /// Metadata of what an user as started/planned to watch. /// [Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] - [LoadableRelation] public ShowWatchStatus? WatchStatus { get; set; } + [LoadableRelation( + Sql = "show_watch_status", + On = "show_id = \"this\".id and \"relation\".user_id = [current_user]" + )] + public ShowWatchStatus? WatchStatus { get; set; } // There is a global query filter to filter by user so we just need to do single. private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); diff --git a/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs b/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs index 36c4f6fc..ffb74638 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs @@ -53,6 +53,7 @@ namespace Kyoo.Abstractions.Models /// /// Metadata of what an user as started/planned to watch. /// + [SqlFirstColumn(nameof(UserId))] public class MovieWatchStatus : IAddedDate { /// @@ -105,6 +106,7 @@ namespace Kyoo.Abstractions.Models public int? WatchedPercent { get; set; } } + [SqlFirstColumn(nameof(UserId))] public class EpisodeWatchStatus : IAddedDate { /// @@ -157,6 +159,7 @@ namespace Kyoo.Abstractions.Models public int? WatchedPercent { get; set; } } + [SqlFirstColumn(nameof(UserId))] public class ShowWatchStatus : IAddedDate { /// diff --git a/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs b/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs index 72d11011..c6b1aaf6 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs @@ -28,15 +28,35 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Dapper; using InterpolatedSql.Dapper; +using InterpolatedSql.Dapper.SqlBuilders; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication; using Kyoo.Utils; +using Microsoft.AspNetCore.Http; namespace Kyoo.Core.Controllers; public static class DapperHelper { + public static SqlBuilder ProcessVariables(SqlBuilder sql, SqlVariableContext context) + { + int start = 0; + while ((start = sql.IndexOf("[", start, false)) != -1) + { + int end = sql.IndexOf("]", start, false); + if (end == -1) + throw new ArgumentException("Invalid sql variable substitue (missing ])"); + string var = sql.Format[(start + 1)..end]; + sql.Remove(start, end - start + 1); + sql.Insert(start, $"{context.ReadVar(var)}"); + } + + return sql; + } + public static string Property(string key, Dictionary config) { if (key == "kind") @@ -95,10 +115,12 @@ public static class DapperHelper string owner = config.First(x => x.Value == declaring).Key; string lateral = sql.Contains("\"this\"") ? " lateral" : string.Empty; sql = sql.Replace("\"this\"", owner); - on = on?.Replace("\"this\"", owner); + on = on?.Replace("\"this\"", owner)?.Replace("\"relation\"", $"r{relation}"); + if (sql.Any(char.IsWhiteSpace)) + sql = $"({sql})"; types.Add(type); projection.AppendLine($", r{relation}.*"); - join.Append($"\nleft join{lateral} ({sql}) as r{relation} on r{relation}.{on}"); + join.Append($"\nleft join{lateral} {sql} as r{relation} on r{relation}.{on}"); break; case Include.ProjectedRelation: continue; @@ -197,13 +219,14 @@ public static class DapperHelper Dictionary config, Func, T> mapper, Func> get, + SqlVariableContext context, Include? include, Filter? filter, Sort? sort, Pagination? limit) where T : class, IResource, IQuery { - InterpolatedSql.Dapper.SqlBuilders.SqlBuilder query = new(db, command); + SqlBuilder query = new(db, command); // Include handling include ??= new(); @@ -227,6 +250,8 @@ public static class DapperHelper if (limit != null) query += $"\nlimit {limit.Limit}"; + ProcessVariables(query, context); + // Build query and prepare to do the query/projections IDapperSqlCommand cmd = query.Build(); string sql = cmd.Sql; @@ -280,7 +305,7 @@ public static class DapperHelper return mapIncludes(mapper(nItems), nItems.Skip(config.Count)); }, ParametersDictionary.LoadFrom(cmd), - splitOn: string.Join(',', types.Select(x => x == typeof(Image) ? "source" : "id")) + splitOn: string.Join(',', types.Select(x => x.GetCustomAttribute()?.Name ?? "id")) ); if (limit?.Reverse == true) data = data.Reverse(); @@ -292,6 +317,7 @@ public static class DapperHelper FormattableString command, Dictionary config, Func, T> mapper, + SqlVariableContext context, Include? include, Filter? filter, Sort? sort = null, @@ -303,6 +329,7 @@ public static class DapperHelper config, mapper, get: null!, + context, include, filter, sort, @@ -315,6 +342,7 @@ public static class DapperHelper this IDbConnection db, FormattableString command, Dictionary config, + SqlVariableContext context, Filter? filter) where T : class, IResource { @@ -322,7 +350,7 @@ public static class DapperHelper if (filter != null) query += ProcessFilter(filter, config); - + ProcessVariables(query, context); IDapperSqlCommand cmd = query.Build(); // language=postgreSQL @@ -334,3 +362,22 @@ public static class DapperHelper ); } } + +public class SqlVariableContext +{ + private readonly IHttpContextAccessor _accessor; + + public SqlVariableContext(IHttpContextAccessor accessor) + { + _accessor = accessor; + } + + public object? ReadVar(string var) + { + return var switch + { + "current_user" => _accessor.HttpContext?.User.GetId(), + _ => throw new ArgumentException($"Invalid sql variable name: {var}") + }; + } +} diff --git a/back/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs index 27c30c11..7806fe65 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs @@ -25,7 +25,6 @@ using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Utils; -using static Kyoo.Core.Controllers.DapperHelper; namespace Kyoo.Core.Controllers; @@ -42,9 +41,13 @@ public abstract class DapperRepository : IRepository protected DbConnection Database { get; init; } - public DapperRepository(DbConnection database) + protected SqlVariableContext Context { get; init; } + + + public DapperRepository(DbConnection database, SqlVariableContext context) { Database = database; + Context = context; } /// @@ -83,6 +86,7 @@ public abstract class DapperRepository : IRepository Config, Mapper, (id) => Get(id), + Context, include, Filter.Or(ids.Select(x => new Filter.Eq("id", x)).ToArray()), sort: null, @@ -99,6 +103,7 @@ public abstract class DapperRepository : IRepository Sql, Config, Mapper, + Context, include, new Filter.Eq(nameof(IResource.Id), id) ); @@ -113,6 +118,7 @@ public abstract class DapperRepository : IRepository Sql, Config, Mapper, + Context, include, filter: null, new Sort.Random() @@ -122,6 +128,7 @@ public abstract class DapperRepository : IRepository Sql, Config, Mapper, + Context, include, new Filter.Eq(nameof(IResource.Slug), slug) ); @@ -137,6 +144,7 @@ public abstract class DapperRepository : IRepository Sql, Config, Mapper, + Context, include, filter, sortBy @@ -154,6 +162,7 @@ public abstract class DapperRepository : IRepository Config, Mapper, (id) => Get(id), + Context, include, filter, sort ?? new Sort.Default(), @@ -167,6 +176,7 @@ public abstract class DapperRepository : IRepository return Database.Count( Sql, Config, + Context, filter ); } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs index 0a438961..be6272c1 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs @@ -76,8 +76,8 @@ namespace Kyoo.Core.Controllers throw new InvalidDataException(); } - public LibraryItemRepository(DbConnection database) - : base(database) + public LibraryItemRepository(DbConnection database, SqlVariableContext context) + : base(database, context) { } public async Task> GetAllOfCollection( @@ -118,6 +118,7 @@ namespace Kyoo.Core.Controllers }, Mapper, (id) => Get(id), + Context, include, filter, sort ?? new Sort.Default(), diff --git a/back/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs index 7224d3ff..1ed9fad0 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs @@ -60,8 +60,8 @@ namespace Kyoo.Core.Controllers throw new InvalidDataException(); } - public NewsRepository(DbConnection database) - : base(database) + public NewsRepository(DbConnection database, SqlVariableContext context) + : base(database, context) { } } } diff --git a/back/src/Kyoo.Core/CoreModule.cs b/back/src/Kyoo.Core/CoreModule.cs index 71035c07..0339c6ac 100644 --- a/back/src/Kyoo.Core/CoreModule.cs +++ b/back/src/Kyoo.Core/CoreModule.cs @@ -68,6 +68,7 @@ namespace Kyoo.Core builder.RegisterRepository(); builder.RegisterRepository(); builder.RegisterType().As().AsSelf().InstancePerLifetimeScope(); + builder.RegisterType().InstancePerLifetimeScope(); } ///