diff --git a/QS.Cloud.Client/BasicAuthInfoProvider.cs b/QS.Cloud.Client/BasicAuthInfoProvider.cs
index fb0700d16..573a84194 100644
--- a/QS.Cloud.Client/BasicAuthInfoProvider.cs
+++ b/QS.Cloud.Client/BasicAuthInfoProvider.cs
@@ -9,7 +9,9 @@ public BasicAuthInfoProvider(string userName, string password)
public string UserName { get; }
- public string Password { get; }
+ public string Password { get; private set; }
+
+ public void UpdatePassword(string newPassword) => Password = newPassword;
}
public interface IBasicAuthInfoProvider
@@ -17,5 +19,7 @@ public interface IBasicAuthInfoProvider
string UserName { get; }
string Password { get; }
+
+ void UpdatePassword(string newPassword);
}
}
diff --git a/QS.Cloud.Client/Clients/Base/CloudClientByBasicAuth.cs b/QS.Cloud.Client/Clients/Base/CloudClientByBasicAuth.cs
index d7babc3d9..2d0a80cf2 100644
--- a/QS.Cloud.Client/Clients/Base/CloudClientByBasicAuth.cs
+++ b/QS.Cloud.Client/Clients/Base/CloudClientByBasicAuth.cs
@@ -4,19 +4,44 @@
namespace QS.Cloud.Client
{
- public class CloudClientByBasicAuth : CloudClientServiceBase
- {
+ public class CloudClientByBasicAuth : CloudClientServiceBase {
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
+ /// Столько ждём соединения в CanConnect - это проверка доступности, а не сама операция
+ private const int ConnectTimeoutSeconds = 5;
+
+ private readonly IBasicAuthInfoProvider authInfo;
+
public CloudClientByBasicAuth(IBasicAuthInfoProvider basicAuthInfoProvider, string serviceAddress, int servicePort)
- : base(serviceAddress, servicePort)
- {
- headers = new Metadata
- { {
- "Authorization",
- $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"{basicAuthInfoProvider.UserName}:{basicAuthInfoProvider.Password}"))}"
- } };
-
+ : base(serviceAddress, servicePort) {
+ authInfo = basicAuthInfoProvider ?? throw new ArgumentNullException(nameof(basicAuthInfoProvider));
+ headers = BuildHeaders();
}
- public override bool CanConnect => throw new NotImplementedException();
+ private Metadata BuildHeaders() => new Metadata
+ { {
+ "Authorization",
+ $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"{authInfo.UserName}:{authInfo.Password}"))}"
+ } };
+
+ public virtual void UpdatePassword(string newPassword) {
+ authInfo.UpdatePassword(newPassword);
+ headers = BuildHeaders();
+ }
+
+ public override bool CanConnect { get {
+ try {
+ Channel.ConnectAsync(DateTime.UtcNow.AddSeconds(ConnectTimeoutSeconds))
+ .GetAwaiter().GetResult();
+ return true;
+ }
+ catch(Exception ex) {
+ // Недоступное облако здесь - штатный ответ «нельзя», операцию не валим.
+ // Но причину молча терять нельзя: без неё непонятно, почему пропали кнопки.
+ logger.Debug(ex, "Нет соединения с облаком QS");
+ return false;
+ }
+ }
+ }
}
}
diff --git a/QS.Cloud.Client/Clients/Base/CloudClientServiceBase.cs b/QS.Cloud.Client/Clients/Base/CloudClientServiceBase.cs
index 7463f9b90..5b20c1ab3 100644
--- a/QS.Cloud.Client/Clients/Base/CloudClientServiceBase.cs
+++ b/QS.Cloud.Client/Clients/Base/CloudClientServiceBase.cs
@@ -5,6 +5,8 @@ namespace QS.Cloud.Client
{
public abstract class CloudClientServiceBase : IDisposable
{
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
private readonly string serviceAddress;
private readonly int servicePort;
private readonly ChannelCredentials credentials;
@@ -39,10 +41,20 @@ protected Channel Channel
public abstract bool CanConnect { get; }
- public virtual async void Dispose()
+ public virtual void Dispose()
{
- if(channel != null)
- await channel.ShutdownAsync();
+ var opened = channel;
+ channel = null;
+ if(opened == null)
+ return;
+
+ try {
+ opened.ShutdownAsync().GetAwaiter().GetResult();
+ }
+ catch(Exception ex) {
+ // исключение из Dispose подменяет собой настоящую причину, по которой закрывались.
+ logger.Debug(ex, "Не удалось закрыть канал к облаку QS");
+ }
}
}
}
diff --git a/QS.Cloud.Client/Clients/DatabaseManagementCloudClient.cs b/QS.Cloud.Client/Clients/DatabaseManagementCloudClient.cs
new file mode 100644
index 000000000..a53c8b1ff
--- /dev/null
+++ b/QS.Cloud.Client/Clients/DatabaseManagementCloudClient.cs
@@ -0,0 +1,38 @@
+using Grpc.Core;
+using QS.Cloud.Core;
+using QS.Project.Versioning;
+using System.Threading;
+
+namespace QS.Cloud.Client.Clients {
+ public class DatabaseManagementCloudClient : CloudClientByBasicAuth {
+ private readonly uint ProductCode;
+ public DatabaseManagementCloudClient(IBasicAuthInfoProvider basicAuthInfoProvider, uint productCode)
+ : base(basicAuthInfoProvider, "core.cloud.qsolution.ru", 443)
+ {
+ ProductCode = productCode;
+ }
+ public virtual ClearDatabaseResponse ClearDatabase(int baseId) {
+ var client = new DatabaseManagement.DatabaseManagementClient(Channel);
+ var request = new ClearDatabaseRequest { BaseId = baseId, ProductId = ProductCode };
+ return client.ClearDatabase(request, headers);
+ }
+
+ public virtual CheckDatabaseExistsResponse CheckDatabaseExists(string dbName) {
+ var client = new DatabaseManagement.DatabaseManagementClient(Channel);
+ var request = new CheckDatabaseExistsRequest { Name = dbName, ProductId = ProductCode };
+ return client.CheckDatabaseExists(request, headers);
+ }
+
+ public virtual CreateDatabaseResponse CreateDatabase(string dbName, string dbTitle) {
+ var client = new DatabaseManagement.DatabaseManagementClient(Channel);
+ var request = new CreateDatabaseRequest { Name = dbName, Title = dbTitle, ProductId = ProductCode };
+ return client.CreateDatabase(request, headers);
+ }
+
+ public virtual DropDatabaseResponse DropDatabase(int baseId) {
+ var client = new DatabaseManagement.DatabaseManagementClient(Channel);
+ var request = new DropDatabaseRequest { BaseId = baseId, ProductId = ProductCode };
+ return client.DropDatabase(request, headers);
+ }
+ }
+}
diff --git a/QS.Cloud.Client/Clients/LoginManagementCloudClient.cs b/QS.Cloud.Client/Clients/LoginManagementCloudClient.cs
index 28def483c..4bedbceaf 100644
--- a/QS.Cloud.Client/Clients/LoginManagementCloudClient.cs
+++ b/QS.Cloud.Client/Clients/LoginManagementCloudClient.cs
@@ -9,14 +9,14 @@ public class LoginManagementCloudClient : CloudClientByBasicAuth
public LoginManagementCloudClient(IBasicAuthInfoProvider basicAuthInfoProvider)
: base(basicAuthInfoProvider, "core.cloud.qsolution.ru", 443) { }
- public StartResponse Start(string launcherVersion) {
+ public virtual StartResponse Start(string launcherVersion) {
var client = new LoginManagement.LoginManagementClient(Channel);
var request = new StartRequest { LauncherVersion = launcherVersion };
var response = client.Start(request, headers);
return response;
}
- public StartSessionResponse StartSession(int baseId)
+ public virtual StartSessionResponse StartSession(int baseId)
{
var client = new LoginManagement.LoginManagementClient(Channel);
@@ -29,7 +29,7 @@ public StartSessionResponse StartSession(int baseId)
return response;
}
- public List GetBasesForUser(uint productId)
+ public virtual List GetBasesForUser(uint productId)
{
var client = new LoginManagement.LoginManagementClient(Channel);
var request = new GetBasesForUserRequest();
@@ -38,7 +38,7 @@ public List GetBasesForUser(uint productId)
return response.Bases.ToList();
}
- public ChangePasswordResponse ChangePassword(string newPassword) {
+ public virtual ChangePasswordResponse ChangePassword(string newPassword) {
var client = new LoginManagement.LoginManagementClient(Channel);
var request = new ChangePasswordRequest { NewPassword = newPassword };
var response = client.ChangePassword(request, headers);
diff --git a/QS.Cloud.Client/Clients/UserManagementCloudClient.cs b/QS.Cloud.Client/Clients/UserManagementCloudClient.cs
index 77ed41744..bad91a4a0 100644
--- a/QS.Cloud.Client/Clients/UserManagementCloudClient.cs
+++ b/QS.Cloud.Client/Clients/UserManagementCloudClient.cs
@@ -1,63 +1,81 @@
+using System.Collections.Generic;
+using System.Linq;
using QS.Cloud.Core;
namespace QS.Cloud.Client
{
- public class UserManagementCloudClient : CloudClientBySession
+ public class UserManagementCloudClient : CloudClientByBasicAuth
{
- public UserManagementCloudClient(ISessionInfoProvider sessionInfoProvider)
- : base(sessionInfoProvider, "core.cloud.qsolution.ru", 443) { }
+ public UserManagementCloudClient(IBasicAuthInfoProvider basicAuthInfoProvider)
+ : base(basicAuthInfoProvider, "core.cloud.qsolution.ru", 443) { }
-
- public CreateUserResponse CreateUser(string login, string userName, string email, string password)
+ public virtual List GetUsers()
{
var client = new UserManagement.UserManagementClient(Channel);
+ var response = client.GetUsers(new GetUsersRequest(), headers);
+ return response.Users.ToList();
+ }
- var request = new CreateUserRequest
- {
- Login = login, Name = userName, Email = email, Password = password
+ public virtual CreateUserResponse CreateUser(UserInfo user, string password)
+ {
+ var client = new UserManagement.UserManagementClient(Channel);
+ var request = new CreateUserRequest {
+ Login = user.Login,
+ Name = user.Name ?? "",
+ Email = user.Email ?? "",
+ Password = password ?? "",
+ Phone = user.Phone ?? "",
+ Post = user.Post ?? "",
+ Comment = user.Comment ?? "",
+ IsAccountAdmin = user.IsAccountAdmin
};
-
- var response = client.CreateUser(request, headers);
-
- return response;
+ return client.CreateUser(request, headers);
}
- public DeleteUserResponse DeleteUser(string login)
+ public virtual UpdateUserResponse UpdateUser(UserInfo user, string newPassword)
{
var client = new UserManagement.UserManagementClient(Channel);
+ var request = new UpdateUserRequest {
+ Login = user.Login,
+ Name = user.Name ?? "",
+ Email = user.Email ?? "",
+ Phone = user.Phone ?? "",
+ Post = user.Post ?? "",
+ Comment = user.Comment ?? "",
+ Disabled = user.Disabled,
+ IsAccountAdmin = user.IsAccountAdmin,
+ NewPassword = newPassword ?? ""
+ };
+ return client.UpdateUser(request, headers);
+ }
+ public virtual DeleteUserResponse DeleteUser(string login)
+ {
+ var client = new UserManagement.UserManagementClient(Channel);
var request = new DeleteUserRequest { User = login };
- var response = client.DeleteUser(request, headers);
-
- return response;
+ return client.DeleteUser(request, headers);
}
- // strange, but protobuf has the same signature
- public UpdateUserResponse UpdateUser()
+ public virtual List GetUserBaseAccess(string login, uint productId)
{
var client = new UserManagement.UserManagementClient(Channel);
-
- var request = new UpdateUserRequest();
- var response = client.UpdateUser(request, headers);
-
- return response;
+ var request = new GetUserBaseAccessRequest { User = login, ProductId = productId };
+ var response = client.GetUserBaseAccess(request, headers);
+ return response.Bases.ToList();
}
- public bool ChangeBaseAccess(string user, int baseId, bool grant, bool admin)
+ public virtual ChangeBaseAccessResponse ChangeBaseAccess(string user, int baseId, bool grant, bool admin, bool readOnly, uint productId)
{
var client = new UserManagement.UserManagementClient(Channel);
-
- var request = new ChangeBaseAccessRequest
- {
+ var request = new ChangeBaseAccessRequest {
User = user,
BaseId = baseId,
Grant = grant,
- Admin = admin
+ Admin = admin,
+ ReadOnly = readOnly,
+ ProductId = productId
};
-
- var response = client.ChangeBaseAccess(request);
-
- return response.Success;
+ return client.ChangeBaseAccess(request, headers);
}
}
}
diff --git a/QS.Cloud.Client/Database/CloudDbSession.cs b/QS.Cloud.Client/Database/CloudDbSession.cs
new file mode 100644
index 000000000..78f5224ee
--- /dev/null
+++ b/QS.Cloud.Client/Database/CloudDbSession.cs
@@ -0,0 +1,53 @@
+using System;
+using MySqlConnector;
+using QS.Cloud.Client.Clients;
+using QS.Cloud.Core;
+
+namespace QS.Cloud.Client.Database {
+ ///
+ /// Временное подключение к облачной базе
+ ///
+ public sealed class CloudDbSession : IDisposable {
+ private readonly AliveCloudClient sessionLife;
+
+ public bool Success { get; }
+ public string Description { get; }
+ public bool IsAdmin { get; }
+ public BaseConnection Db { get; }
+ public MySqlConnectionStringBuilder ConnectionStringBuilder { get; }
+
+ private CloudDbSession(StartSessionResponse session, AliveCloudClient sessionLife, MySqlConnectionStringBuilder connectionStringBuilder) {
+ Success = session.Success;
+ Description = session.Description;
+ IsAdmin = session.IsAdmin;
+ Db = session.Db;
+ this.sessionLife = sessionLife;
+ ConnectionStringBuilder = connectionStringBuilder;
+ }
+
+ ///
+ /// Открывает сессию к базе. При успехе запускает keep-alive и собирает строку подключения;
+ /// при отказе возвращает сессию с = false
+ ///
+ public static CloudDbSession Open(LoginManagementCloudClient loginClient, int baseId) {
+ var session = loginClient.StartSession(baseId);
+ if(!session.Success)
+ return new CloudDbSession(session, null, null);
+
+ var sessionLife = new AliveCloudClient(new SessionInfoProvider(session.SessionId));
+ sessionLife.KeepAlive();
+
+ var builder = new MySqlConnectionStringBuilder {
+ Server = session.Db.Server,
+ Port = session.Db.Port,
+ UserID = session.Db.Login,
+ Password = session.Db.Password,
+ Database = session.Db.BaseName,
+ AllowUserVariables = true
+ };
+ return new CloudDbSession(session, sessionLife, builder);
+ }
+
+ public void Dispose() => sessionLife?.Dispose();
+ }
+}
diff --git a/QS.Cloud.Client/Database/QSCloudProvider.cs b/QS.Cloud.Client/Database/QSCloudProvider.cs
new file mode 100644
index 000000000..194023ad3
--- /dev/null
+++ b/QS.Cloud.Client/Database/QSCloudProvider.cs
@@ -0,0 +1,377 @@
+using Grpc.Core;
+using MySqlConnector;
+using QS.Cloud.Client.Clients;
+using QS.Cloud.Core;
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.DBScripts.Controllers;
+using QS.Dialog;
+using QS.ErrorReporting;
+using QS.Project.Versioning;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+
+namespace QS.Cloud.Client.Database {
+ public class QSCloudProvider : IDbProvider {
+
+ public bool IsAdmin { get; protected set; }
+ public string Account { get; private set; }
+ public string UserName { get; private set; }
+ public byte ProductCode { get; private set; }
+
+ public bool CanCreateDatabase => dbClient.CanConnect && IsAdmin;
+ public bool CanDropDatabase => CanCreateDatabase;
+ public bool CanBackupDatabase => true;
+ private const string MessageTitle = "Создание базы в облаке";
+
+ private readonly LoginManagementCloudClient loginClient;
+ private readonly DatabaseManagementCloudClient dbClient;
+ private readonly UserManagementCloudClient userClient;
+
+ /// Клиентов создали мы сами - значит нам их и закрывать
+ private readonly bool ownsClients;
+
+ public QSCloudProvider(IList parameters, byte productCode, string password = null)
+ : this(parameters.First(p => p.Name == "Account").Value,
+ parameters.First(p => p.Name == "Login").Value,
+ productCode, password) { }
+ private QSCloudProvider(string account, string userName, byte productCode, string password)
+ : this(account, userName, productCode,
+ new BasicAuthInfoProvider($@"{account}\{userName}", password)) { }
+
+ private QSCloudProvider(string account, string userName, byte productCode, IBasicAuthInfoProvider authInfo)
+ : this(account, userName, productCode,
+ new LoginManagementCloudClient(authInfo),
+ new DatabaseManagementCloudClient(authInfo, productCode),
+ new UserManagementCloudClient(authInfo)) {
+ ownsClients = true;
+ }
+
+ ///
+ /// Нужен тестам
+ ///
+ public QSCloudProvider(string account, string userName, byte productCode,
+ LoginManagementCloudClient loginClient, DatabaseManagementCloudClient dbClient, UserManagementCloudClient userClient) {
+ Account = account;
+ UserName = userName;
+ ProductCode = productCode;
+
+ this.loginClient = loginClient ?? throw new ArgumentNullException(nameof(loginClient));
+ this.dbClient = dbClient ?? throw new ArgumentNullException(nameof(dbClient));
+ this.userClient = userClient ?? throw new ArgumentNullException(nameof(userClient));
+ }
+
+ #region Управление пользователями
+
+ public bool CanManageUsers => IsAdmin;
+ public bool CanManageBaseAccess => IsAdmin;
+
+ public DbUserFields SupportedUserFields =>
+ DbUserFields.Name | DbUserFields.Email | DbUserFields.Phone | DbUserFields.Post
+ | DbUserFields.Comment | DbUserFields.AdminFlag | DbUserFields.Disabling | DbUserFields.BaseReadOnly;
+
+ public bool CanChangeOwnPassword => true;
+
+ public bool ChangeOwnPassword(string newPassword) => Call(() =>
+ {
+ if(!loginClient.ChangePassword(newPassword).Success)
+ return false;
+
+ loginClient.UpdatePassword(newPassword);
+ dbClient.UpdatePassword(newPassword);
+ userClient.UpdatePassword(newPassword);
+ return true;
+ });
+
+ public List GetUsers() => Call(() =>
+ userClient.GetUsers().Select(ToDbUserInfo).ToList());
+
+ public bool CreateUser(DbUserInfo user, string password) => Call(() => {
+ var response = userClient.CreateUser(ToCloudUser(user), password);
+ return EnsureSuccess(response.Success, response.Message);
+ });
+
+ public bool UpdateUser(DbUserInfo user, string newPassword = null) => Call(() => {
+ var response = userClient.UpdateUser(ToCloudUser(user), newPassword);
+ return EnsureSuccess(response.Success, response.Message);
+ });
+
+ public bool DeleteUser(string login) =>
+ Call(() => {
+ var response = userClient.DeleteUser(login);
+ return EnsureSuccess(response.Success, response.Message);
+ });
+
+ public List GetUserBaseAccess(string login) => Call(() =>
+ userClient.GetUserBaseAccess(login, ProductCode)
+ .Select(b => new DbUserBaseAccess {
+ BaseId = b.BaseId,
+ Title = b.BaseTitle,
+ HasAccess = b.HasAccess,
+ IsAdmin = b.Admin,
+ ReadOnly = b.ReadOnly
+ }).ToList());
+
+ public bool SetUserBaseAccess(string login, DbUserBaseAccess access) =>
+ Call(() => {
+ var response = userClient.ChangeBaseAccess(
+ login, access.BaseId, access.HasAccess, access.IsAdmin, access.ReadOnly, ProductCode);
+ return EnsureSuccess(response.Success, string.IsNullOrEmpty(response.Message)
+ ? "Не удалось изменить доступ к базе"
+ : response.Message);
+ });
+
+ private static T Call(Func operation) {
+ try {
+ return operation();
+ }
+ catch(RpcException ex) {
+ throw CloudError(ex);
+ }
+ }
+
+ private static bool EnsureSuccess(bool success, string message) {
+ if(!success)
+ throw new OperationRefusedException(message);
+ return true;
+ }
+
+ private DbUserInfo ToDbUserInfo(UserInfo user) => new DbUserInfo {
+ Login = user.Login,
+ Name = user.Name,
+ Email = user.Email,
+ Phone = user.Phone,
+ Post = user.Post,
+ Comment = user.Comment,
+ Disabled = user.Disabled,
+ IsAdmin = user.IsAccountAdmin,
+ IsCurrentUser = string.Equals(user.Login, UserName, StringComparison.OrdinalIgnoreCase)
+ };
+
+ private static UserInfo ToCloudUser(DbUserInfo user) => new UserInfo {
+ Login = user.Login ?? "",
+ Name = user.Name ?? "",
+ Email = user.Email ?? "",
+ Phone = user.Phone ?? "",
+ Post = user.Post ?? "",
+ Comment = user.Comment ?? "",
+ Disabled = user.Disabled,
+ IsAccountAdmin = user.IsAdmin
+ };
+
+ #endregion
+
+ #region Управление базами
+
+ public bool CanRefreshMetadata => false;
+
+ public RefreshMetadataResponse RefreshMetadata() => throw NoMetadataSync();
+
+ public RefreshMetadataResponse RefreshBases() => throw NoMetadataSync();
+
+ public RefreshMetadataResponse RefreshUsers() => throw NoMetadataSync();
+
+ private static Exception NoMetadataSync() =>
+ new InvalidOperationException("Облако ведёт список баз само, синхронизация метаинформации ему не нужна.");
+
+ public bool CreateDatabase(DbCreationRequest request) {
+ if(request == null)
+ throw new ArgumentNullException(nameof(request));
+ try {
+ int? baseId = PrepareEmptyDatabase(request);
+ if(baseId == null)
+ return false;
+
+ using(var session = CloudDbSession.Open(loginClient, baseId.Value)) {
+ if(!session.Success) {
+ request.Interaction.ReportError("Не удалось открыть сессию к созданной базе: " + session.Description, MessageTitle);
+ return false;
+ }
+ if(!session.IsAdmin) {
+ request.Interaction.ReportError("Вы не имеете прав администратора для наполнения базы", MessageTitle);
+ return false;
+ }
+
+ request.CreationResources.ConnectionString = session.ConnectionStringBuilder.ConnectionString;
+ request.CreationResources.JustCreated = true;
+ var creationModel = request.CreationFactory.Create(request.CreationResources);
+ return creationModel.RunCreation(session.Db.BaseName, request.DbTitle);
+ }
+ }
+ catch(RpcException ex) {
+ throw CloudError(ex);
+ }
+ }
+
+ private int? PrepareEmptyDatabase(DbCreationRequest request) {
+ var existing = dbClient.CheckDatabaseExists(request.DbName);
+ if(!existing.Exists)
+ return dbClient.CreateDatabase(request.DbName, request.DbTitle).BaseId;
+
+ switch(request.Interaction.AskDropExistingDatabase(request.DbName)) {
+ case ToDoWithExistingDatabase.Recreate:
+ if(!dbClient.DropDatabase(existing.BaseId).Success) {
+ request.Interaction.ReportError("Не удалось удалить существующую базу: " + existing.BaseId, MessageTitle);
+ return null;
+ }
+ return dbClient.CreateDatabase(request.DbName, request.DbTitle).BaseId;
+
+ case ToDoWithExistingDatabase.Rewrite:
+ // облако пересоздаст пустую базу, сохранив записи реестра и права доступа
+ if(!dbClient.ClearDatabase(existing.BaseId).Success) {
+ request.Interaction.ReportError("Не удалось очистить существующую базу: " + existing.BaseId, MessageTitle);
+ return null;
+ }
+ return existing.BaseId;
+
+ default: // Nothing
+ return null;
+ }
+ }
+
+ private bool disposed;
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if(disposed)
+ return;
+
+ // Клиентов, пришедших снаружи, освобождает тот, кто их создал.
+ // Свои закрываем сами - за ними каналы gRPC
+ if(disposing && ownsClients) {
+ loginClient.Dispose();
+ dbClient.Dispose();
+ userClient.Dispose();
+ }
+
+ disposed = true;
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ public bool DropDatabase(DbInfo database) => Call(() =>
+ EnsureSuccess(dbClient.DropDatabase(database.BaseId).Success, "Облако не удалило базу"));
+
+ public void BackupDatabase(DbInfo database, string filePath, IProgressBarDisplayable progress, CancellationToken cancellation) {
+ try {
+ using(var session = CloudDbSession.Open(loginClient, database.BaseId)) {
+ if(!session.Success)
+ throw new InvalidOperationException("Не удалось открыть сессию к облачной базе: " + session.Description);
+ new MariaDbExportService().Export(session.ConnectionStringBuilder, session.Db.BaseName, filePath, progress, cancellation);
+ }
+ }
+ catch(RpcException ex) {
+ throw CloudError(ex);
+ }
+ }
+
+ public List GetUserDatabases() => Call(() =>
+ loginClient.GetBasesForUser(ProductCode).Select(bi => new DbInfo {
+ Title = bi.BaseTitle,
+ BaseId = bi.BaseId,
+ BaseName = bi.BaseName,
+ Version = bi.BaseVersion
+ }).ToList());
+
+ public LoginToDatabaseResponse LoginToDatabase(DbInfo dbInfo) {
+ LoginToDatabaseResponse resp;
+
+ try {
+ var cloudResponse = loginClient.StartSession(dbInfo.BaseId);
+ if(!cloudResponse.Success || cloudResponse.Db == null)
+ return new LoginToDatabaseResponse {
+ Success = false,
+ ErrorMessage = string.IsNullOrEmpty(cloudResponse.Description)
+ ? "Облако не открыло сессию к базе"
+ : cloudResponse.Description
+ };
+
+ var builder = new MySqlConnectionStringBuilder {
+ Server = cloudResponse.Db.Server,
+ Port = cloudResponse.Db.Port,
+ UserID = cloudResponse.Db.Login,
+ Password = cloudResponse.Db.Password,
+ Database = cloudResponse.Db.BaseName
+ };
+ resp = new LoginToDatabaseResponse {
+ Success = cloudResponse.Success,
+ ConnectionString = builder.ConnectionString,
+ Login = UserName,
+ Parameters = new Dictionary(StringComparer.Ordinal) {
+ {"SessionId", cloudResponse.SessionId},
+ {"BaseTitle", dbInfo.Title}
+ }
+ };
+ }
+ // ErrorMessage уходит прямо в окно пользователю, а ex.Message у RpcException - это
+ // строка вида Status(StatusCode="PermissionDenied", Detail="Нет доступа к базе"):
+ // человеку читать в ней нечего. Берём Detail, как и во всех остальных вызовах
+ catch(RpcException ex) {
+ resp = new LoginToDatabaseResponse {
+ Success = false,
+ ErrorMessage = Describe(ex)
+ };
+ }
+ catch(Exception ex) {
+ resp = new LoginToDatabaseResponse {
+ Success = false,
+ ErrorMessage = ex.Message
+ };
+ }
+
+ return resp;
+ }
+
+ public LoginToServerResponse LoginToServer() {
+ // вход - ожидаемая точка отказа, поэтому не исключение, а Response с текстом для пользователя
+ try {
+ var cloudResponce = loginClient.Start(Assembly.GetExecutingAssembly().GetName().Version.ToString());
+
+ // права вызывающего читаются со свойств провайдера, в ответе их не дублируем
+ IsAdmin = cloudResponce.YouAccountAdmin;
+ return new LoginToServerResponse {
+ Success = true,
+ NeedToUpdateLauncher = cloudResponce.NeedUpdateLauncher
+ };
+ }
+ catch(RpcException ex) when(ex.StatusCode == StatusCode.Unauthenticated || ex.StatusCode == StatusCode.PermissionDenied) {
+ return new LoginToServerResponse {
+ Success = false,
+ ErrorMessage = "Неверные данные для входа: " + Describe(ex)
+ };
+ }
+ catch(RpcException ex) {
+ return new LoginToServerResponse {
+ Success = false,
+ ErrorMessage = "Не удалось подключиться к облаку QS: " + Describe(ex)
+ };
+ }
+ }
+
+ private static string Describe(RpcException ex) =>
+ string.IsNullOrEmpty(ex.Status.Detail) ? ex.Message : ex.Status.Detail;
+
+ private static Exception CloudError(RpcException ex) {
+ string message = Describe(ex);
+ switch(ex.StatusCode) {
+ case StatusCode.Unauthenticated:
+ case StatusCode.PermissionDenied:
+ return new UnauthorizedAccessException(message, ex);
+ case StatusCode.NotFound:
+ case StatusCode.AlreadyExists:
+ case StatusCode.InvalidArgument:
+ return new OperationRefusedException(message, ex);
+ default:
+ return new InvalidOperationException(message, ex);
+ }
+ }
+ #endregion
+ }
+}
diff --git a/QS.Cloud.Client/QsCloudConnectionTypeBase.cs b/QS.Cloud.Client/Database/QsCloudConnectionTypeBase.cs
similarity index 78%
rename from QS.Cloud.Client/QsCloudConnectionTypeBase.cs
rename to QS.Cloud.Client/Database/QsCloudConnectionTypeBase.cs
index 504c04fe5..8ba77eebb 100644
--- a/QS.Cloud.Client/QsCloudConnectionTypeBase.cs
+++ b/QS.Cloud.Client/Database/QsCloudConnectionTypeBase.cs
@@ -1,18 +1,20 @@
-using QS.DbManagement;
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.Utilities.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
-using QS.Utilities.Extensions;
-namespace QS.Cloud.Client {
+namespace QS.Cloud.Client.Database {
public class QsCloudConnectionTypeBase : ConnectionTypeBase {
+
public QsCloudConnectionTypeBase() {
Title = "QS: Облако";
ConnectionTypeName = "QSCloud";
-
+
Parameters.Add(new ConnectionParameter("Account","Организация"));
Parameters.Add(new ConnectionParameter("Login","Пользователь"));
-
+
IconBytes = Assembly.GetExecutingAssembly().GetResourceByteArray("QS.Cloud.Client.Icons.qscloud.ico");
}
@@ -21,7 +23,8 @@ public override bool CanConnect(IEnumerable parameters
parameters.Any(p => p.Name == "Login" && !string.IsNullOrEmpty(p.Value));
}
- public override IDbProvider CreateProvider(IList parameters, string password = null)
- => new QSCloudProvider(parameters, password);
+ public override IDbProvider CreateProvider(IList parameters, byte productCode, string password = null) {
+ return new QSCloudProvider(parameters, productCode, password);
+ }
}
}
diff --git a/QS.Cloud.Client/Protos/DatabaseManagement.proto b/QS.Cloud.Client/Protos/DatabaseManagement.proto
new file mode 100644
index 000000000..3119fea6f
--- /dev/null
+++ b/QS.Cloud.Client/Protos/DatabaseManagement.proto
@@ -0,0 +1,51 @@
+syntax = "proto3";
+
+package QS.Cloud.Core;
+
+service DatabaseManagement{
+ rpc CheckDatabaseExists (CheckDatabaseExistsRequest) returns (CheckDatabaseExistsResponse);
+ rpc ClearDatabase (ClearDatabaseRequest) returns (ClearDatabaseResponse);
+ // Создать пустую базу
+ rpc CreateDatabase (CreateDatabaseRequest) returns (CreateDatabaseResponse);
+ // Удалить базу и очистить реестр
+ rpc DropDatabase (DropDatabaseRequest) returns (DropDatabaseResponse);
+}
+
+message ClearDatabaseRequest{
+ int32 base_id = 1;
+ uint32 product_id = 2;
+}
+
+message ClearDatabaseResponse{
+ bool success = 1;
+}
+
+message CheckDatabaseExistsRequest{
+ string name = 1;
+ uint32 product_id = 2;
+}
+
+message CheckDatabaseExistsResponse{
+ bool exists = 1;
+ optional int32 base_id = 2;
+}
+
+message CreateDatabaseRequest{
+ string name = 1;
+ string title = 2;
+ uint32 product_id = 3;
+}
+
+message CreateDatabaseResponse{
+ int32 base_id = 1;
+ string base_guid = 2;
+}
+
+message DropDatabaseRequest{
+ int32 base_id = 1;
+ uint32 product_id = 2;
+}
+
+message DropDatabaseResponse{
+ bool success = 1;
+}
diff --git a/QS.Cloud.Client/Protos/LoginManagement.proto b/QS.Cloud.Client/Protos/LoginManagement.proto
index 1dd64025d..c7005db71 100644
--- a/QS.Cloud.Client/Protos/LoginManagement.proto
+++ b/QS.Cloud.Client/Protos/LoginManagement.proto
@@ -53,6 +53,7 @@ message BaseInfo{
int32 base_id = 1;
string base_title = 2;
string base_version = 3;
+ string base_name = 4;
}
//ChangePassword - смена собственного пароля от учетной записи
diff --git a/QS.Cloud.Client/Protos/UserManagement.proto b/QS.Cloud.Client/Protos/UserManagement.proto
index 3d002dcaf..7498341e8 100644
--- a/QS.Cloud.Client/Protos/UserManagement.proto
+++ b/QS.Cloud.Client/Protos/UserManagement.proto
@@ -2,23 +2,56 @@ syntax = "proto3";
package QS.Cloud.Core;
service UserManagement{
+ rpc GetUsers (GetUsersRequest) returns (GetUsersResponse);
rpc CreateUser (CreateUserRequest) returns (CreateUserResponse);
rpc UpdateUser (UpdateUserRequest) returns (UpdateUserResponse);
rpc DeleteUser (DeleteUserRequest) returns (DeleteUserResponse);
+ rpc GetUserBaseAccess (GetUserBaseAccessRequest) returns (GetUserBaseAccessResponse);
rpc ChangeBaseAccess (ChangeBaseAccessRequest) returns (ChangeBaseAccessResponse);
}
+message UserInfo{
+ string login = 1;
+ string name = 2;
+ string email = 3;
+ string phone = 4;
+ string post = 5;
+ string comment = 6;
+ bool disabled = 7;
+ bool is_account_admin = 8;
+}
+
+message GetUsersRequest{
+}
+message GetUsersResponse{
+ repeated UserInfo users = 1;
+}
+
message CreateUserRequest{
string login = 1;
string name = 2;
string email = 3;
string password = 4;
+ string phone = 5;
+ string post = 6;
+ string comment = 7;
+ bool is_account_admin = 8;
}
message CreateUserResponse{
bool success = 1;
string message = 2;
}
+
message UpdateUserRequest{
+ string login = 1;
+ string name = 2;
+ string email = 3;
+ string phone = 4;
+ string post = 5;
+ string comment = 6;
+ bool disabled = 7;
+ bool is_account_admin = 8;
+ string new_password = 9;
}
message UpdateUserResponse{
bool success = 1;
@@ -28,18 +61,33 @@ message UpdateUserResponse{
message DeleteUserRequest{
string user = 1;
}
-
message DeleteUserResponse {
bool success = 1;
string message = 2;
}
+message GetUserBaseAccessRequest{
+ string user = 1;
+ uint32 product_id = 2;
+}
+message BaseAccessInfo{
+ int32 base_id = 1;
+ string base_title = 2;
+ bool has_access = 3;
+ bool admin = 4;
+ bool read_only = 5;
+}
+message GetUserBaseAccessResponse{
+ repeated BaseAccessInfo bases = 1;
+}
+
message ChangeBaseAccessRequest{
string user = 1;
int32 base_id = 2;
bool grant = 3;
bool admin = 4;
-
+ bool read_only = 5;
+ uint32 product_id = 6;
}
message ChangeBaseAccessResponse {
bool success = 1;
diff --git a/QS.Cloud.Client/QS.Cloud.Client.csproj b/QS.Cloud.Client/QS.Cloud.Client.csproj
index ec2e96649..00dc4c656 100644
--- a/QS.Cloud.Client/QS.Cloud.Client.csproj
+++ b/QS.Cloud.Client/QS.Cloud.Client.csproj
@@ -18,13 +18,16 @@
+
+
+
diff --git a/QS.Cloud.Client/QSCloudProvider.cs b/QS.Cloud.Client/QSCloudProvider.cs
deleted file mode 100644
index c6e33dd8e..000000000
--- a/QS.Cloud.Client/QSCloudProvider.cs
+++ /dev/null
@@ -1,133 +0,0 @@
-using Grpc.Core;
-using MySqlConnector;
-using QS.Cloud.Core;
-using QS.DbManagement.Responces;
-using QS.DbManagement;
-using QS.Project.Versioning;
-using System.Collections.Generic;
-using System.Linq;
-using System.Reflection;
-using System;
-
-namespace QS.Cloud.Client
-{
- public class QSCloudProvider : IDbProvider {
- public string ConnectionString { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
-
- public bool IsConnected => throw new NotImplementedException();
-
- public bool IsAdmin { get; protected set; }
-
- #region Параметры подключени
- public string Account { get; private set; }
-
-
- #endregion
- public string UserName { get; private set; }
-
- private CloudFeaturesClient featuresClient;
- private LoginManagementCloudClient loginClient;
- private SessionManagementCloudClient sessionClient;
- private UserManagementCloudClient userClient;
-
-
- public QSCloudProvider(IList parameters, string password = null) {
- Account = parameters.First(p => p.Name == "Account").Value;
- UserName = parameters.First(p => p.Name == "Login").Value;
- BasicAuthInfoProvider authInfo = new BasicAuthInfoProvider($@"{Account}\{UserName}", password);
-
- loginClient = new LoginManagementCloudClient(authInfo);
- }
-
- public bool AddUser(string username, string password)
- {
- throw new NotImplementedException();
- }
-
- public bool ChangePassword(string username, string oldPassword, string newPassword)
- {
- throw new NotImplementedException();
- }
-
- public bool CreateDatabase(string databaseName)
- {
- throw new NotImplementedException();
- }
-
- public void Dispose()
- {
- loginClient.Dispose();
- }
-
- public bool DropDatabase(string databaseName)
- {
- throw new NotImplementedException();
- }
-
- public List GetUserDatabases(IApplicationInfo applicationInfo) {
- return loginClient.GetBasesForUser(applicationInfo.ProductCode).Select(bi => new DbInfo
- {
- Title = bi.BaseTitle,
- BaseId = bi.BaseId,
- Version = bi.BaseVersion
- }).ToList();
- }
-
- public LoginToDatabaseResponse LoginToDatabase(DbInfo dbInfo) {
- LoginToDatabaseResponse resp;
-
- try {
- var cloudResponse = loginClient.StartSession(dbInfo.BaseId);
- var builder = new MySqlConnectionStringBuilder {
- Server = cloudResponse.Db.Server,
- Port = cloudResponse.Db.Port,
- UserID = cloudResponse.Db.Login,
- Password = cloudResponse.Db.Password,
- Database = cloudResponse.Db.BaseName
- };
- resp = new LoginToDatabaseResponse {
- Success = cloudResponse.Success,
- ConnectionString = builder.ConnectionString,
- Login = UserName,
- Parameters = new Dictionary() {
- {"SessionId", cloudResponse.SessionId},
- {"BaseTitle", dbInfo.Title}
- }
- };
- }
- catch(Exception ex) {
- resp = new LoginToDatabaseResponse {
- Success = false,
- ErrorMessage = ex.Message
- };
- }
-
- return resp;
- }
-
- public LoginToServerResponse LoginToServer() {
- LoginToServerResponse resp;
-
- StartResponse cloudResponce;
- try {
- cloudResponce = loginClient.Start(Assembly.GetExecutingAssembly().GetName().Version.ToString());
-
- resp = new LoginToServerResponse {
- Success = true,
- IsAdmin = cloudResponce.YouAccountAdmin,
- NeedToUpdateLauncher = cloudResponce.NeedUpdateLauncher
- };
- }
- catch(RpcException ex) when(ex.StatusCode == Grpc.Core.StatusCode.Unauthenticated || ex.StatusCode == Grpc.Core.StatusCode.PermissionDenied) {
- resp = new LoginToServerResponse {
- Success = false,
- ErrorMessage = "Неверные данные для входа: " + ex.Message
- };
- }
-
- return resp;
- }
- }
-}
-
-
diff --git a/QS.DbManagement/Connection.cs b/QS.DbManagement/Connection.cs
index 581921fea..3b15565b6 100644
--- a/QS.DbManagement/Connection.cs
+++ b/QS.DbManagement/Connection.cs
@@ -1,7 +1,9 @@
+using QS.DbManagement.Entities;
+using QS.Project.Versioning;
+using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Linq;
-using ReactiveUI;
namespace QS.DbManagement {
public class Connection : ReactiveObject, ICloneable {
@@ -62,7 +64,7 @@ public Connection(Connection other) {
public bool CanConnect() => ConnectionType.CanConnect(CustomParameters);
- public IDbProvider CreateProvider(string password) => ConnectionType.CreateProvider(CustomParameters, password);
+ public IDbProvider CreateProvider(string password, byte productCode) => ConnectionType.CreateProvider(CustomParameters, productCode, password);
public Dictionary GetConfigDefinitions() {
var config = new Dictionary {
{"Type", ConnectionType.ConnectionTypeName},
diff --git a/QS.DbManagement/ConnectionTypeBase.cs b/QS.DbManagement/ConnectionTypeBase.cs
index 377ecad56..b902efa39 100644
--- a/QS.DbManagement/ConnectionTypeBase.cs
+++ b/QS.DbManagement/ConnectionTypeBase.cs
@@ -1,8 +1,11 @@
-using System;
+using QS.DbManagement.Entities;
using System.Collections.Generic;
namespace QS.DbManagement {
+ ///
+ /// метаданные, проверка параметров и создание провайдера
+ ///
public abstract class ConnectionTypeBase {
public string Title { get; protected set; }
public string ConnectionTypeName { get; protected set; }
@@ -10,9 +13,9 @@ public abstract class ConnectionTypeBase {
public List Parameters { get; } = new List();
public byte[] IconBytes { get; protected set; }
-
+
public abstract bool CanConnect(IEnumerable parameters);
- public abstract IDbProvider CreateProvider(IList parameters, string password = null);
+ public abstract IDbProvider CreateProvider(IList parameters, byte productCode, string password = null);
}
}
diff --git a/QS.DbManagement/Creation/DbCreationFactory.cs b/QS.DbManagement/Creation/DbCreationFactory.cs
new file mode 100644
index 000000000..044d4e29d
--- /dev/null
+++ b/QS.DbManagement/Creation/DbCreationFactory.cs
@@ -0,0 +1,24 @@
+using QS.DBScripts.Controllers;
+using System;
+using System.Reflection;
+
+namespace QS.DbManagement.Creation {
+ public class DbCreationFactory
+ {
+ private readonly DbResourcesCreationMap map;
+
+ public DbCreationFactory(DbResourcesCreationMap map) {
+ this.map = map ?? throw new ArgumentNullException(nameof(map));
+ }
+
+ public IDbCreatorModel Create(DbCreationResources resources)
+ {
+ try {
+ return (IDbCreatorModel)map.Resolve(resources);
+ }
+ catch(TargetInvocationException ex) when(ex.InnerException != null) {
+ throw ex.InnerException;
+ }
+ }
+ }
+}
diff --git a/QS.DbManagement/Creation/DbDumpResources.cs b/QS.DbManagement/Creation/DbDumpResources.cs
new file mode 100644
index 000000000..5e5e4128f
--- /dev/null
+++ b/QS.DbManagement/Creation/DbDumpResources.cs
@@ -0,0 +1,8 @@
+using QS.DBScripts.Controllers;
+using QS.Dialog;
+
+namespace QS.DbManagement.Creation {
+ public class DbDumpResources : DbCreationResources {
+ public string DumpFilePath { get; set; }
+ }
+}
diff --git a/QS.DbManagement/Creation/DbResourcesCreationMap.cs b/QS.DbManagement/Creation/DbResourcesCreationMap.cs
new file mode 100644
index 000000000..f2c7c14d4
--- /dev/null
+++ b/QS.DbManagement/Creation/DbResourcesCreationMap.cs
@@ -0,0 +1,34 @@
+using QS.DBScripts.Controllers;
+using System;
+using System.Collections.Generic;
+
+namespace QS.DbManagement.Creation {
+ public class DbResourcesCreationMap
+ {
+ private Dictionary> _map = new Dictionary>();
+
+ public bool Contains(Type target) {
+ return _map.ContainsKey(target);
+ }
+
+ public void Register(Type resource, Type creator)
+ {
+ if(!typeof(DbCreationResources).IsAssignableFrom(resource))
+ throw new ArgumentException($"{resource} не наследует DbCreationResources", nameof(resource));
+
+ if(!typeof(IDbCreatorModel).IsAssignableFrom(creator))
+ throw new ArgumentException($"{creator} не реализует IDbCreatorModel", nameof(creator));
+
+ _map[resource] = arg => Activator.CreateInstance(creator, arg);
+ }
+
+ public object Resolve(DbCreationResources arg)
+ {
+ if(arg == null)
+ throw new ArgumentNullException(nameof(arg));
+ if(!_map.TryGetValue(arg.GetType(), out var creator))
+ throw new InvalidOperationException($"Нет зарегистрированного создателя для ресурса {arg.GetType().Name}");
+ return creator(arg);
+ }
+ }
+}
diff --git a/QS.DbManagement/Creation/MariaDbImportModel.cs b/QS.DbManagement/Creation/MariaDbImportModel.cs
new file mode 100644
index 000000000..9325e6d05
--- /dev/null
+++ b/QS.DbManagement/Creation/MariaDbImportModel.cs
@@ -0,0 +1,43 @@
+using MySqlConnector;
+using QS.DBScripts.Models;
+
+namespace QS.DbManagement.Creation {
+ ///
+ /// Наполнение MariaDB базы пользовательским дампом.
+ /// Метод блокирует вызывающий поток — выносить в фон ответственность вызывающего кода.
+ ///
+ public class MariaDbImportModel : BaseMySqlDbLoader {
+ private string DumpFilePath { get; }
+
+ public MariaDbImportModel(
+ DbDumpResources resources)
+ : base(resources) {
+ DumpFilePath = resources.DumpFilePath;
+ }
+
+ protected override void ExecutScript(MySqlCommand cmd) {
+ cmd.CommandTimeout = 0;
+ using(var backup = new MySqlBackup(cmd)) {
+ // ускоряет дамп
+ backup.Command.CommandText = "SET SESSION foreign_key_checks = 0, unique_checks = 0;";
+ backup.Command.ExecuteNonQuery();
+
+ bool started = false;
+ backup.ImportProgressChanged += (sender, args) => {
+ if(cancellationToken.IsCancellationRequested) {
+ ((MySqlBackup)sender).StopAllProcess();
+ return;
+ }
+ if(!started) {
+
+ logger.Debug("Предполагаем наличие {0} команд в скрипте.", args.TotalBytes);
+ progress?.Start(maxValue: args.TotalBytes, text: "Импорт дампа в базу данных");
+ started = true;
+ }
+ progress?.Update(args.CurrentBytes);
+ };
+ backup.ImportFromFile(DumpFilePath);
+ }
+ }
+ }
+}
diff --git a/QS.DbManagement/DbCapabilities.cs b/QS.DbManagement/DbCapabilities.cs
new file mode 100644
index 000000000..97bb3b167
--- /dev/null
+++ b/QS.DbManagement/DbCapabilities.cs
@@ -0,0 +1,38 @@
+using QS.DbManagement.Creation;
+using QS.DBScripts;
+using QS.DBScripts.Models;
+
+namespace QS.DbManagement {
+ ///
+ /// комбинирует права пользователя с конфигурацией приложения
+ ///
+ public class DbCapabilities {
+ private readonly IDbScriptsConfiguration scripts;
+ private readonly DbResourcesCreationMap creationMap;
+ public DbCapabilities(IDbScriptsConfiguration scripts = null, DbResourcesCreationMap creationMap = null) {
+ this.scripts = scripts;
+ this.creationMap = creationMap;
+ }
+
+ /// Провайдер отвечает за то, что разрешает сервер
+ public DbCapabilitySet For(IDbProvider provider) {
+ if(provider == null)
+ return DbCapabilitySet.None;
+
+ return new DbCapabilitySet {
+ CanCreate = provider.CanCreateDatabase
+ && scripts?.HasCreationScript() == true
+ && creationMap != null
+ && creationMap.Contains(typeof(EmbeddedCreationResources)),
+ CanImport = provider.CanCreateDatabase
+ && creationMap != null
+ && creationMap.Contains(typeof(DbDumpResources)),
+ CanDrop = provider.CanDropDatabase,
+ CanBackup = provider.CanBackupDatabase,
+ CanRefreshMetadata = provider.CanRefreshMetadata,
+ CanChangeOwnPassword = provider.CanChangeOwnPassword,
+ CanManageUsers = provider.CanManageUsers
+ };
+ }
+ }
+}
diff --git a/QS.DbManagement/DbCapabilitySet.cs b/QS.DbManagement/DbCapabilitySet.cs
new file mode 100644
index 000000000..8980f7cdf
--- /dev/null
+++ b/QS.DbManagement/DbCapabilitySet.cs
@@ -0,0 +1,30 @@
+namespace QS.DbManagement {
+ /// собирается один раз на смену провайдера и дальше не меняется
+ public class DbCapabilitySet {
+ /// Ничего не доступно - состояние до входа на сервер
+ public static readonly DbCapabilitySet None = new DbCapabilitySet();
+
+ /// Создание базы из встроенного скрипта
+ public bool CanCreate { get; internal set; }
+
+ /// Наполнение новой базы дампом
+ public bool CanImport { get; internal set; }
+
+ public bool CanDrop { get; internal set; }
+
+ public bool CanBackup { get; internal set; }
+
+ /// Пересборка локальной метаинформации по реальному состоянию сервера
+ public bool CanRefreshMetadata { get; internal set; }
+
+ public bool CanChangeOwnPassword { get; internal set; }
+
+ public bool CanManageUsers { get; internal set; }
+
+ /// Есть ли что показать в меню строки списка баз
+ public bool CanManageDatabases => CanDrop || CanBackup;
+
+ /// Есть ли что показать в меню операций с базами
+ public bool CanOpenDbOperations => CanCreate || CanImport || CanRefreshMetadata;
+ }
+}
diff --git a/QS.DbManagement/Entities/BaseUserRow.cs b/QS.DbManagement/Entities/BaseUserRow.cs
new file mode 100644
index 000000000..03dd56d57
--- /dev/null
+++ b/QS.DbManagement/Entities/BaseUserRow.cs
@@ -0,0 +1,9 @@
+namespace QS.DbManagement.Entities {
+ internal class BaseUserRow {
+ public string Login { get; set; }
+ public string Name { get; set; }
+ public string Email { get; set; }
+ public bool Admin { get; set; }
+ public bool Deactivated { get; set; }
+ }
+}
diff --git a/QS.DbManagement/ConnectionParameter.cs b/QS.DbManagement/Entities/ConnectionParameter.cs
similarity index 85%
rename from QS.DbManagement/ConnectionParameter.cs
rename to QS.DbManagement/Entities/ConnectionParameter.cs
index ddbaaa231..4498b2f8d 100644
--- a/QS.DbManagement/ConnectionParameter.cs
+++ b/QS.DbManagement/Entities/ConnectionParameter.cs
@@ -1,4 +1,4 @@
-namespace QS.DbManagement
+namespace QS.DbManagement.Entities
{
public class ConnectionParameter
{
diff --git a/QS.DbManagement/ConnectionParameterValue.cs b/QS.DbManagement/Entities/ConnectionParameterValue.cs
similarity index 94%
rename from QS.DbManagement/ConnectionParameterValue.cs
rename to QS.DbManagement/Entities/ConnectionParameterValue.cs
index 18a24b8f6..11bf825f3 100644
--- a/QS.DbManagement/ConnectionParameterValue.cs
+++ b/QS.DbManagement/Entities/ConnectionParameterValue.cs
@@ -1,7 +1,7 @@
using System;
using ReactiveUI;
-namespace QS.DbManagement {
+namespace QS.DbManagement.Entities {
public class ConnectionParameterValue : ReactiveObject {
private readonly ConnectionParameter parameter;
diff --git a/QS.DbManagement/Entities/DbCreationPhase.cs b/QS.DbManagement/Entities/DbCreationPhase.cs
new file mode 100644
index 000000000..f7e35be5c
--- /dev/null
+++ b/QS.DbManagement/Entities/DbCreationPhase.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace QS.DbManagement.Entities {
+ ///
+ /// Один шаг пайплайна длительной операции с базами - создания, импорта, синхронизации
+ ///
+ public sealed class DbCreationPhase {
+ public string Title { get; }
+ public Func Action { get; }
+
+ public DbCreationPhase(string title, Func action) {
+ Title = title ?? throw new ArgumentNullException(nameof(title));
+ Action = action ?? throw new ArgumentNullException(nameof(action));
+ }
+ }
+}
diff --git a/QS.DbManagement/Entities/DbCreationRequest.cs b/QS.DbManagement/Entities/DbCreationRequest.cs
new file mode 100644
index 000000000..7fc98db69
--- /dev/null
+++ b/QS.DbManagement/Entities/DbCreationRequest.cs
@@ -0,0 +1,16 @@
+using QS.DbManagement.Creation;
+using QS.DBScripts.Controllers;
+
+namespace QS.DbManagement.Entities {
+ public sealed class DbCreationRequest {
+ public string DbName { get; set; }
+ public string DbTitle { get; set; }
+
+ public IDbCreatorInteraction Interaction { get; set; }
+
+ /// Чем наполнять созданную базу
+ public DbCreationFactory CreationFactory { get; set; }
+
+ public DbCreationResources CreationResources { get; set; }
+ }
+}
diff --git a/QS.DbManagement/DbInfo.cs b/QS.DbManagement/Entities/DbInfo.cs
similarity index 82%
rename from QS.DbManagement/DbInfo.cs
rename to QS.DbManagement/Entities/DbInfo.cs
index 3fd58f347..d9e54c7ad 100644
--- a/QS.DbManagement/DbInfo.cs
+++ b/QS.DbManagement/Entities/DbInfo.cs
@@ -1,4 +1,4 @@
-namespace QS.DbManagement {
+namespace QS.DbManagement.Entities {
public class DbInfo {
public string Title { get; set; }
public string BaseName { get; set; }
diff --git a/QS.DbManagement/Entities/DbPhaseArgs.cs b/QS.DbManagement/Entities/DbPhaseArgs.cs
new file mode 100644
index 000000000..4471fdf55
--- /dev/null
+++ b/QS.DbManagement/Entities/DbPhaseArgs.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Threading;
+using QS.Dialog;
+
+namespace QS.DbManagement.Entities {
+ ///
+ /// Контекст выполнения одной фазы операциии
+ ///
+ public class DbPhaseArgs {
+ public IDbManager Provider { get; set; }
+ public IProgressBarDisplayable Progress { get; set; }
+ public CancellationToken CancellationToken { get; set; }
+ public IServiceProvider ServiceProvider { get; set; }
+ public string FailureReason { get; set; }
+ }
+}
diff --git a/QS.DbManagement/Entities/DbUserBaseAccess.cs b/QS.DbManagement/Entities/DbUserBaseAccess.cs
new file mode 100644
index 000000000..5c6d21c63
--- /dev/null
+++ b/QS.DbManagement/Entities/DbUserBaseAccess.cs
@@ -0,0 +1,20 @@
+namespace QS.DbManagement.Entities {
+ public class DbUserBaseAccess {
+ /// Идентификатор базы в облаке
+ public int BaseId { get; set; }
+
+ /// Имя базы на сервере
+ public string BaseName { get; set; }
+
+ public string Title { get; set; }
+
+ public bool HasAccess { get; set; }
+
+ public bool IsAdmin { get; set; }
+
+ public bool ReadOnly { get; set; }
+ public bool CanEdit { get; set; } = true;
+ public string Name { get; set; }
+ public string Email { get; set; }
+ }
+}
diff --git a/QS.DbManagement/Entities/DbUserInfo.cs b/QS.DbManagement/Entities/DbUserInfo.cs
new file mode 100644
index 000000000..91790d52f
--- /dev/null
+++ b/QS.DbManagement/Entities/DbUserInfo.cs
@@ -0,0 +1,33 @@
+using System;
+
+namespace QS.DbManagement.Entities {
+ public class DbUserInfo {
+ public string Login { get; set; }
+ public string Name { get; set; }
+ public string Email { get; set; }
+ public string Phone { get; set; }
+ public string Post { get; set; }
+ public string Comment { get; set; }
+
+ /// не может входить
+ public bool Disabled { get; set; }
+
+ /// может управлять другими пользователями
+ public bool IsAdmin { get; set; }
+ /// текущий пользователь подключения
+ public bool IsCurrentUser { get; set; }
+ }
+
+ [Flags]
+ public enum DbUserFields {
+ None = 0,
+ Name = 1,
+ Email = 2,
+ Phone = 4,
+ Post = 8,
+ Comment = 16,
+ AdminFlag = 32,
+ Disabling = 64,
+ BaseReadOnly = 128
+ }
+}
diff --git a/QS.DbManagement/Entities/LauncherUserInfo.cs b/QS.DbManagement/Entities/LauncherUserInfo.cs
new file mode 100644
index 000000000..fa7ad24c9
--- /dev/null
+++ b/QS.DbManagement/Entities/LauncherUserInfo.cs
@@ -0,0 +1,12 @@
+namespace QS.DbManagement.Entities {
+ internal class LauncherUserInfo {
+ public int Id { get; set; }
+ public string Login { get; set; }
+ public string Name { get; set; }
+ public string Email { get; set; }
+ public string Phone { get; set; }
+
+ public bool IsAdmin { get; set; }
+ public bool Disabled { get; set; }
+ }
+}
diff --git a/QS.DbManagement/ProviderResponces.cs b/QS.DbManagement/Entities/ProviderResponces.cs
similarity index 68%
rename from QS.DbManagement/ProviderResponces.cs
rename to QS.DbManagement/Entities/ProviderResponces.cs
index d354ac0cd..235ee34dd 100644
--- a/QS.DbManagement/ProviderResponces.cs
+++ b/QS.DbManagement/Entities/ProviderResponces.cs
@@ -1,24 +1,19 @@
using System.Collections.Generic;
-namespace QS.DbManagement.Responces
+namespace QS.DbManagement.Entities
{
public class Response {
public bool Success { get; set; }
public string ErrorMessage { get; set; }
}
- public class LoginToServerResponse : Response {
- public bool CanCreateDatabase { get; set; }
- public bool IsAdmin { get; set; }
+ public class LoginToServerResponse : Response
+ {
public bool NeedToUpdateLauncher { get; set; }
}
public class ChangePasswordResponse : Response { }
- public class CreateDatabaseResponse : Response { }
-
- public class DropDatabaseResponse : Response { }
-
public class AddUserResponse : Response { }
public class DeleteUserResponse : Response { }
@@ -27,6 +22,12 @@ public class GetUserDatabasesResponse : Response {
public List Bases { get; set; }
}
+ /// счётчики синхронизированных
+ public class RefreshMetadataResponse : Response {
+ public int SyncedBases { get; set; }
+ public int SyncedUsers { get; set; }
+ }
+
public class LoginToDatabaseResponse : Response {
public string ConnectionString { get; set; }
public string Login { get; set; }
diff --git a/QS.DbManagement/IDbProvider.cs b/QS.DbManagement/IDbProvider.cs
index da6ea6964..30b822821 100644
--- a/QS.DbManagement/IDbProvider.cs
+++ b/QS.DbManagement/IDbProvider.cs
@@ -1,30 +1,84 @@
-using QS.DbManagement.Responces;
+using QS.Dialog;
using QS.Project.Versioning;
using System.Collections.Generic;
+using System.Threading;
using System;
+using QS.DbManagement.Entities;
namespace QS.DbManagement
{
- public interface IDbProvider : IDisposable
+ public interface IDbManager
{
string UserName { get; }
-
- bool ChangePassword(string username, string oldPassword, string newPassword);
-
- bool CreateDatabase(string databaseName);
-
- bool DropDatabase(string databaseName);
-
- bool AddUser(string username, string password);
+
+ bool IsAdmin { get; }
+ bool CanCreateDatabase { get; }
+ bool CanDropDatabase { get; }
+
+ /// умеет ли провайдер выгружать базу в файл
+ bool CanBackupDatabase { get; }
+
+ bool CanRefreshMetadata { get; }
LoginToServerResponse LoginToServer();
- List GetUserDatabases(IApplicationInfo applicationInfo);
+ RefreshMetadataResponse RefreshMetadata();
+
+ RefreshMetadataResponse RefreshBases();
+
+ RefreshMetadataResponse RefreshUsers();
+
+ List GetUserDatabases();
LoginToDatabaseResponse LoginToDatabase(DbInfo dbInfo);
-
- bool IsConnected { get; }
- bool IsAdmin { get; }
+ ///
+ /// Создаёт базу и сразу наполняет её
+ ///
+ bool CreateDatabase(DbCreationRequest request);
+
+ bool DropDatabase(DbInfo database);
+
+ void BackupDatabase(DbInfo database, string filePath, IProgressBarDisplayable progress, CancellationToken cancellation);
+ }
+
+ ///
+ /// Управление пользователями сервера и собственным паролем.
+ ///
+ public interface IDbUserManager
+ {
+ bool ChangeOwnPassword(string newPassword);
+
+ /// может ли пользователь сменить себе пароль средствами провайдера
+ bool CanChangeOwnPassword { get; }
+
+ bool CanManageUsers { get; }
+
+ bool CanManageBaseAccess { get; }
+
+ ///
+ /// Какие поля пользователя и виды доступа поддерживает провайдер
+ ///
+ DbUserFields SupportedUserFields { get; }
+
+ List GetUsers();
+
+ bool CreateUser(DbUserInfo user, string password);
+
+ bool UpdateUser(DbUserInfo user, string newPassword = null);
+
+ bool DeleteUser(string login);
+
+ ///
+ /// Список баз продукта с текущим доступом указанного пользователя
+ ///
+ List GetUserBaseAccess(string login);
+
+ ///
+ /// меняет доступ пользователя к базе согласно флагам
+ ///
+ bool SetUserBaseAccess(string login, DbUserBaseAccess access);
}
+
+ public interface IDbProvider : IDbManager, IDbUserManager, IDisposable {}
}
diff --git a/QS.DbManagement/MariaDb/BaseParametersReader.cs b/QS.DbManagement/MariaDb/BaseParametersReader.cs
new file mode 100644
index 000000000..459f1a55b
--- /dev/null
+++ b/QS.DbManagement/MariaDb/BaseParametersReader.cs
@@ -0,0 +1,93 @@
+using Dapper;
+using NLog;
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Data.Common;
+using System.Linq;
+
+namespace QS.DbManagement.MariaDb {
+ internal static class BaseParametersReader {
+ private static readonly Logger logger = LogManager.GetCurrentClassLogger();
+
+ private const string ParametersTable = "base_parameters";
+ private const string BaseNameColumn = "BaseName";
+ private const string Projection = "`name` AS Name, `str_value` AS StrValue";
+ private const int DefaultBatchSize = 50;
+
+ ///
+ /// Параметры перечисленных баз. Базы без таблицы base_parameters и базы, которые прочитать не удалось, в результат не попадают
+ ///
+ /// какие параметры нужны, null - все
+ /// base_name -> (name -> str_value)
+ public static Dictionary> ReadMany(
+ IDbConnection connection, IEnumerable databases,
+ IEnumerable names = null, int batchSize = DefaultBatchSize) {
+ if(connection == null)
+ throw new ArgumentNullException(nameof(connection));
+ if(batchSize < 1)
+ throw new ArgumentOutOfRangeException(nameof(batchSize), "Размер пачки должен быть положительным");
+
+ var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ var wanted = MySqlMultiBase.Distinct(databases);
+ if(wanted.Count == 0)
+ return result;
+
+ var nameFilter = names?.ToList();
+ var withTable = MySqlMultiBase.DatabasesWithTable(connection, wanted, ParametersTable);
+
+ for(int offset = 0; offset < withTable.Count; offset += batchSize) {
+ var batch = withTable.Skip(offset).Take(batchSize).ToList();
+ try {
+ ReadBatch(connection, batch, nameFilter, result);
+ }
+ catch(DbException ex) {
+ logger.Warn(ex, "Пакетное чтение {0} не удалось, читаем базы по одной", ParametersTable);
+ foreach(var database in batch)
+ ReadOne(connection, database, nameFilter, result);
+ }
+ }
+
+ return result;
+ }
+
+ private static void ReadBatch(IDbConnection connection, IEnumerable databases,
+ IList names, IDictionary> result) {
+ var parameters = new DynamicParameters();
+ var projections = databases.Select(db => new KeyValuePair(db, Projection));
+ string union = MySqlMultiBase.UnionAll(projections, ParametersTable, BaseNameColumn, null, parameters);
+
+ // имена фильтруем снаружи, одним условием на весь запрос, а не по разу в каждой ветке
+ string sql = $"SELECT * FROM ({union}) p";
+ if(names != null && names.Count > 0) {
+ sql += " WHERE p.Name IN @names";
+ parameters.Add("names", names);
+ }
+
+ foreach(var row in connection.Query(sql, parameters)) {
+ if(!result.TryGetValue(row.BaseName, out var byName)) {
+ byName = new Dictionary();
+ result[row.BaseName] = byName;
+ }
+ byName[row.Name] = row.StrValue;
+ }
+ }
+
+ private static void ReadOne(IDbConnection connection, string database,
+ IList names, IDictionary> result) {
+ try {
+ ReadBatch(connection, new[] { database }, names, result);
+ }
+ catch(DbException ex) {
+ logger.Debug(ex, "Не удалось прочитать {0} в базе {1}", ParametersTable, database);
+ }
+ }
+
+ /// Строка base_parameters вместе с базой, из которой она приехала
+ private sealed class ParameterRow {
+ public string BaseName { get; set; }
+ public string Name { get; set; }
+ public string StrValue { get; set; }
+ }
+ }
+}
diff --git a/QS.DbManagement/MariaDb/MariaDBProvider.cs b/QS.DbManagement/MariaDb/MariaDBProvider.cs
index b0343a132..3302c885d 100644
--- a/QS.DbManagement/MariaDb/MariaDBProvider.cs
+++ b/QS.DbManagement/MariaDb/MariaDBProvider.cs
@@ -1,90 +1,288 @@
using Dapper;
using MySqlConnector;
-using QS.DbManagement.Responces;
-using QS.Project.Versioning;
+using QS.DbManagement.Entities;
+using QS.DbManagement.MariaDb;
+using QS.DbManagement.MariaDb.QSLauncher;
+using QS.DBScripts.Controllers;
+using QS.Dialog;
+using QS.ErrorReporting;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
+using System.Threading;
-namespace QS.DbManagement
-{
+namespace QS.DbManagement {
public class MariaDBProvider : IDbProvider {
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
- private static readonly string[] SystemDatabases = { "information_schema", "mysql", "performance_schema", "sys" };
+ private const string MessageTitle = "Создание базы данных";
+ private const string AnyHost = "%";
+ private const string LocalHost = "localhost";
+ private const int ER_NONEXISTING_GRANT = 1141;
- readonly MySqlConnection connection;
- readonly MySqlConnectionStringBuilder connectionStringBuilder;
+ private MySqlConnection connection;
- public bool IsConnected => connection.State == ConnectionState.Open;
+ ///
+ /// Публичный - в типе подключения нужен доступ, реализацию он знает и так
+ ///
+ public MySqlConnectionStringBuilder ConnectionStringBuilder { get; }
public bool IsAdmin { get; private set; }
- ///
- /// Есть ли у текущего пользователя право создавать базы данных.
- /// Определяется в момент из SHOW GRANTS.
- ///
public bool CanCreateDatabase { get; private set; }
+ public bool CanDropDatabase { get; private set; }
+ public bool CanManageBaseAccess { get; private set; }
#region Параметры подключения
public string Server { get; }
public string UserName { get; }
- private readonly string password;
+ public byte ProductCode { get; }
#endregion
- public MariaDBProvider(IList parameters, string password = null) {
+ public MariaDBProvider(IList parameters, byte productCode, string password = null) {
if(parameters == null)
throw new ArgumentNullException(nameof(parameters));
- string serverValue = parameters.First(p => p.Name == "Server").Value;
+ Server = parameters.First(p => p.Name == "Server").Value;
UserName = parameters.First(p => p.Name == "Login").Value;
- this.password = password;
+ ProductCode = productCode;
- string host = serverValue;
- uint? port = null;
- if(serverValue.Contains(":")) {
- var parts = serverValue.Split(':');
- host = parts[0];
- if(uint.TryParse(parts[1], out var parsedPort))
- port = parsedPort;
- }
- Server = serverValue;
-
- connectionStringBuilder = new MySqlConnectionStringBuilder {
- Server = host,
+ var address = Server.Split(':');
+ ConnectionStringBuilder = new MySqlConnectionStringBuilder {
+ Server = address[0],
UserID = UserName,
Password = password,
AllowUserVariables = true
};
- if(port != null)
- connectionStringBuilder.Port = port.Value;
- connection = new MySqlConnection(connectionStringBuilder.ConnectionString);
+ if(address.Length > 1 && uint.TryParse(address[1], out var port))
+ ConnectionStringBuilder.Port = port;
+
+ connection = new MySqlConnection(ConnectionStringBuilder.ConnectionString);
+ }
+
+ #region QSLauncher
+
+ public bool CanRefreshMetadata => IsAdmin;
+ public bool CanBackupDatabase { get; } = true;
+
+ private LauncherMetadataManagement metadata;
+ /// false - собрать метабазу ещё не пробовали; true - пробовали, и повторять не будем
+ private bool metadataMade;
+ private bool serverLoggedIn;
+
+ private LauncherMetadataManagement Metadata
+ {
+ get
+ {
+ // до логина права ещё не посчитаны - собранная сейчас метабаза запомнила бы их заниженными
+ if(!serverLoggedIn)
+ return null;
+
+ // один раз не собралась - больше не пробуем
+ if(metadataMade)
+ return metadata;
+
+ try
+ {
+ metadata = new LauncherMetadataManagement(
+ new MySqlConnectionStringBuilder(ConnectionStringBuilder.ConnectionString),
+ IsAdmin, UserName, ProductCode);
+ }
+ catch(Exception ex) when(
+ ex is MySqlException //базы нет, нет прав, сервер недоступен
+ || ex is InvalidOperationException|| ex is KeyNotFoundException //схема не та, что можно читать
+ || ex is ArgumentException
+ )
+ {
+ logger.Debug(ex, "QSLauncher база недоступна, используем прямой доступ к серверу");
+ }
+ metadataMade = true;
+ return metadata;
+ }
+ }
+
+ private T FromMetadataOrDirect(Func fromMetadata, Func direct)
+ {
+ var launcher = Metadata;
+ if(launcher != null)
+ {
+ try
+ {
+ return fromMetadata(launcher);
+ }
+ catch(Exception ex)
+ {
+ logger.Debug(ex, "Не удалось прочитать из метабазы, работаем напрямую с сервером.");
+ }
+ }
+ return direct();
+ }
+
+ // Синхронизация таблицы users без привязки к базе
+ private BaseUsersManagement baseUsers;
+ private BaseUsersManagement BaseUsers
+ => baseUsers ?? (baseUsers = new BaseUsersManagement(new MySqlConnectionStringBuilder(ConnectionStringBuilder.ConnectionString)));
+
+ public RefreshMetadataResponse RefreshMetadata()
+ {
+ var bases = RefreshBases();
+ if(!bases.Success)
+ return bases;
+
+ var users = RefreshUsers();
+ if(!users.Success)
+ return users;
+
+ return new RefreshMetadataResponse
+ {
+ Success = true,
+ SyncedBases = bases.SyncedBases,
+ SyncedUsers = users.SyncedUsers
+ };
}
- #region IDbProvider
+ public RefreshMetadataResponse RefreshBases()
+ {
+ var launcher = Metadata;
+ if(launcher == null)
+ return MetadataUnavailable();
+
+ return new RefreshMetadataResponse
+ {
+ Success = true,
+ SyncedBases = launcher.Bases.SyncBases()
+ };
+ }
+
+ public RefreshMetadataResponse RefreshUsers()
+ {
+ var launcher = Metadata;
+ if(launcher == null)
+ return MetadataUnavailable();
+
+ var users = GetUsersDirect().ConvertAll(ToLauncherUser);
+ return new RefreshMetadataResponse
+ {
+ Success = true,
+ SyncedUsers = launcher.Users.SyncUsers(users)
+ };
+ }
+
+ private static RefreshMetadataResponse MetadataUnavailable() =>
+ new RefreshMetadataResponse
+ {
+ Success = false,
+ ErrorMessage = $"База {LauncherMetadataManagement.LauncherBaseName} недоступна, "
+ + "синхронизировать метаинформацию некуда."
+ };
+
+ private void RegisterInLauncherMetadata(DbCreationRequest request)
+ {
+ try
+ {
+ Metadata?.CreateBaseWithCreatorAccess(new DbInfo {
+ Title = request.DbTitle,
+ BaseName = request.DbName,
+ Version = ReadBaseVersion(request.DbName)
+ });
+ }
+ catch(Exception ex)
+ {
+ logger.Warn(ex, "Не удалось зарегистрировать базу {0} в метабазе QSLauncher.", request.DbName);
+ }
+ }
+
+ ///
+ /// Версию база сообщает о себе сама: её кладёт в base_parameters дамп, которым базу наполнили.
+ /// null - параметра в базе нет, каталог обновится ближайшей синхронизацией
+ ///
+ private string ReadBaseVersion(string dbName) {
+ var parameters = OnConnection(c =>
+ BaseParametersReader.ReadMany(c, new[] { dbName }, new[] { VersionParameter }));
+
+ return parameters.TryGetValue(dbName, out var byName)
+ && byName.TryGetValue(VersionParameter, out var version)
+ ? version
+ : null;
+ }
+
+ #region Отображение сущностей метабазы в публичные
+
+ private DbUserInfo ToDbUserInfo(LauncherUserInfo u) => new DbUserInfo
+ {
+ Login = u.Login,
+ Name = u.Name,
+ Email = u.Email,
+ Phone = u.Phone,
+ Disabled = u.Disabled,
+ IsAdmin = u.IsAdmin,
+ IsCurrentUser = string.Equals(u.Login, UserName, StringComparison.OrdinalIgnoreCase)
+ };
+
+ private static LauncherUserInfo ToLauncherUser(DbUserInfo u) => new LauncherUserInfo
+ {
+ Login = u.Login,
+ Name = u.Name,
+ Email = u.Email,
+ Phone = u.Phone,
+ Disabled = u.Disabled,
+ IsAdmin = u.IsAdmin
+ };
+
+ #endregion
+
+ private void ReflectInMetadata(Action action, string operation, string subject)
+ {
+ var launcher = Metadata;
+ if(launcher == null)
+ return;
+ try
+ {
+ action(launcher);
+ }
+ catch(Exception ex)
+ {
+ logger.Warn(ex, "Не удалось отразить {0} ({1}) в метабазе.", operation, subject);
+ }
+ }
+
+ private void ReflectUserUpdateInMetadata(DbUserInfo user)
+ {
+ ReflectInMetadata(m => {
+ var target = m.Users.GetUserByLogin(user.Login);
+ if(target == null)
+ return;
+ var launcherUser = ToLauncherUser(user);
+ launcherUser.Id = target.Id;
+ m.Users.UpdateUser(launcherUser);
+ }, "обновление", user.Login);
+ }
+
+ #endregion
public LoginToServerResponse LoginToServer() {
try {
- if(connection.State != ConnectionState.Open)
- connection.Open();
+ var grants = OnConnection(c => c.Query("SHOW GRANTS FOR CURRENT_USER").ToList());
+
+ IsAdmin = MySqlGrants.HasGlobalAdmin(grants);
+ CanManageBaseAccess = MySqlGrants.HasGlobalGrantOption(grants);
- var grants = connection.Query("SHOW GRANTS FOR CURRENT_USER").ToList();
+ var privileges = new HashSet(grants
+ .Where(g => MySqlGrants.Scope(g) == "*")
+ .SelectMany(MySqlGrants.Privileges), StringComparer.Ordinal);
- IsAdmin = grants.Any(g =>
- g.IndexOf("ALL PRIVILEGES ON *.*", StringComparison.OrdinalIgnoreCase) >= 0
- || g.IndexOf("GRANT OPTION", StringComparison.OrdinalIgnoreCase) >= 0
- || g.IndexOf("SUPER", StringComparison.OrdinalIgnoreCase) >= 0);
+ CanCreateDatabase = IsAdmin || HasPrivilege(privileges, "CREATE");
+ CanDropDatabase = IsAdmin || HasPrivilege(privileges, "DROP");
- CanCreateDatabase = IsAdmin || grants.Any(g =>
- g.IndexOf("ALL PRIVILEGES", StringComparison.OrdinalIgnoreCase) >= 0
- || g.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) >= 0);
+ // права пересчитаны - метабазу, если её уже собирали, пересоберём с новыми
+ metadata = null;
+ metadataMade = false;
+ serverLoggedIn = true;
return new LoginToServerResponse {
Success = true,
- IsAdmin = IsAdmin,
- NeedToUpdateLauncher = false,
- CanCreateDatabase = CanCreateDatabase
+ NeedToUpdateLauncher = false
};
}
catch(MySqlException ex) {
@@ -96,59 +294,61 @@ public LoginToServerResponse LoginToServer() {
}
}
- public List GetUserDatabases(IApplicationInfo applicationInfo) {
- var result = new List();
+ private static bool HasPrivilege(ICollection privileges, string privilege)
+ => privileges.Contains(MySqlAccess.AllPrivileges) || privileges.Contains(privilege);
- if(connection.State != ConnectionState.Open)
- connection.Open();
+ #region Управление базами
- var databases = connection.Query("SHOW DATABASES").ToList();
- byte expectedProductCode = applicationInfo.ProductCode;
-
- foreach(var dbName in databases.Except(SystemDatabases, StringComparer.OrdinalIgnoreCase)) {
- byte? productCode = null;
- string version = null;
- string title = null;
- try {
- var rows = connection.Query<(string name, string str_value)>(
- $"SELECT name, str_value FROM `{dbName}`.base_parameters WHERE name IN ('ProductCode', 'version', 'BaseTitle')").ToList();
- foreach(var row in rows) {
- if(string.Equals(row.name, "ProductCode", StringComparison.OrdinalIgnoreCase))
- productCode = Convert.ToByte(row.str_value);
- else if(string.Equals(row.name, "version", StringComparison.OrdinalIgnoreCase))
- version = row.str_value;
- else if(string.Equals(row.name, "BaseTitle", StringComparison.OrdinalIgnoreCase))
- title = row.str_value;
- }
- }
- catch(MySqlException ex) {
- logger.Debug(ex, "Не удалось прочитать base_parameters в базе {0}", dbName);
- continue;
- }
+ public List GetUserDatabases() {
+ return FromMetadataOrDirect(
+ metadata => metadata.Bases.GetBases().ToList(),
+ () => GetUserDatabasesDirect());
+ }
- if((expectedProductCode != null)
- && (productCode != expectedProductCode))
- continue;
+ private const string VersionParameter = "version";
+ private static readonly string[] DbInfoParameters = { "ProductCode", "BaseTitle", VersionParameter };
- result.Add(new DbInfo {
- BaseName = dbName,
- Title = title ?? dbName,
- Version = version
- });
- }
+ private List GetUserDatabasesDirect() {
+ var names = OnConnection(c => c.Query("SHOW DATABASES").ToList())
+ .Except(MySqlSystemObjects.Databases, StringComparer.OrdinalIgnoreCase)
+ .ToList();
- return result;
+ // одним запросом по серверному соединению: подключение к каждой базе - это ещё один пул
+ var parameters = OnConnection(c => BaseParametersReader.ReadMany(c, names, DbInfoParameters));
+
+ return names
+ .Select(dbName => ToDbInfo(dbName, parameters))
+ .Where(db => db != null)
+ .ToList();
+ }
+
+ /// null - база не от нашего продукта либо её параметры прочитать не вышло
+ private DbInfo ToDbInfo(string dbName, IReadOnlyDictionary> byDatabase) {
+ if(!byDatabase.TryGetValue(dbName, out var parameters))
+ return null;
+
+ if(!parameters.TryGetValue("ProductCode", out var code) || !byte.TryParse(code, out var baseProduct)
+ || baseProduct != ProductCode)
+ return null;
+
+ return new DbInfo {
+ BaseName = dbName,
+ Title = parameters.TryGetValue("BaseTitle", out var title) ? title : dbName,
+ Version = parameters.TryGetValue(VersionParameter, out var version) ? version : null
+ };
}
public LoginToDatabaseResponse LoginToDatabase(DbInfo dbInfo) {
try {
- connectionStringBuilder.Database = dbInfo.BaseName;
+ var toDatabase = new MySqlConnectionStringBuilder(ConnectionStringBuilder.ConnectionString) {
+ Database = dbInfo.BaseName
+ };
return new LoginToDatabaseResponse {
Success = true,
- ConnectionString = connectionStringBuilder.ConnectionString,
+ ConnectionString = toDatabase.ConnectionString,
Login = UserName,
- Parameters = new Dictionary {
+ Parameters = new Dictionary(StringComparer.Ordinal) {
{ "BaseTitle", dbInfo.Title }
}
};
@@ -161,29 +361,501 @@ public LoginToDatabaseResponse LoginToDatabase(DbInfo dbInfo) {
}
}
- public bool AddUser(string username, string password) {
- string sql = $"CREATE USER IF NOT EXISTS '{username}' IDENTIFIED BY '{password}'";
- return connection.Execute(sql) != 0;
+ public bool CreateDatabase(DbCreationRequest request) {
+ if(request == null)
+ throw new ArgumentNullException(nameof(request));
+
+ if(!PrepareEmptyDatabase(request))
+ return false;
+
+ var connectionStringBuilder = new MySqlConnectionStringBuilder(ConnectionStringBuilder.ConnectionString) {
+ Database = request.DbName
+ };
+ request.CreationResources.ConnectionString = connectionStringBuilder.ConnectionString;
+ request.CreationResources.JustCreated = true;
+ var creationModel = request.CreationFactory.Create(request.CreationResources); //? users теперь не ответственность модели наполнения
+ if(!creationModel.RunCreation(request.DbName, request.DbTitle))
+ return false;
+
+ RegisterInLauncherMetadata(request);
+ return true;
}
- public bool ChangePassword(string username, string oldPassword, string newPassword) {
- string sql = $"ALTER USER '{username}'@'%' IDENTIFIED BY '{newPassword}'";
- return connection.Execute(sql) != 0;
+ ///
+ /// Готовит пустую базу под наполнение
+ /// false - пользователь отказался что-либо делать с существующей базой
+ ///
+ private bool PrepareEmptyDatabase(DbCreationRequest request) {
+ if(!DoesDatabaseExist(request.DbName)) {
+ CreateEmptyDatabase(request.DbName);
+ return true;
+ }
+
+ switch(request.Interaction.AskDropExistingDatabase(request.DbName)) {
+ case ToDoWithExistingDatabase.Recreate:
+ if(!DropDatabase(new DbInfo { BaseName = request.DbName })) {
+ request.Interaction.ReportError("Не удалось удалить существующую базу: " + request.DbName, MessageTitle);
+ return false;
+ }
+ break;
+ case ToDoWithExistingDatabase.Rewrite:
+ // базу сносимp
+ // записи метабазы и выданные на базу права должны пережить перезапись
+ OnConnection(c => c.Execute($"DROP DATABASE IF EXISTS `{MySqlEscape.Identifier(request.DbName)}`"));
+ break;
+ default: // Nothing
+ return false;
+ }
+
+ CreateEmptyDatabase(request.DbName);
+ return true;
}
- public bool CreateDatabase(string databaseName) {
- string sql = $"CREATE DATABASE IF NOT EXISTS `{databaseName}`";
- return connection.Execute(sql) != 0;
+ private void CreateEmptyDatabase(string dbName) =>
+ OnConnection(c => c.Execute($"CREATE DATABASE `{MySqlEscape.Identifier(dbName)}`"));
+
+ private bool DoesDatabaseExist(string dbName) =>
+ OnConnection(c => c.ExecuteScalar(
+ "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = @name;",
+ new { name = dbName })) > 0;
+
+ public bool DropDatabase(DbInfo database) {
+ OnConnection(c => c.Execute($"DROP DATABASE IF EXISTS `{MySqlEscape.Identifier(database.BaseName)}`"));
+ CleanDatabasePrivileges(database.BaseName);
+
+ ReflectInMetadata(m => m.Bases.SyncWithDelete(database), "удаление базы", database.BaseName);
+
+ return true;
}
- public bool DropDatabase(string databaseName) {
- string sql = $"DROP DATABASE IF EXISTS `{databaseName}`";
- return connection.Execute(sql) != 0;
+ private void CleanDatabasePrivileges(string dbName) {
+ var names = new[] { dbName, dbName.Replace("_", "\\_").Replace("%", "\\%") }
+ .Distinct(StringComparer.Ordinal).ToArray();
+ try {
+ OnConnection(c => c.Execute(
+ "DELETE FROM mysql.db WHERE Db IN @names;" +
+ "DELETE FROM mysql.tables_priv WHERE Db IN @names;" +
+ "DELETE FROM mysql.columns_priv WHERE Db IN @names;" +
+ "DELETE FROM mysql.procs_priv WHERE Db IN @names;" +
+ "FLUSH PRIVILEGES;",
+ new { names }));
+ }
+ catch(MySqlException ex) {
+ // у текущего пользователя может не быть прав на mysql.*
+ logger.Warn(ex, "Не удалось вычистить права удалённой базы {0}.", dbName);
+ }
}
- public void Dispose() {
- connection?.Dispose();
+ ///
+ /// Резервное копирование базы в скрипт
+ /// Метод блокирующий - вызывать из фонового потока
+ ///
+ public void BackupDatabase(DbInfo database, string filePath, IProgressBarDisplayable progress, CancellationToken cancellation) {
+ new MariaDbExportService().Export(ConnectionStringBuilder, database.BaseName, filePath, progress, cancellation);
}
+
#endregion
+
+ #region Управление пользователями
+
+ public DbUserFields SupportedUserFields =>
+ DbUserFields.BaseReadOnly
+ | DbUserFields.Name | DbUserFields.Email | DbUserFields.Phone
+ | (CanManageUsers && SupportsAccountLock ? DbUserFields.Disabling : DbUserFields.None)
+ | (SupportsAdminFlag ? DbUserFields.AdminFlag : DbUserFields.None);
+
+ public bool CanManageUsers => IsAdmin;
+
+ private bool SupportsAdminFlag => CanManageUsers && CanManageBaseAccess;
+
+ private static readonly string[] NewUserHosts = { AnyHost, LocalHost };
+
+ private readonly Dictionary> userHosts = new Dictionary>(StringComparer.Ordinal);
+
+ private enum AccountLockStorage {
+ /// Сервер блокировку не поддерживает
+ Unsupported,
+ /// колонка mysql.user.account_locked
+ UserColumn,
+ /// JSON в mysql.global_priv, в mysql.user такой колонки нет
+ GlobalPriv
+ }
+
+ private AccountLockStorage? accountLockStorage;
+ private AccountLockStorage AccountLocks {
+ get {
+ if(accountLockStorage == null)
+ accountLockStorage = DetectAccountLockStorage();
+ return accountLockStorage.Value;
+ }
+ }
+
+ private AccountLockStorage DetectAccountLockStorage() {
+ bool hasUserColumn = OnConnection(c => c.ExecuteScalar(
+ "SELECT COUNT(*) FROM information_schema.COLUMNS " +
+ "WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'user' AND COLUMN_NAME = 'account_locked'")) > 0;
+ if(hasUserColumn)
+ return AccountLockStorage.UserColumn;
+
+ bool hasGlobalPriv = OnConnection(c => c.ExecuteScalar(
+ "SELECT COUNT(*) FROM information_schema.TABLES " +
+ "WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'global_priv'")) > 0;
+
+ return hasGlobalPriv ? AccountLockStorage.GlobalPriv : AccountLockStorage.Unsupported;
+ }
+
+ private bool SupportsAccountLock => AccountLocks != AccountLockStorage.Unsupported;
+ public bool CanChangeOwnPassword { get; } = true;
+
+ public bool ChangeOwnPassword(string newPassword) {
+ if(string.IsNullOrEmpty(newPassword))
+ throw new ArgumentException("Пароль не может быть пустым", nameof(newPassword));
+ OnConnection(c => c.Execute($"SET PASSWORD = PASSWORD('{MySqlHelper.EscapeString(newPassword)}')"));
+
+ ConnectionStringBuilder.Password = newPassword;
+ connection = new MySqlConnection(ConnectionStringBuilder.ConnectionString);
+
+ return true;
+ }
+
+ public List GetUsers() {
+ return FromMetadataOrDirect(
+ metadata => metadata.Users.GetUsers().Select(ToDbUserInfo).ToList(),
+ GetUsersDirect);
+ }
+
+ private List ReadServerAccounts()
+ => OnConnection(c => c.Query(ServerAccountsQuery()).ToList());
+
+ // один логин заведён на сервере под несколькими хостами - админ он, только если админ везде
+ private static bool IsAccountAdmin(IEnumerable accounts)
+ => accounts.All(r => MySqlGrants.IsGlobalAdmin(IsYes(r.SuperPriv), IsYes(r.CreateUserPriv)));
+
+ private List GetUsersDirect() {
+ lock(serverLock) {
+ var rows = ReadServerAccounts();
+
+ userHosts.Clear();
+ var result = new List();
+ foreach(var accounts in rows.Where(IsRealUser).GroupBy(r => r.Login, StringComparer.Ordinal)) {
+ userHosts[accounts.Key] = accounts.Select(r => string.IsNullOrEmpty(r.Host) ? AnyHost : r.Host).ToList();
+ result.Add(new DbUserInfo {
+ Login = accounts.Key,
+ Disabled = accounts.All(r => IsYes(r.AccountLocked)),
+ IsAdmin = IsAccountAdmin(accounts),
+ IsCurrentUser = string.Equals(accounts.Key, UserName, StringComparison.OrdinalIgnoreCase)
+ });
+ }
+ return result;
+ }
+ }
+
+ ///
+ /// Учётки сервера с признаком блокировки. У MariaDB флаг лежит JSON-ом в mysql.global_priv
+ /// у MySQL - колонкой в mysql.user
+ /// в обоих случаях приводим к 'Y'/'N', как ждёт MySqlUserRow
+ ///
+ private string ServerAccountsQuery() {
+ string locked;
+ string join = string.Empty;
+
+ switch(AccountLocks) {
+ case AccountLockStorage.UserColumn:
+ locked = "u.account_locked";
+ break;
+ case AccountLockStorage.GlobalPriv:
+ locked = "IF(JSON_VALUE(p.Priv, '$.account_locked') = 1, 'Y', 'N')";
+ join = "LEFT JOIN mysql.global_priv p ON p.User = u.User AND p.Host = u.Host";
+ break;
+ default:
+ locked = "NULL";
+ break;
+ }
+
+ return $"SELECT u.User AS Login, u.Host, {locked} AS AccountLocked, " +
+ "u.Super_priv AS SuperPriv, u.Create_user_priv AS CreateUserPriv " +
+ $"FROM mysql.user u {join}";
+ }
+
+ // служебные учётки самого сервера пользователю приложения не показываем
+ private static bool IsRealUser(MySqlUserRow row) =>
+ !string.IsNullOrEmpty(row.Login)
+ && !row.Login.StartsWith("mysql.", StringComparison.OrdinalIgnoreCase)
+ && !MySqlSystemObjects.Users.Contains(row.Login, StringComparer.OrdinalIgnoreCase);
+
+ private static bool IsYes(string flag) => string.Equals(flag, "Y", StringComparison.OrdinalIgnoreCase);
+
+ public bool CreateUser(DbUserInfo user, string password) {
+ ValidateLogin(user?.Login);
+ if(string.IsNullOrEmpty(password))
+ throw new ArgumentException("Пароль не может быть пустым", nameof(password));
+
+ // без чтения метабазы новый пользователь не увидит список баз
+ var launcherAccess = new DbUserBaseAccess {
+ BaseName = LauncherMetadataManagement.LauncherBaseName, ReadOnly = true, HasAccess = true
+ };
+
+ var statements = NewUserHosts
+ .SelectMany(host => NewUserStatements(user, host, password, launcherAccess))
+ .ToList();
+
+ OnConnection(c => c.Execute(string.Join(";", statements)));
+ lock(serverLock)
+ userHosts[user.Login] = NewUserHosts.ToList();
+
+ ReflectInMetadata(m => m.Users.CreateUser(ToLauncherUser(user)), "создание", user.Login);
+ return true;
+ }
+
+ private IEnumerable NewUserStatements(DbUserInfo user, string host, string password,
+ DbUserBaseAccess launcherAccess) {
+ string account = MySqlAccess.UserOf(user.Login, host);
+ string lockOption = user.Disabled && SupportsAccountLock ? " ACCOUNT LOCK" : string.Empty;
+
+ yield return $"CREATE USER {account} IDENTIFIED BY '{MySqlHelper.EscapeString(password)}'{lockOption}";
+
+ if(SupportsAdminFlag && user.IsAdmin)
+ yield return MySqlAccess.GrantAdmin(account);
+
+ foreach(var statement in MySqlAccess.Statements(user.Login, host, null, launcherAccess))
+ yield return statement;
+ }
+
+ public bool UpdateUser(DbUserInfo user, string newPassword = null) {
+ ValidateLogin(user?.Login);
+
+ var grantsByHost = RequireGrantsByHost(user.Login);
+ bool isAdminNow = SupportsAdminFlag
+ && grantsByHost.Values.Any(MySqlGrants.HasGlobalAdmin);
+
+ var statements = HostsOf(user.Login)
+ .SelectMany(host => UserChangeStatements(user, host, newPassword, isAdminNow))
+ .ToList();
+
+ if(statements.Any())
+ OnConnection(c => c.Execute(string.Join(";", statements)));
+
+ ReflectUserUpdateInMetadata(user);
+ ReflectProfileInBases(user);
+ return true;
+ }
+
+ private void ReflectProfileInBases(DbUserInfo user) {
+ if(string.IsNullOrEmpty(user.Name) && string.IsNullOrEmpty(user.Email))
+ return;
+
+ BaseUsers.SyncProfile(GetUserDatabases().Select(db => db.BaseName), user.Login, user.Name, user.Email);
+ }
+
+ private IEnumerable UserChangeStatements(DbUserInfo user, string host, string newPassword, bool isAdminNow) {
+ string account = MySqlAccess.UserOf(user.Login, host);
+
+ string options = string.Join(" ", AlterUserOptions(user, newPassword));
+ if(options.Length > 0)
+ yield return $"ALTER USER {account} {options}";
+
+ if(SupportsAdminFlag && user.IsAdmin != isAdminNow)
+ yield return user.IsAdmin ? MySqlAccess.GrantAdmin(account) : MySqlAccess.RevokeAdmin(account);
+ }
+
+ // через ALTER USER меняются только пароль и блокировка учётки
+ private IEnumerable AlterUserOptions(DbUserInfo user, string newPassword) {
+ if(!string.IsNullOrEmpty(newPassword))
+ yield return $"IDENTIFIED BY '{MySqlHelper.EscapeString(newPassword)}'";
+ if(SupportsAccountLock)
+ yield return user.Disabled ? "ACCOUNT LOCK" : "ACCOUNT UNLOCK";
+ }
+
+ public bool DeleteUser(string login) {
+ ValidateLogin(login);
+
+ // строку гасим во всех базах каталога, а не только там, где были гранты: удаляют
+ // и пропавшую с сервера учётку, у которой грантов уже нет
+ var userBases = GetUserDatabases().ConvertAll(db => db.BaseName);
+
+ OnConnection(c => c.Execute(string.Join(";", HostsOf(login)
+ .Select(host => $"DROP USER IF EXISTS {MySqlAccess.UserOf(login, host)}"))));
+ lock(serverLock)
+ userHosts.Remove(login);
+
+ ReflectInMetadata(m => {
+ var target = m.Users.GetUserByLogin(login);
+ if(target != null)
+ m.Users.DeleteUser(target);
+ }, "удаление", login);
+
+ BaseUsers.SyncWithDeletingUser(login, userBases);
+
+ return true;
+ }
+
+ public List GetUserBaseAccess(string login)
+ {
+ var grants = RequireGrantsByHost(login).Values.SelectMany(g => g).ToList();
+ bool globalAdmin = MySqlGrants.HasGlobalAdmin(grants);
+
+ // каталог берём из метабазы одним запросом, а не читая параметры каждой базы
+ List result = GetUserDatabases()
+ .Select(db => globalAdmin ? MySqlAccess.FullAccessByGlobalGrant(db) : MySqlAccess.FromGrants(db, grants))
+ .ToList();
+
+ FillUsersProfiles(result, login);
+ return result;
+ }
+
+ private void FillUsersProfiles(IEnumerable accesses, string login) {
+ var withAccess = accesses.Where(a => a.HasAccess && !string.IsNullOrEmpty(a.BaseName));
+ if(!withAccess.Any())
+ return;
+
+ // один запрос на все базы: поштучно это два запроса на каждую
+ var profiles = BaseUsers.TryGetProfiles(withAccess.Select(a => a.BaseName), login);
+
+ foreach(var access in withAccess) {
+ if(!profiles.TryGetValue(access.BaseName, out var profile))
+ continue;
+ access.Name = profile.Name;
+ access.Email = profile.Email;
+ }
+ }
+
+ public bool SetUserBaseAccess(string login, DbUserBaseAccess access) {
+ ValidateLogin(login);
+ if(string.IsNullOrWhiteSpace(access?.BaseName))
+ throw new ArgumentException("Не указано имя базы", nameof(access));
+
+ var grantsByHost = RequireGrantsByHost(login);
+
+ if(MySqlGrants.HasGlobalAdmin(grantsByHost.Values.SelectMany(g => g)))
+ throw new InvalidOperationException($"У пользователя {login} глобальные права на весь сервер");
+
+ var statements = grantsByHost
+ .SelectMany(hostGrants => MySqlAccess.Statements(login, hostGrants.Key, hostGrants.Value, access))
+ .ToList();
+ if(statements.Any())
+ OnConnection(c => c.Execute(string.Join(";", statements)));
+
+ ReflectInMetadata(m => {
+ var target = m.Users.GetUserByLogin(login);
+ if(target == null)
+ return;
+ if(access.HasAccess)
+ m.Users.SetBaseUpdateRight(access.BaseName, target, !access.ReadOnly);
+ else
+ m.Users.RevokeBaseUpdateRight(access.BaseName, target);
+ }, "изменение права на обновление базы", login);
+
+ BaseUsers.SyncWithUserTable(access.BaseName, new BaseUserRow { Admin = access.IsAdmin, Deactivated = !access.HasAccess, Email = access.Email, Login = login, Name = access.Name}, access.HasAccess);
+
+ return true;
+ }
+
+ private Dictionary> RequireGrantsByHost(string login)
+ {
+ var hosts = HostsOf(login);
+ var result = new Dictionary>(StringComparer.Ordinal);
+ string sql = string.Join(";", hosts
+ .Select(host => $"SHOW GRANTS FOR {MySqlAccess.UserOf(login, host)}"));
+ try
+ {
+ OnConnection(c => {
+ using(var multi = c.QueryMultiple(sql))
+ {
+ foreach(var host in hosts)
+ result[host] = multi.Read();
+ }
+ });
+ }
+ catch(MySqlException ex) when(ex.Number == ER_NONEXISTING_GRANT)
+ {
+ logger.Debug(ex, "Не удалось получить гранты пользователя {0}", login);
+ }
+
+ if(!result.Any())
+ throw new OperationRefusedException($"Учётной записи {login} на сервере нет. "
+ + "Возможно, её удалили в обход лаунчера - обновите список пользователей.");
+
+ return result;
+ }
+
+ private sealed class MySqlUserRow {
+ public string Login { get; set; }
+ public string Host { get; set; }
+ public string AccountLocked { get; set; }
+ public string SuperPriv { get; set; }
+ public string CreateUserPriv { get; set; }
+ }
+
+ private IReadOnlyList HostsOf(string login)
+ {
+ lock(serverLock) {
+ if(!userHosts.ContainsKey(login)) {
+ try {
+ GetUsersDirect();
+ }
+ catch(MySqlException ex) {
+ logger.Debug(ex, "Не удалось прочитать хосты учётной записи {0}", login);
+ }
+ }
+
+ return userHosts.TryGetValue(login, out var hosts) && hosts.Any()
+ ? (IReadOnlyList)hosts.ToList()
+ : NewUserHosts;
+ }
+ }
+
+ private static void ValidateLogin(string login)
+ {
+ if(string.IsNullOrWhiteSpace(login))
+ throw new ArgumentException("Логин пользователя не может быть пустым", nameof(login));
+ if(login.Length > 80)
+ throw new ArgumentException("Логин пользователя длиннее 80 символов", nameof(login));
+ }
+
+ #endregion
+
+ private readonly object serverLock = new object();
+
+ private T OnConnection(Func query) {
+ lock(serverLock) {
+ EnsureOpen();
+ return query(connection);
+ }
+ }
+
+ private void OnConnection(Action command) {
+ lock(serverLock) {
+ EnsureOpen();
+ command(connection);
+ }
+ }
+
+ private void EnsureOpen()
+ {
+ if(connection.State == ConnectionState.Broken)
+ connection.Close();
+
+ if(connection.State != ConnectionState.Open)
+ connection.Open();
+ }
+
+ private bool disposed;
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if(disposed)
+ return;
+
+ if(disposing)
+ connection?.Dispose();
+
+ disposed = true;
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
}
}
diff --git a/QS.DbManagement/MariaDb/MariaDbConnectionTypeBase.cs b/QS.DbManagement/MariaDb/MariaDbConnectionTypeBase.cs
index 960a29b4e..40e5f5cae 100644
--- a/QS.DbManagement/MariaDb/MariaDbConnectionTypeBase.cs
+++ b/QS.DbManagement/MariaDb/MariaDbConnectionTypeBase.cs
@@ -1,7 +1,8 @@
+using QS.DbManagement.Entities;
+using QS.Utilities.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
-using QS.Utilities.Extensions;
namespace QS.DbManagement
{
@@ -20,7 +21,7 @@ public override bool CanConnect(IEnumerable parameters
parameters.Any(p => p.Name == "Login" && !string.IsNullOrEmpty(p.Value));
}
- public override IDbProvider CreateProvider(IList parameters, string password = null)
- => new MariaDBProvider(parameters, password);
+ public override IDbProvider CreateProvider(IList parameters, byte productCode, string password = null)
+ => new MariaDBProvider(parameters, productCode, password);
}
}
diff --git a/QS.DbManagement/MariaDb/MariaDbExportService.cs b/QS.DbManagement/MariaDb/MariaDbExportService.cs
new file mode 100644
index 000000000..ce83a5261
--- /dev/null
+++ b/QS.DbManagement/MariaDb/MariaDbExportService.cs
@@ -0,0 +1,110 @@
+using MySqlConnector;
+using QS.Dialog;
+using System;
+using System.IO;
+using System.Threading;
+
+namespace QS.DbManagement {
+ public class MariaDbExportService {
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
+ /// Выгружает базу в файл
+ public void Export(
+ MySqlConnectionStringBuilder connectionSettings,
+ string databaseName,
+ string filePath,
+ IProgressBarDisplayable progress,
+ CancellationToken cancellation)
+ {
+ if(connectionSettings == null)
+ throw new ArgumentNullException(nameof(connectionSettings));
+ if(string.IsNullOrWhiteSpace(databaseName))
+ throw new ArgumentException("Не указано имя базы", nameof(databaseName));
+ if(string.IsNullOrWhiteSpace(filePath))
+ throw new ArgumentException("Не указан путь к файлу резервной копии.", nameof(filePath));
+
+ EnsureDirectory(filePath);
+ progress?.Update($"Создаём резервную копию базы {databaseName} в файл {filePath}");
+
+ var builder = new MySqlConnectionStringBuilder(connectionSettings.ConnectionString)
+ {
+ Database = databaseName
+ };
+
+ using(var connection = new MySqlConnection(builder.ConnectionString))
+ {
+ connection.Open();
+ using(var command = connection.CreateCommand())
+ {
+ command.CommandTimeout = 0;
+ using(var backup = new MySqlBackup(command))
+ {
+ var reporter = new ExportReporter(progress, cancellation);
+ backup.ExportProgressChanged += reporter.OnProgress;
+ backup.ExportToFile(filePath);
+
+ if(reporter.Stopped || cancellation.IsCancellationRequested)
+ {
+ DeleteQuietly(filePath);
+ cancellation.ThrowIfCancellationRequested();
+ }
+ }
+ }
+ }
+ }
+
+ private static void EnsureDirectory(string filePath)
+ {
+ var directory = Path.GetDirectoryName(filePath);
+ if(!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ Directory.CreateDirectory(directory);
+ }
+
+ private sealed class ExportReporter
+ {
+ private readonly IProgressBarDisplayable progress;
+ private readonly CancellationToken cancellation;
+ private bool started;
+ private string currentTable;
+
+ public ExportReporter(IProgressBarDisplayable progress, CancellationToken cancellation)
+ {
+ this.progress = progress;
+ this.cancellation = cancellation;
+ }
+
+ /// Выгрузку остановили по отмене - файл дописан не до конца
+ public bool Stopped { get; private set; }
+
+ public void OnProgress(object sender, ExportProgressArgs e)
+ {
+ if(cancellation.IsCancellationRequested) {
+ Stopped = true;
+ ((MySqlBackup)sender).StopAllProcess();
+ return;
+ }
+
+ if(!started) {
+ progress?.Start(maxValue: e.TotalRowsInAllTables, text: "Создание резервной копии");
+ started = true;
+ }
+ if(currentTable != e.CurrentTableName) {
+ currentTable = e.CurrentTableName;
+ progress?.Update($"Экспорт таблицы {currentTable}");
+ }
+ progress?.Update(e.CurrentRowIndexInAllTables);
+ }
+ }
+
+ private static void DeleteQuietly(string filePath)
+ {
+ try {
+ if(File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ catch(IOException ex) {
+ logger.Warn(ex, "Не удалось удалить незавершённую резервную копию {0}", filePath);
+ }
+ }
+ }
+}
diff --git a/QS.DbManagement/MariaDb/MySqlAccess.cs b/QS.DbManagement/MariaDb/MySqlAccess.cs
new file mode 100644
index 000000000..e1f3c4960
--- /dev/null
+++ b/QS.DbManagement/MariaDb/MySqlAccess.cs
@@ -0,0 +1,113 @@
+using MySqlConnector;
+using QS.DbManagement.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace QS.DbManagement.MariaDb {
+ /// Перевод между грантами сервера и правами доступа приложения
+ internal static class MySqlAccess {
+ public const string AllPrivileges = "ALL PRIVILEGES";
+ private const string ReadOnlyPrivileges = "SELECT, LOCK TABLES, SHOW VIEW";
+
+ private const string UpdatePrivileges =
+ "SELECT, INSERT, UPDATE, DELETE, EXECUTE, CREATE TEMPORARY TABLES, LOCK TABLES, SHOW VIEW, "
+ + "ALTER, CREATE, DROP"; //Право накатывать на базу обновления
+
+ /// переводит в вид в виде 'логин'@'хост'
+ public static string UserOf(string login, string host)
+ => $"'{MySqlHelper.EscapeString(login)}'@'{MySqlHelper.EscapeString(host)}'";
+
+ ///
+ /// Выдача глобального админа: права на весь сервер плюс право их раздавать.
+ ///
+ public static string GrantAdmin(string account)
+ => $"GRANT {AllPrivileges} ON *.* TO {account} WITH GRANT OPTION";
+
+ /// Снятие глобального админа
+ public static string RevokeAdmin(string account)
+ => $"REVOKE {AllPrivileges} ON *.* FROM {account}; REVOKE GRANT OPTION ON *.* FROM {account}";
+
+ public static DbUserBaseAccess FullAccessByGlobalGrant(DbInfo db)
+ => new DbUserBaseAccess {
+ BaseId = db.BaseId,
+ BaseName = db.BaseName,
+ Title = db.Title,
+ HasAccess = true,
+ IsAdmin = true,
+ CanEdit = false
+ };
+
+ public static DbUserBaseAccess FromGrants(DbInfo db, IEnumerable grants)
+ {
+ var access = new DbUserBaseAccess { BaseId = db.BaseId, BaseName = db.BaseName, Title = db.Title };
+
+ var privileges = grants
+ .Where(g => CoversDatabase(g, db.BaseName) && MySqlGrants.IsMeaningful(g))
+ .SelectMany(MySqlGrants.Privileges)
+ .ToList();
+ if(!privileges.Any())
+ return access;
+
+ access.HasAccess = true;
+ if(privileges.Contains(AllPrivileges))
+ access.IsAdmin = true;
+ else if(privileges.All(IsReadOnlyPrivilege))
+ access.ReadOnly = true;
+ return access;
+ }
+
+ ///
+ /// SQL, чтобы у @ стал доступ
+ ///
+ public static IEnumerable Statements(string login, string host, IEnumerable grants, DbUserBaseAccess access)
+ {
+ string user = UserOf(login, host);
+
+ foreach(var grant in (grants ?? Enumerable.Empty()).Where(g => GrantedOnDatabase(g, access.BaseName))) {
+ string pattern = $"`{MySqlEscape.Identifier(MySqlGrants.Scope(grant))}`.*";
+ if(MySqlGrants.IsMeaningful(grant))
+ yield return $"REVOKE {AllPrivileges} ON {pattern} FROM {user}";
+ // ALL PRIVILEGES не включает право раздачи грантов
+ if(MySqlGrants.HasGrantOption(grant))
+ yield return $"REVOKE GRANT OPTION ON {pattern} FROM {user}";
+ }
+
+ string privileges = PrivilegesFor(access);
+ if(privileges != null)
+ yield return $"GRANT {privileges} ON `{MySqlEscape.Pattern(access.BaseName)}`.* TO {user}";
+ }
+
+ /// null - доступа нет
+ private static string PrivilegesFor(DbUserBaseAccess access)
+ {
+ if(!access.HasAccess)
+ return null;
+ if(access.IsAdmin)
+ return AllPrivileges;
+ if(access.ReadOnly)
+ return ReadOnlyPrivileges;
+ return UpdatePrivileges;
+ }
+
+ // грант на всём сервере *.* базу тоже покрывает
+ private static bool CoversDatabase(string grant, string baseName)
+ {
+ string scope = MySqlGrants.Scope(grant);
+ if(scope == null)
+ return false;
+ return scope == "*" || string.Equals(MySqlEscape.UnescapePattern(scope), baseName, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // отзывать по имени базы можно только грант, выданный именно на неё
+ private static bool GrantedOnDatabase(string grant, string baseName)
+ {
+ string scope = MySqlGrants.Scope(grant);
+ return scope != null && scope != "*"
+ && string.Equals(MySqlEscape.UnescapePattern(scope), baseName, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsReadOnlyPrivilege(string privilege)
+ => privilege == "SELECT" || privilege == "LOCK TABLES" || privilege == "SHOW VIEW";
+ }
+}
diff --git a/QS.DbManagement/MariaDb/MySqlEscape.cs b/QS.DbManagement/MariaDb/MySqlEscape.cs
new file mode 100644
index 000000000..18eb80ce4
--- /dev/null
+++ b/QS.DbManagement/MariaDb/MySqlEscape.cs
@@ -0,0 +1,21 @@
+namespace QS.DbManagement.MariaDb {
+ internal static class MySqlEscape {
+ /// идентификатор в обратных кавычках
+ public static string Identifier(string value)
+ {
+ return value == null ? string.Empty : value.Replace("`", "``");
+ }
+
+ /// имя базы для GRANT
+ public static string Pattern(string dbName)
+ {
+ return Identifier(dbName).Replace("_", "\\_").Replace("%", "\\%");
+ }
+
+ /// имя базы, как его вернул SHOW GRANTS
+ public static string UnescapePattern(string pattern)
+ {
+ return pattern.Replace("\\_", "_").Replace("\\%", "%");
+ }
+ }
+}
diff --git a/QS.DbManagement/MariaDb/MySqlGrants.cs b/QS.DbManagement/MariaDb/MySqlGrants.cs
new file mode 100644
index 000000000..66e595e1b
--- /dev/null
+++ b/QS.DbManagement/MariaDb/MySqlGrants.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace QS.DbManagement.MariaDb {
+ ///
+ /// Разбор строк, которые отдаёт SHOW GRANTS
+ ///
+ internal static class MySqlGrants {
+ private const string AllPrivileges = "ALL PRIVILEGES";
+ private const string GrantOption = "WITH GRANT OPTION";
+
+ // Скобки после привилегии
+ private static readonly Regex ColumnList =
+ new Regex(@"\([^)]*\)", RegexOptions.None, TimeSpan.FromSeconds(5));
+
+ public static bool HasGlobalAdmin(IEnumerable grants)
+ => grants.Any(g => Scope(g) == "*" && Privileges(g).Contains(AllPrivileges));
+
+ public static bool IsGlobalAdmin(bool superPriv, bool createUserPriv)
+ => superPriv && createUserPriv;
+
+ public static bool HasGlobalGrantOption(IEnumerable grants) =>
+ grants.Any(g => Scope(g) == "*" && HasGrantOption(g));
+
+ public static bool HasGrantOption(string grant) =>
+ grant.IndexOf(GrantOption, StringComparison.OrdinalIgnoreCase) >= 0;
+
+ public static string Scope(string grant) {
+ int on = grant.IndexOf(" ON ", StringComparison.OrdinalIgnoreCase);
+ int to = grant.IndexOf(" TO ", StringComparison.OrdinalIgnoreCase);
+ if(on < 0 || to < on)
+ return null;
+
+ string target = grant.Substring(on + 4, to - on - 4).Trim(); //4 = длина " ON "
+ if(!target.EndsWith(".*", StringComparison.Ordinal))
+ return null;
+
+ string name = target.Substring(0, target.Length - 2).Trim();
+ if(name == "*")
+ return name;
+ if(name.Length < 2 || !name.StartsWith("`", StringComparison.Ordinal) || !name.EndsWith("`", StringComparison.Ordinal))
+ return null;
+ return name
+ .Substring(1, name.Length - 2)
+ .Replace("``", "`");
+ }
+
+ public static IEnumerable Privileges(string grant) {
+ int start = grant.IndexOf("GRANT ", StringComparison.OrdinalIgnoreCase);
+ int on = grant.IndexOf(" ON ", StringComparison.OrdinalIgnoreCase);
+ if(start < 0 || on <= start)
+ return Enumerable.Empty();
+
+ start += 6; //длина "GRANT "
+ return ColumnList
+ .Replace(
+ grant.Substring(start, on - start), string.Empty)
+ .Split(',')
+ .Select(p =>
+ p.Trim()
+ .ToUpperInvariant())
+ .Where(p =>
+ p.Length > 0);
+ }
+
+ public static bool IsMeaningful(string grant)
+ {
+ return Privileges(grant)
+ .Any(p => p != "USAGE");
+ }
+ }
+}
diff --git a/QS.DbManagement/MariaDb/MySqlMultiBase.cs b/QS.DbManagement/MariaDb/MySqlMultiBase.cs
new file mode 100644
index 000000000..cf9fcd5a7
--- /dev/null
+++ b/QS.DbManagement/MariaDb/MySqlMultiBase.cs
@@ -0,0 +1,64 @@
+using Dapper;
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Linq;
+
+namespace QS.DbManagement.MariaDb {
+ internal static class MySqlMultiBase {
+ ///
+ /// базы, где таблицы нет или куда нет доступа, в результат не попадают
+ ///
+ public static List DatabasesWithTable(IDbConnection connection, IEnumerable databases, string table) {
+ var wanted = Distinct(databases);
+ if(wanted.Count == 0)
+ return new List();
+
+ return connection.Query(
+ "SELECT TABLE_SCHEMA FROM information_schema.TABLES " +
+ "WHERE TABLE_NAME = @table AND TABLE_SCHEMA IN @databases ORDER BY TABLE_SCHEMA",
+ new { table, databases = wanted }).ToList();
+ }
+
+ /// Есть ли в базе такая таблица - и видна ли она текущему пользователю
+ public static bool HasTable(IDbConnection connection, string database, string table)
+ => DatabasesWithTable(connection, new[] { database }, table).Count > 0;
+
+ ///
+ /// Число и порядок колонок в ветках обязаны совпадать
+ ///
+ /// база -> список колонок её ветки
+ /// base_parameters имя перменной с именем базы
+ /// условие ветки без слова WHERE; null - без условия
+ /// сюда добавляются метки баз
+ public static string UnionAll(IEnumerable> projections,
+ string table, string label, string where, DynamicParameters parameters) {
+ if(parameters == null)
+ throw new ArgumentNullException(nameof(parameters));
+
+ string condition = string.IsNullOrEmpty(where) ? string.Empty : " WHERE " + where;
+ string tableName = $"`{MySqlEscape.Identifier(table)}`";
+
+ var selects = new List();
+ foreach(var projection in projections) {
+ // имя базы уходит в запрос дважды: идентификатором в кавычках и меткой - параметром
+ string marker = "base" + selects.Count.ToString();
+ parameters.Add(marker, projection.Key);
+ selects.Add($"SELECT @{marker} AS `{label}`, {projection.Value} "
+ + $"FROM `{MySqlEscape.Identifier(projection.Key)}`.{tableName}{condition}");
+ }
+ return string.Join(" UNION ALL ", selects);
+ }
+
+ /// Имена баз без повторов и пустых - в таком виде их принимают оба запроса
+ public static List Distinct(IEnumerable databases) {
+ if(databases == null)
+ throw new ArgumentNullException(nameof(databases));
+
+ return databases
+ .Where(db => !string.IsNullOrEmpty(db))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+ }
+}
diff --git a/QS.DbManagement/MariaDb/MySqlSystemObjects.cs b/QS.DbManagement/MariaDb/MySqlSystemObjects.cs
new file mode 100644
index 000000000..70fe910df
--- /dev/null
+++ b/QS.DbManagement/MariaDb/MySqlSystemObjects.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+
+namespace QS.DbManagement.MariaDb {
+ internal static class MySqlSystemObjects {
+ public static readonly IReadOnlyCollection Databases =
+ new[] { "information_schema", "mysql", "performance_schema", "sys" };
+
+ public static readonly IReadOnlyCollection Users =
+ new[] { "root", "mariadb.sys", "mysql", "PUBLIC" };
+ }
+}
diff --git a/QS.DbManagement/MariaDb/QSLauncher/BaseUsersManagement.cs b/QS.DbManagement/MariaDb/QSLauncher/BaseUsersManagement.cs
new file mode 100644
index 000000000..14968d2ed
--- /dev/null
+++ b/QS.DbManagement/MariaDb/QSLauncher/BaseUsersManagement.cs
@@ -0,0 +1,141 @@
+using Dapper;
+using MySqlConnector;
+using QS.DbManagement.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace QS.DbManagement.MariaDb.QSLauncher {
+ internal class BaseUsersManagement {
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
+ private const string UsersTable = "users";
+
+ // незаполненное поле формы приходит null и через COALESCE не затирает то,
+ // что в базу вписало само приложение
+ private const string ProfileSet = "`name` = COALESCE(@name, `name`), `email` = COALESCE(@email, `email`)";
+ private const string ProfileSelect = "`name` AS `Name`, `email` AS `Email`";
+
+ private readonly string connectionString;
+
+ public BaseUsersManagement(MySqlConnectionStringBuilder connectionBuilder) {
+ connectionString = connectionBuilder.ConnectionString;
+ }
+
+ /// Заводит, обновляет пользователя в базе при доступе, снимает доступ через deactivated
+ public void SyncWithUserTable(string baseName, BaseUserRow user, bool hasAccess) {
+ try {
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ if(!MySqlMultiBase.HasTable(connection, baseName, UsersTable))
+ return; // таблицы users в этой базе нет
+
+ string table = Table(baseName);
+ if(!hasAccess) {
+ connection.Execute($"UPDATE {table} SET `deactivated` = TRUE WHERE `login` = @login",
+ new { login = user.Login });
+ return;
+ }
+
+ bool exists = connection.ExecuteScalar(
+ $"SELECT COUNT(*) > 0 FROM {table} WHERE `login` = @login", new { login = user.Login });
+
+ // колонка name в базах NOT NULL - без подстановки вставка упадёт
+ if(!exists && string.IsNullOrEmpty(user.Name))
+ user.Name = user.Login;
+
+ connection.Execute(exists
+ ? $"UPDATE {table} SET {ProfileSet}, `admin` = @admin, `deactivated` = FALSE WHERE `login` = @login"
+ : $"INSERT INTO {table} (`login`, `name`, `email`, `admin`, `deactivated`) " +
+ "VALUES (@login, @name, @email, @admin, FALSE)",
+ new { login = user.Login, name = Blank(user.Name), email = Blank(user.Email), admin = user.Admin });
+ }
+ }
+ catch(MySqlException ex) {
+ logger.Warn(ex, "не удалось синхронизировать users в базе {0} для {1}", baseName, user.Login);
+ }
+ }
+
+ public void SyncWithDeletingUser(string login, List baseNames) {
+ try {
+ UpdateInBases(baseNames, "`deactivated` = TRUE", new { login });
+ }
+ catch(MySqlException ex) {
+ logger.Warn(ex, "не удалось синхронизировать отключение пользователя {0} из всех таблиц users", login);
+ }
+ }
+
+ public void SyncProfile(IEnumerable baseNames, string login, string name, string email) {
+ if(string.IsNullOrEmpty(name) && string.IsNullOrEmpty(email))
+ return;
+
+ try {
+ UpdateInBases(baseNames, ProfileSet, new { login, name = Blank(name), email = Blank(email) });
+ }
+ catch(MySqlException ex) {
+ logger.Warn(ex, "не удалось обновить профиль пользователя {0} в таблицах {1}", login, UsersTable);
+ }
+ }
+
+ public Dictionary TryGetProfiles(IEnumerable baseNames, string login) {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var wanted = Wanted(baseNames);
+ if(wanted.Count == 0)
+ return result;
+
+ try {
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ var parameters = new DynamicParameters();
+ parameters.Add("login", login);
+
+ // набор колонок один на все базы, поэтому ветки UNION соединяются как есть
+ var projections = MySqlMultiBase.DatabasesWithTable(connection, wanted, UsersTable)
+ .Select(baseName => new KeyValuePair(baseName, ProfileSelect));
+ string sql = MySqlMultiBase.UnionAll(projections, UsersTable,
+ nameof(BaseProfileRow.BaseName), "`login` = @login", parameters);
+ if(sql.Length == 0)
+ return result;
+
+ foreach(BaseProfileRow row in connection.Query(sql, parameters))
+ result[row.BaseName] = row;
+ }
+ }
+ catch(MySqlException ex) {
+ logger.Warn(ex, "Не удалось прочитать {0} по базам пользователя {1}", UsersTable, login);
+ }
+ return result;
+ }
+
+ /// Все базы одним запросом: поштучно это два запроса на каждую
+ private void UpdateInBases(IEnumerable baseNames, string setClause, object parameters) {
+ var wanted = Wanted(baseNames);
+ if(wanted.Count == 0)
+ return;
+
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ var sqls = MySqlMultiBase.DatabasesWithTable(connection, wanted, UsersTable)
+ .Select(baseName => $"UPDATE {Table(baseName)} SET {setClause} WHERE `login` = @login")
+ .ToList();
+ if(sqls.Count == 0)
+ return;
+
+ connection.Execute(string.Join("; ", sqls), parameters);
+ }
+ }
+
+ private static List Wanted(IEnumerable baseNames)
+ => MySqlMultiBase.Distinct(baseNames ?? Enumerable.Empty());
+
+ private static string Table(string baseName)
+ => $"`{MySqlEscape.Identifier(baseName)}`.`{UsersTable}`";
+
+ /// Пустую строку из формы кладём как NULL
+ private static string Blank(string value) => string.IsNullOrEmpty(value) ? null : value;
+
+ private sealed class BaseProfileRow : BaseUserRow {
+ public string BaseName { get; set; }
+ }
+ }
+}
diff --git a/QS.DbManagement/MariaDb/QSLauncher/LauncherBasesManagement.cs b/QS.DbManagement/MariaDb/QSLauncher/LauncherBasesManagement.cs
new file mode 100644
index 000000000..0110f645c
--- /dev/null
+++ b/QS.DbManagement/MariaDb/QSLauncher/LauncherBasesManagement.cs
@@ -0,0 +1,231 @@
+using Dapper;
+using MySqlConnector;
+using QS.DbManagement.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace QS.DbManagement.MariaDb.QSLauncher {
+ internal class LauncherBasesManagement {
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
+ private const string LauncherBaseName = LauncherMetadataManagement.LauncherBaseName;
+ private const string BasesTable = "bases";
+ private static readonly string[] BaseDependencies = { "base_update_rights" };
+ private static readonly string[] BaseColumns =
+ { "product_id", "base_name", "base_title", "version" };
+ private static readonly string[] BaseUpdatableColumns = { "base_title", "version" };
+
+ private readonly bool canSync;
+ private readonly string connectionString;
+ private readonly byte productId;
+
+
+ ///
+ /// Требуется полный обзор сервера
+ ///
+ public LauncherBasesManagement(MySqlConnectionStringBuilder connectionBuilder, bool canSync, byte productId) {
+ var toLauncher = new MySqlConnectionStringBuilder(connectionBuilder.ConnectionString) {
+ Database = LauncherBaseName,
+ AllowLoadLocalInfile = true
+ };
+ connectionString = toLauncher.ConnectionString;
+
+ this.canSync = canSync;
+ this.productId = productId;
+ }
+
+ public int SyncBases() {
+ if(!canSync)
+ throw new UnauthorizedAccessException($"Синхронизировать {LauncherBaseName} может только пользователь с правами на весь сервер");
+
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+
+ var bases = connection.Query("SHOW DATABASES")
+ .Except(MySqlSystemObjects.Databases, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ //Базы нашего продукта, о которых есть что записать в метабазу
+ var parameters = BaseParametersReader.ReadMany(connection, bases, BaseMetaParameters);
+ var rows = new List();
+ foreach(var dbName in bases)
+ {
+ BaseRow row = ToBaseMeta(dbName, parameters);
+ if(row == null || row.ProductId != productId)
+ continue;
+
+ rows.Add(row);
+ }
+
+ int written = rows.Count > 0 ? UpsertBases(connection, rows) : 0;
+
+ // пропавшие с сервера базы помечаем disabled
+ MarkMissingBasesDisabled(connection, bases);
+
+ return written;
+ }
+ }
+
+ private static int UpsertBases(MySqlConnection connection, IList rows, MySqlTransaction tx = null) {
+ const int chunkSize = 500;
+ for(int offset = 0; offset < rows.Count; offset += chunkSize) {
+ var chunk = rows.Skip(offset).Take(chunkSize).ToList();
+ string sql = BuildUpsert(chunk, out var parameters);
+ connection.Execute(sql, parameters, tx);
+ }
+
+ return rows.Count;
+ }
+
+ /// Один INSERT со всеми строками пачки ON DUPLICATE KEY UPDATE
+ private static string BuildUpsert(IReadOnlyList chunk, out DynamicParameters parameters) {
+ var sql = new StringBuilder($"INSERT INTO `{BasesTable}` (")
+ .Append(string.Join(", ", BaseColumns.Select(c => $"`{c}`")))
+ .Append(") VALUES ");
+ parameters = new DynamicParameters();
+
+ for(int row = 0; row < chunk.Count; row++) {
+ if(row > 0)
+ sql.Append(',');
+ sql.Append('(')
+ .Append(string.Join(",", BaseColumns.Select((c, i) => "@" + ParameterName(row, i))))
+ .Append(')');
+
+ var values = RowValues(chunk[row]);
+ for(int i = 0; i < values.Length; i++)
+ parameters.Add(ParameterName(row, i), values[i]);
+ }
+
+ sql.Append(" ON DUPLICATE KEY UPDATE ")
+ .Append(string.Join(", ", BaseUpdatableColumns.Select(c => $"`{c}` = VALUES(`{c}`)")));
+
+ return sql.ToString();
+ }
+
+ /// Порядок обязан совпадать с
+ private static object[] RowValues(BaseRow row) => new object[] {
+ row.ProductId, row.BaseName, row.BaseTitle, row.Version
+ };
+
+ private static string ParameterName(int row, int column) => $"p{row}_{column}";
+
+ private void MarkMissingBasesDisabled(MySqlConnection connection, IReadOnlyCollection presentDatabases)
+ {
+ if(presentDatabases.Count == 0) {
+ connection.Execute(
+ $"UPDATE `{BasesTable}` SET disabled = TRUE WHERE product_id = @pid;",
+ new { pid = productId });
+ return;
+ }
+ // пропавшие -> disabled
+ // вернувшиеся -> снимаем флаг
+ connection.Execute(
+ $"UPDATE `{BasesTable}` SET disabled = (base_name NOT IN @present) WHERE product_id = @pid;",
+ new { present = presentDatabases, pid = productId });
+ }
+
+ public IEnumerable GetBases()
+ {
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+
+ var visible = new HashSet(connection.Query("SHOW DATABASES"),
+ StringComparer.OrdinalIgnoreCase);
+
+ return connection.Query(
+ "SELECT `id` AS BaseId, COALESCE(base_title, base_name) AS Title, " +
+ " `base_name` AS BaseName, `version` AS Version " +
+ $"FROM `{BasesTable}` WHERE product_id = @productId " +
+ " AND base_name IN @visible;",
+ new { productId, visible })
+ .ToList();
+ }
+ }
+
+ /// Идентификатор базы продукта в метабазе, 0 - её там нет
+ public int FindBaseId(MySqlConnection connection, string baseName, MySqlTransaction transaction = null) =>
+ connection.ExecuteScalar(
+ $"SELECT id FROM `{BasesTable}` WHERE base_name = @name AND product_id = @pid;",
+ new { name = baseName, pid = productId }, transaction) ?? 0;
+
+ public int UpsertBase(MySqlConnection connection, MySqlTransaction transaction, DbInfo dbInfo)
+ {
+ UpsertBases(connection, new[] { ToBaseRow(dbInfo) }, transaction);
+
+ return FindBaseId(connection, dbInfo.BaseName, transaction);
+ }
+
+ private BaseRow ToBaseRow(DbInfo dbInfo) => new BaseRow {
+ ProductId = productId,
+ BaseName = dbInfo.BaseName,
+ BaseTitle = dbInfo.Title,
+ Version = dbInfo.Version
+ };
+
+ public bool SyncWithDelete(DbInfo dbInfo)
+ {
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ using(var transaction = connection.BeginTransaction()) {
+ bool deleted = SyncWithDelete(dbInfo, transaction);
+ transaction.Commit();
+ return deleted;
+ }
+ }
+ }
+
+ /// Транзакцию коммитит вызывающий
+ public bool SyncWithDelete(DbInfo dbInfo, MySqlTransaction transaction)
+ {
+ // в удаление приходит и база, созданная только что при пересоздании: у неё известно лишь имя
+ int baseId = dbInfo.BaseId > 0
+ ? dbInfo.BaseId
+ : FindBaseId(transaction.Connection, dbInfo.BaseName, transaction);
+
+ if(baseId <= 0) {
+ logger.Debug("База {0} в метабазе не значится, удалять нечего", dbInfo.BaseName);
+ return false;
+ }
+
+ foreach(var dependency in BaseDependencies)
+ transaction.Connection.Execute($"DELETE FROM `{dependency}` WHERE base_id = @id;",
+ new { id = baseId }, transaction);
+ transaction.Connection.Execute($"DELETE FROM `{BasesTable}` WHERE id = @id;", new { id = baseId }, transaction);
+
+ logger.Info("Удалена база {0} продукта {1}", dbInfo.BaseName, productId);
+ return true;
+ }
+
+ private static readonly string[] BaseMetaParameters = { "ProductCode", "version", "BaseTitle" };
+
+ /// null - параметров базы нет либо в них нет кода продукта
+ private static BaseRow ToBaseMeta(string dbName, IReadOnlyDictionary> byDatabase) {
+ if(!byDatabase.TryGetValue(dbName, out var parameters))
+ return null;
+
+ if(!parameters.TryGetValue("ProductCode", out var code) || !byte.TryParse(code, out var productCode))
+ return null;
+
+ return new BaseRow
+ {
+ ProductId = productCode,
+ BaseName = dbName,
+ Version = Parameter(parameters, "version"),
+ BaseTitle = Parameter(parameters, "BaseTitle")
+ };
+ }
+
+ private static string Parameter(IReadOnlyDictionary parameters, string name)
+ => parameters.TryGetValue(name, out var value) ? value : null;
+
+ /// Строка таблицы bases, как её пишет синхронизация
+ private sealed class BaseRow {
+ public byte ProductId { get; set; }
+ public string BaseName { get; set; }
+ public string BaseTitle { get; set; }
+ public string Version { get; set; }
+ }
+ }
+}
diff --git a/QS.DbManagement/MariaDb/QSLauncher/LauncherMetadataManagement.cs b/QS.DbManagement/MariaDb/QSLauncher/LauncherMetadataManagement.cs
new file mode 100644
index 000000000..9404f4aca
--- /dev/null
+++ b/QS.DbManagement/MariaDb/QSLauncher/LauncherMetadataManagement.cs
@@ -0,0 +1,47 @@
+using Dapper;
+using MySqlConnector;
+using QS.DbManagement.Entities;
+
+namespace QS.DbManagement.MariaDb.QSLauncher {
+ internal class LauncherMetadataManagement {
+ public const string LauncherBaseName = "QSLauncher";
+
+ private readonly string connectionString;
+ public LauncherBasesManagement Bases { get; }
+ public LauncherUsersManagement Users { get; }
+
+ public LauncherMetadataManagement(MySqlConnectionStringBuilder connectionBuilder, bool isServerAdmin,
+ string login, byte productId)
+ {
+ Bases = new LauncherBasesManagement(connectionBuilder, isServerAdmin, productId);
+ Users = new LauncherUsersManagement(connectionBuilder, login, isServerAdmin, Bases);
+
+ // строку правим на копии: builder принадлежит вызывающему
+ var toLauncher = new MySqlConnectionStringBuilder(connectionBuilder.ConnectionString) {
+ Database = LauncherBaseName
+ };
+ connectionString = toLauncher.ConnectionString;
+
+ EnsureAvailable();
+ }
+
+ private void EnsureAvailable() {
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ connection.ExecuteScalar("SELECT 1 FROM `bases` LIMIT 1;");
+ }
+ }
+
+ public int CreateBaseWithCreatorAccess(DbInfo dbInfo) {
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ using(var transaction = connection.BeginTransaction()) {
+ int baseId = Bases.UpsertBase(connection, transaction, dbInfo);
+ Users.GrantCreatorUpdateRight(connection, transaction, baseId);
+ transaction.Commit();
+ return baseId;
+ }
+ }
+ }
+ }
+}
diff --git a/QS.DbManagement/MariaDb/QSLauncher/LauncherUsersManagement.cs b/QS.DbManagement/MariaDb/QSLauncher/LauncherUsersManagement.cs
new file mode 100644
index 000000000..aea4d2c0a
--- /dev/null
+++ b/QS.DbManagement/MariaDb/QSLauncher/LauncherUsersManagement.cs
@@ -0,0 +1,276 @@
+using Dapper;
+using MySqlConnector;
+using QS.DbManagement.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace QS.DbManagement.MariaDb.QSLauncher {
+ internal class LauncherUsersManagement
+ {
+ private const string LauncherBaseName = LauncherMetadataManagement.LauncherBaseName;
+ private const string UsersTable = "server_users";
+ private const string UpdateRightsTable = "base_update_rights";
+ private const int ER_DUP_ENTRY = 1062;
+
+ private const string UserNotFound = "Пользователь с указанным именем не найден";
+ private const string LoginTaken = "Такое имя пользователя уже занято";
+ private const string BaseNotFound = "База не найдена";
+
+ private readonly string connectionString;
+ private readonly string login;
+ private readonly bool isAdmin;
+ private readonly LauncherBasesManagement bases;
+
+ public LauncherUsersManagement(MySqlConnectionStringBuilder connectionBuilder, string login, bool isAdmin,
+ LauncherBasesManagement bases) {
+ // строку правим на копии: builder принадлежит вызывающему
+ var toLauncher = new MySqlConnectionStringBuilder(connectionBuilder.ConnectionString) {
+ Database = LauncherBaseName,
+ AllowLoadLocalInfile = true
+ };
+ connectionString = toLauncher.ConnectionString;
+ this.login = login ?? throw new ArgumentNullException(nameof(login));
+ this.isAdmin = isAdmin;
+ this.bases = bases ?? throw new ArgumentNullException(nameof(bases));
+ }
+
+ public IEnumerable GetUsers() {
+ RequireAdminFor("просмотра пользователей");
+
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ return connection.Query(
+ $"SELECT {UserSelect} FROM `{UsersTable}` ORDER BY login;").ToList();
+ }
+ }
+
+ public LauncherUserInfo GetUserByLogin(string login) {
+ RequireAdminFor("просмотра пользователя");
+
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ return connection.QueryFirstOrDefault(
+ $"SELECT {UserSelect} FROM `{UsersTable}` WHERE login = @login;",
+ new { login });
+ }
+ }
+
+ public bool CreateUser(LauncherUserInfo user) {
+ RequireAdminFor("управления пользователями");
+
+ using(var connection = new MySqlConnection(connectionString)) {
+ try {
+ connection.Open();
+
+ int taken = connection.ExecuteScalar(
+ $"SELECT COUNT(*) FROM `{UsersTable}` WHERE login = @login;",
+ new { user.Login });
+ if(taken != 0)
+ throw new ArgumentException(LoginTaken, nameof(user));
+
+ DynamicParameters parameters = new DynamicParameters();
+ FillCreateUserCollumnsParams(user, parameters);
+
+ string query = $"INSERT INTO `{UsersTable}` ({GetLabelsUserStr(UserCreateColumns)}) " +
+ $"VALUES ({GetValuesUserStr(UserCreateColumns)});";
+ return connection.Execute(query, parameters) > 0;
+ }
+ catch(MySqlException ex) when(ex.Number == ER_DUP_ENTRY) { //логин заняли между проверкой и вставкой
+ throw new ArgumentException(LoginTaken, nameof(user), ex);
+ }
+ }
+ }
+
+ /// Пустую строку из формы кладём как NULL
+ private static string Blank(string value) => string.IsNullOrEmpty(value) ? null : value;
+
+ public bool UpdateUser(LauncherUserInfo user) {
+ RequireAdminFor("управления пользователями");
+ if(user.Id <= 0)
+ throw new ArgumentException(UserNotFound, nameof(user));
+
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+
+ DynamicParameters parameters = new DynamicParameters();
+ FillUpdateUserCollumnsParams(user, parameters);
+ parameters.Add("id", user.Id);
+
+ connection.Execute($"UPDATE `{UsersTable}` SET {GetSetUserStr(UserUpdateColumns)} WHERE id = @id;", parameters);
+ }
+ return true;
+ }
+
+ public bool DeleteUser(LauncherUserInfo user) {
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ using(var transaction = connection.BeginTransaction()) {
+ bool deleted = DeleteUser(user, transaction);
+ transaction.Commit();
+ return deleted;
+ }
+ }
+ }
+
+ /// Транзакцию коммитит вызывающий
+ public bool DeleteUser(LauncherUserInfo user, MySqlTransaction transaction) {
+ RequireAdminFor("управления пользователями");
+ if(user.Id <= 0)
+ throw new ArgumentException(UserNotFound, nameof(user));
+
+ transaction.Connection.Execute($"DELETE FROM `{UpdateRightsTable}` WHERE `user_id` = @userId", new { userId = user.Id }, transaction);
+ int rowsAffected = transaction.Connection.Execute($"DELETE FROM `{UsersTable}` WHERE `id` = @userId", new { userId = user.Id }, transaction);
+
+ return rowsAffected > 0;
+ }
+
+ /// число учёток сервера, сопоставленных с метабазой
+ public int SyncUsers(IEnumerable serverUsers) {
+ RequireAdminFor("синхронизации пользователей");
+
+ var present = (serverUsers ?? Enumerable.Empty())
+ .Where(u => !string.IsNullOrEmpty(u?.Login))
+ .ToList();
+
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ DisableMissingUsers(connection, present.ConvertAll(u => u.Login));
+ InsertMissingUsers(connection, present);
+ return present.Count;
+ }
+ }
+ private void DisableMissingUsers(MySqlConnection connection, List presentLogins) {
+ var keep = presentLogins.Concat(MySqlSystemObjects.Users).ToList();
+
+ connection.Execute(
+ $"UPDATE `{UsersTable}` SET disabled = TRUE WHERE login NOT IN @keep;",
+ new { keep });
+ }
+
+ private void InsertMissingUsers(MySqlConnection connection, List serverUsers)
+ {
+ int userCount = serverUsers.Count;
+ if(userCount <= 0)
+ return;
+
+ List values = new List(userCount);
+ DynamicParameters parameters = new DynamicParameters();
+ for(int i = 0; i < userCount; i++)
+ {
+ values.Add($"({GetValuesUserStr(UserCreateColumns, i)})");
+ FillCreateUserCollumnsParams(serverUsers[i], parameters, i);
+ }
+
+ connection.Execute(
+ $"INSERT INTO `{UsersTable}` ({GetLabelsUserStr(UserCreateColumns)})"
+ + " VALUES "
+ + string.Join(", ", values)
+ + " ON DUPLICATE KEY UPDATE `login` = `login`;"
+ , parameters);
+ }
+
+
+ private static readonly string[] UserKeyColumns = { "login" };
+ private static readonly string[] UserUpdateColumns = { "name", "email", "phone", "is_admin", "disabled" };
+ private static readonly string[] UserCreateColumns = UserKeyColumns.Concat(UserUpdateColumns).ToArray();
+ private static void FillCreateUserCollumnsParams(LauncherUserInfo user, DynamicParameters parameters, int i = 0)
+ {
+ parameters.Add($"login{i}", user.Login);
+ FillUpdateUserCollumnsParams(user, parameters, i);
+ }
+ private static void FillUpdateUserCollumnsParams(LauncherUserInfo user, DynamicParameters parameters, int i = 0)
+ {
+ parameters.Add($"name{i}", Blank(user.Name));
+ parameters.Add($"email{i}", Blank(user.Email));
+ parameters.Add($"phone{i}", Blank(user.Phone));
+ parameters.Add($"is_admin{i}", user.IsAdmin);
+ parameters.Add($"disabled{i}", user.Disabled);
+ }
+
+ private static string GetLabelsUserStr(string[] columns)
+ {
+ return string.Join(", ", columns.Select(c => "`" + c + "`"));
+ }
+ private static string GetValuesUserStr(string[] columns, int i = 0)
+ {
+ return string.Join(", ", columns.Select(c => "@" + c + i.ToString()));
+ }
+ private static string GetSetUserStr(string[] columns, int i = 0)
+ {
+ return string.Join(", ", columns.Select(c => $"`{c}` = @{c}{i}"));
+ }
+ private const string UserSelect =
+ "`id` AS Id, `login` AS Login, `name` AS Name, " +
+ "`email` AS Email, `phone` AS Phone, `is_admin` AS IsAdmin, `disabled` AS Disabled";
+
+ private int EnsureOwnRow(MySqlConnection connection, MySqlTransaction transaction)
+ {
+ connection.Execute(
+ $"INSERT INTO `{UsersTable}` (`login`, `is_admin`) VALUES (@login, @is_admin) " +
+ "ON DUPLICATE KEY UPDATE `login` = `login`;",
+ new { login, is_admin = isAdmin }, transaction);
+
+ // логин уникален на всю метабазу
+ return connection.ExecuteScalar(
+ $"SELECT `id` FROM `{UsersTable}` WHERE `login` = @login;", new { login }, transaction);
+ }
+
+ #region Право на обновление базы
+
+ public bool SetBaseUpdateRight(string baseName, LauncherUserInfo user, bool canUpdate) {
+ RequireAdminFor("изменения прав на обновление баз");
+ if(user.Id <= 0)
+ throw new ArgumentException(UserNotFound, nameof(user));
+
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ int baseId = RequireBaseId(connection, baseName);
+
+ connection.Execute(
+ $"INSERT INTO `{UpdateRightsTable}` (user_id, base_id, can_update) VALUES (@uid, @bid, @canUpdate) " +
+ "ON DUPLICATE KEY UPDATE can_update = VALUES(can_update);",
+ new { uid = user.Id, bid = baseId, canUpdate });
+ }
+ return true;
+ }
+
+ public bool RevokeBaseUpdateRight(string baseName, LauncherUserInfo user) {
+ RequireAdminFor("изменения прав на обновление баз");
+ if(user.Id <= 0)
+ throw new ArgumentException(UserNotFound, nameof(user));
+
+ using(var connection = new MySqlConnection(connectionString)) {
+ connection.Open();
+ int baseId = RequireBaseId(connection, baseName);
+
+ connection.Execute($"DELETE FROM `{UpdateRightsTable}` WHERE user_id = @uid AND base_id = @bid;",
+ new { uid = user.Id, bid = baseId });
+ }
+ return true;
+ }
+
+ /// Создавший базу может её обновлять
+ public void GrantCreatorUpdateRight(MySqlConnection connection, MySqlTransaction transaction, int baseId) {
+ connection.Execute(
+ $"INSERT INTO `{UpdateRightsTable}` (user_id, base_id, can_update) VALUES (@user_id, @base_id, 1) " +
+ "ON DUPLICATE KEY UPDATE can_update = VALUES(can_update);",
+ new { user_id = EnsureOwnRow(connection, transaction), base_id = baseId }, transaction);
+ }
+
+ private int RequireBaseId(MySqlConnection connection, string baseName) {
+ int baseId = bases.FindBaseId(connection, baseName);
+ if(baseId <= 0)
+ throw new ArgumentException(BaseNotFound, nameof(baseName));
+ return baseId;
+ }
+
+ #endregion
+
+ private void RequireAdminFor(string action) {
+ // право на весь сервер; своей записи в метабазе при этом может и не быть
+ if(!isAdmin)
+ throw new UnauthorizedAccessException($"Недостаточно прав для {action}");
+ }
+ }
+}
diff --git a/QS.DbManagement/QS.DbManagement.csproj b/QS.DbManagement/QS.DbManagement.csproj
index 60196acee..fd48be251 100644
--- a/QS.DbManagement/QS.DbManagement.csproj
+++ b/QS.DbManagement/QS.DbManagement.csproj
@@ -14,6 +14,7 @@
+
@@ -25,7 +26,10 @@
+
+
+
diff --git a/QS.DbManagement/SqlDumpFileValidator.cs b/QS.DbManagement/SqlDumpFileValidator.cs
new file mode 100644
index 000000000..ac8ccd771
--- /dev/null
+++ b/QS.DbManagement/SqlDumpFileValidator.cs
@@ -0,0 +1,60 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace QS.DbManagement {
+ public static class SqlDumpFileValidator {
+ private const int InspectBytes = 8 * 1024;
+
+ private static readonly string[] SqlStartTokens = {
+ "--", "/*", "#", "CREATE", "INSERT", "REPLACE", "DROP", "ALTER",
+ "SET ", "USE ", "LOCK", "DELIMITER", "START TRANSACTION"
+ };
+
+ public static string Validate(string filePath) {
+ try {
+ EnsureLooksLikeSqlDump(filePath);
+ return null;
+ }
+ catch(Exception ex) when(ex is IOException || ex is InvalidDataException || ex is ArgumentException) {
+ return ex.Message;
+ }
+ }
+
+ ///
+ /// Бросает исключение, если файл отсутствует, пуст, бинарный или не начинается с SQL синтаксиса
+ ///
+ public static void EnsureLooksLikeSqlDump(string filePath) {
+ if(string.IsNullOrWhiteSpace(filePath))
+ throw new ArgumentException("Не указан путь к файлу дампа", nameof(filePath));
+ if(!File.Exists(filePath))
+ throw new FileNotFoundException("Файл дампа не найден", filePath);
+ if(new FileInfo(filePath).Length == 0)
+ throw new InvalidDataException("Файл дампа пуст");
+
+ string head = ReadHead(filePath, InspectBytes);
+
+ // Бинарный файл содержит нулевые байты
+ if(head.IndexOf('\0') >= 0)
+ throw new InvalidDataException(
+ "Файл не похож на SQL-дамп: это бинарный файл, а не текстовый SQL-скрипт");
+
+ string trimmed = head.TrimStart('', ' ', '\t', '\r', '\n');
+ bool looksLikeSql = SqlStartTokens.Any(
+ token => trimmed.StartsWith(token, StringComparison.OrdinalIgnoreCase));
+ if(!looksLikeSql)
+ throw new InvalidDataException(
+ "Файл не похож на SQL-дамп: в начале файла нет SQL-инструкций");
+ }
+
+ private static string ReadHead(string filePath, int maxBytes) {
+ using(var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) {
+ int toRead = (int)Math.Min(maxBytes, stream.Length);
+ var buffer = new byte[toRead];
+ int read = stream.Read(buffer, 0, toRead);
+ return Encoding.UTF8.GetString(buffer, 0, read);
+ }
+ }
+ }
+}
diff --git a/QS.Launcher.Avalonia/DependencyInjection.cs b/QS.Launcher.Avalonia/DependencyInjection.cs
index 6af20d57e..0fd566736 100644
--- a/QS.Launcher.Avalonia/DependencyInjection.cs
+++ b/QS.Launcher.Avalonia/DependencyInjection.cs
@@ -1,18 +1,16 @@
using Avalonia.Controls;
using Microsoft.Extensions.DependencyInjection;
+using QS.Dialog;
using QS.Launcher.Views;
using QS.Launcher.Views.Pages;
+using QS.Launcher.Views.Pages.Database;
namespace QS.Launcher;
public static partial class DependencyInjection {
public static IServiceCollection AddPages(this IServiceCollection services) {
return services
.AddSingleton()
- .AddSingleton()
- .AddSingleton()
- .AddSingleton()
- .AddSingleton();
+ .AddSingleton()
+ .AddSingleton();
}
-
-
}
diff --git a/QS.Launcher.Avalonia/LauncherApp.axaml b/QS.Launcher.Avalonia/LauncherApp.axaml
index e0f3cd31e..b850d3a74 100644
--- a/QS.Launcher.Avalonia/LauncherApp.axaml
+++ b/QS.Launcher.Avalonia/LauncherApp.axaml
@@ -2,6 +2,10 @@
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters1="clr-namespace:QS.Project.Converters;assembly=QS.Project.Avalonia"
+ xmlns:vm="clr-namespace:QS.Launcher.ViewModels.PageViewModels;assembly=QS.Launcher"
+ xmlns:vmDb="clr-namespace:QS.Launcher.ViewModels.PageViewModels.Database;assembly=QS.Launcher"
+ xmlns:views="using:QS.Launcher.Views.Pages"
+ xmlns:viewsDb="using:QS.Launcher.Views.Pages.Database"
x:CompileBindings="False"
RequestedThemeVariant="Light">
diff --git a/QS.Launcher.Avalonia/LauncherApp.axaml.cs b/QS.Launcher.Avalonia/LauncherApp.axaml.cs
index 1a7d531c5..4436804f2 100644
--- a/QS.Launcher.Avalonia/LauncherApp.axaml.cs
+++ b/QS.Launcher.Avalonia/LauncherApp.axaml.cs
@@ -1,6 +1,8 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
+using QS.Dialog;
+using QS.ErrorReporting;
using QS.Launcher.Views;
using System;
@@ -10,11 +12,17 @@ public partial class LauncherApp() : Application
{
public Func MainWindowGetter { get; set; }
+ /// Разбор ошибок для глобального перехватчика. Не задан - сообщение показывается без разбора
+ public IErrorHandlingService ErrorHandling { get; set; }
+
public override void Initialize() {
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted() {
+ RxAppExceptionHandler.Install(ErrorHandling);
+ DispatcherExceptionHandler.Install(ErrorHandling);
+
if (MainWindowGetter is null)
throw new ArgumentNullException(nameof(MainWindowGetter));
diff --git a/QS.Launcher.Avalonia/QS.Launcher.Avalonia.csproj b/QS.Launcher.Avalonia/QS.Launcher.Avalonia.csproj
index 0573db99d..8daf828ad 100644
--- a/QS.Launcher.Avalonia/QS.Launcher.Avalonia.csproj
+++ b/QS.Launcher.Avalonia/QS.Launcher.Avalonia.csproj
@@ -47,14 +47,29 @@
LauncherApp.axaml
-
- BaseManagementView.axaml
-
LoginView.axaml
-
- DataBasesView.axaml
+
+ DatabasesView.axaml
+
+
+ CreateDbSettingsView.axaml
+
+
+ ImportDbSettingsView.axaml
+
+
+ BackupDbSettingsView.axaml
+
+
+ CreateDatabaseProgressView.axaml
+
+
+ ChangePasswordView.axaml
+
+
+ UsersView.axaml
UserManagementView.axaml
diff --git a/QS.Launcher.Avalonia/Views/MainWindow.axaml b/QS.Launcher.Avalonia/Views/MainWindow.axaml
index b678546ae..f14ae2f03 100644
--- a/QS.Launcher.Avalonia/Views/MainWindow.axaml
+++ b/QS.Launcher.Avalonia/Views/MainWindow.axaml
@@ -3,7 +3,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- xmlns:views="clr-namespace:QS.Launcher.Views"
xmlns:vm="clr-namespace:QS.Launcher.ViewModels;assembly=QS.Launcher"
Title="QS.Лаунчер"
Width="450"
@@ -15,13 +14,20 @@
x:DataType="vm:MainWindowVM"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QS.Launcher.Avalonia/Views/MainWindow.axaml.cs b/QS.Launcher.Avalonia/Views/MainWindow.axaml.cs
index d17804705..56f391fff 100644
--- a/QS.Launcher.Avalonia/Views/MainWindow.axaml.cs
+++ b/QS.Launcher.Avalonia/Views/MainWindow.axaml.cs
@@ -1,22 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using QS.Launcher.ViewModels;
-using System.Collections.Generic;
+using QS.Launcher.ViewModels.PageViewModels;
+
namespace QS.Launcher.Views;
-public partial class MainWindow : Window {
- public MainWindow(MainWindowVM vm, IEnumerable pages, LauncherOptions options) {
+public partial class MainWindow : Window
+{
+ private readonly PageViewLocator viewLocator;
+
+ public MainWindow(MainWindowVM vm, PageViewLocator viewLocator, LauncherOptions options) {
InitializeComponent();
+ this.viewLocator = viewLocator ?? throw new ArgumentNullException(nameof(viewLocator));
+
Icon = new WindowIcon(new Bitmap(new System.IO.MemoryStream(options.LogoIcon)));
Title = options.AppTitle;
Closing += (_, _) => vm.SaveConnections();
- foreach(var page in pages)
- carousel.Items.Add(page);
- vm.PagesCount = carousel.ItemCount;
+ // корневые страницы:
+ foreach(var page in vm.Navigation.Pages)
+ carousel.Items.Add(viewLocator.Resolve(page));
+
+ vm.Navigation.Pages.CollectionChanged += OnPagesChanged;
DataContext = vm;
}
+
+ ///
+ /// Поддерживает carousel.Items в соответствии со стеком
+ ///
+ private void OnPagesChanged(object? sender, NotifyCollectionChangedEventArgs e) {
+ switch(e.Action) {
+ case NotifyCollectionChangedAction.Add:
+ for(int i = 0; i < e.NewItems!.Count; i++)
+ carousel.Items.Insert(e.NewStartingIndex + i,
+ viewLocator.Resolve((CarouselPageVM)e.NewItems[i]!));
+ break;
+
+ case NotifyCollectionChangedAction.Remove:
+ for(int i = 0; i < e.OldItems!.Count; i++)
+ carousel.Items.RemoveAt(e.OldStartingIndex);
+ break;
+
+ case NotifyCollectionChangedAction.Reset:
+ carousel.Items.Clear();
+ foreach(var page in (IEnumerable)sender!)
+ carousel.Items.Add(viewLocator.Resolve(page));
+ break;
+ }
+ }
}
diff --git a/QS.Launcher.Avalonia/Views/PageViewLocator.cs b/QS.Launcher.Avalonia/Views/PageViewLocator.cs
new file mode 100644
index 000000000..962cb9ae0
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/PageViewLocator.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using QS.Launcher.ViewModels.PageViewModels;
+using QS.Launcher.ViewModels.PageViewModels.Database;
+using QS.Launcher.Views.Pages;
+using QS.Launcher.Views.Pages.Database;
+
+namespace QS.Launcher.Views;
+
+///
+/// Сопоставляет VM страницы её View,
+/// чтобы добавить новый тип страницы, достаточно зарегистрировать здесь пару (VM, View)
+///
+public class PageViewLocator {
+ private readonly Dictionary> factories;
+
+ public PageViewLocator() {
+ factories = new Dictionary> {
+ [typeof(LoginVM)] = vm => new LoginView((LoginVM)vm),
+ [typeof(DatabasesVM)] = vm => new DatabasesView((DatabasesVM)vm),
+ [typeof(UsersVM)] = vm => new UsersView((UsersVM)vm),
+ [typeof(ChangePasswordVM)] = vm => new ChangePasswordView((ChangePasswordVM)vm),
+ [typeof(UserManagementVM)] = vm => new UserManagementView((UserManagementVM)vm),
+ [typeof(CreateDbSettingsVM)] = vm => new CreateDbSettingsView((CreateDbSettingsVM)vm),
+ [typeof(ImportDbSettingsVM)] = vm => new ImportDbSettingsView((ImportDbSettingsVM)vm),
+ [typeof(BackupDbSettingsVM)] = vm => new BackupDbSettingsView((BackupDbSettingsVM)vm),
+ [typeof(CreateDatabaseProgressVM)] = vm => new CreateDatabaseProgressView((CreateDatabaseProgressVM)vm),
+ };
+ }
+
+ public UserControl Resolve(CarouselPageVM page) {
+ if(page == null)
+ throw new ArgumentNullException(nameof(page));
+ if(factories.TryGetValue(page.GetType(), out var factory))
+ return factory(page);
+ throw new InvalidOperationException($"Не зарегистрирован View для страницы {page.GetType().Name}.");
+ }
+}
diff --git a/QS.Launcher.Avalonia/Views/Pages/BaseManagementView.axaml b/QS.Launcher.Avalonia/Views/Pages/BaseManagementView.axaml
deleted file mode 100644
index b779d92a1..000000000
--- a/QS.Launcher.Avalonia/Views/Pages/BaseManagementView.axaml
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/QS.Launcher.Avalonia/Views/Pages/ChangePasswordView.axaml b/QS.Launcher.Avalonia/Views/Pages/ChangePasswordView.axaml
new file mode 100644
index 000000000..ae24ade2d
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/ChangePasswordView.axaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QS.Launcher.Avalonia/Views/Pages/BaseManagementView.axaml.cs b/QS.Launcher.Avalonia/Views/Pages/ChangePasswordView.axaml.cs
similarity index 58%
rename from QS.Launcher.Avalonia/Views/Pages/BaseManagementView.axaml.cs
rename to QS.Launcher.Avalonia/Views/Pages/ChangePasswordView.axaml.cs
index e6332d279..680403c67 100644
--- a/QS.Launcher.Avalonia/Views/Pages/BaseManagementView.axaml.cs
+++ b/QS.Launcher.Avalonia/Views/Pages/ChangePasswordView.axaml.cs
@@ -3,9 +3,10 @@
namespace QS.Launcher.Views.Pages;
-public partial class BaseManagementView : UserControl {
- public BaseManagementView(BaseManagementVM viewModel) {
+public partial class ChangePasswordView : UserControl {
+ public ChangePasswordView(ChangePasswordVM viewModel) {
InitializeComponent();
+
DataContext = viewModel;
}
}
diff --git a/QS.Launcher.Avalonia/Views/Pages/DataBasesView.axaml b/QS.Launcher.Avalonia/Views/Pages/DataBasesView.axaml
deleted file mode 100644
index 436cfe89c..000000000
--- a/QS.Launcher.Avalonia/Views/Pages/DataBasesView.axaml
+++ /dev/null
@@ -1,98 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/QS.Launcher.Avalonia/Views/Pages/Database/BackupDbSettingsView.axaml b/QS.Launcher.Avalonia/Views/Pages/Database/BackupDbSettingsView.axaml
new file mode 100644
index 000000000..0c99e63dd
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/Database/BackupDbSettingsView.axaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QS.Launcher.Avalonia/Views/Pages/Database/BackupDbSettingsView.axaml.cs b/QS.Launcher.Avalonia/Views/Pages/Database/BackupDbSettingsView.axaml.cs
new file mode 100644
index 000000000..eb71efffd
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/Database/BackupDbSettingsView.axaml.cs
@@ -0,0 +1,42 @@
+using System.IO;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using QS.Launcher.ViewModels.PageViewModels.Database;
+
+namespace QS.Launcher.Views.Pages.Database;
+
+public partial class BackupDbSettingsView : UserControl {
+ public BackupDbSettingsView(BackupDbSettingsVM viewModel) {
+ InitializeComponent();
+
+ DataContext = viewModel;
+ }
+
+ private async void BrowseBackupFile_OnClick(object? sender, RoutedEventArgs e) {
+ if(DataContext is not BackupDbSettingsVM vm)
+ return;
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if(topLevel == null)
+ return;
+
+ var options = new FilePickerSaveOptions {
+ Title = "Сохранить резервную копию",
+ DefaultExtension = "sql",
+ SuggestedFileName = Path.GetFileName(vm.BackupFilePath),
+ FileTypeChoices = new[] { new FilePickerFileType("SQL-скрипт") { Patterns = new[] { "*.sql" } } }
+ };
+
+ var directory = Path.GetDirectoryName(vm.BackupFilePath);
+ if(!string.IsNullOrEmpty(directory)) {
+ var folder = await topLevel.StorageProvider.TryGetFolderFromPathAsync(directory);
+ if(folder != null)
+ options.SuggestedStartLocation = folder;
+ }
+
+ var file = await topLevel.StorageProvider.SaveFilePickerAsync(options);
+ if(file != null)
+ vm.BackupFilePath = file.Path.LocalPath;
+ }
+}
diff --git a/QS.Launcher.Avalonia/Views/Pages/Database/CreateDatabaseProgressView.axaml b/QS.Launcher.Avalonia/Views/Pages/Database/CreateDatabaseProgressView.axaml
new file mode 100644
index 000000000..48d9f8532
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/Database/CreateDatabaseProgressView.axaml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QS.Launcher.Avalonia/Views/Pages/Database/CreateDatabaseProgressView.axaml.cs b/QS.Launcher.Avalonia/Views/Pages/Database/CreateDatabaseProgressView.axaml.cs
new file mode 100644
index 000000000..a401f29c6
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/Database/CreateDatabaseProgressView.axaml.cs
@@ -0,0 +1,19 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using QS.Launcher.ViewModels.PageViewModels.Database;
+
+namespace QS.Launcher.Views.Pages.Database;
+
+public partial class CreateDatabaseProgressView : UserControl {
+ public CreateDatabaseProgressView(CreateDatabaseProgressVM progressVM) {
+ InitializeComponent();
+
+ DataContext = progressVM;
+ }
+
+ private void OnLoaded(object? sender, RoutedEventArgs e) {
+ if(DataContext is CreateDatabaseProgressVM vm)
+ vm.StartCommand.Execute().Subscribe();
+ }
+}
diff --git a/QS.Launcher.Avalonia/Views/Pages/Database/CreateDbSettingsView.axaml b/QS.Launcher.Avalonia/Views/Pages/Database/CreateDbSettingsView.axaml
new file mode 100644
index 000000000..a08dc79d9
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/Database/CreateDbSettingsView.axaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QS.Launcher.Avalonia/Views/Pages/Database/CreateDbSettingsView.axaml.cs b/QS.Launcher.Avalonia/Views/Pages/Database/CreateDbSettingsView.axaml.cs
new file mode 100644
index 000000000..8fd98310a
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/Database/CreateDbSettingsView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+using QS.Launcher.ViewModels.PageViewModels.Database;
+
+namespace QS.Launcher.Views.Pages.Database;
+
+public partial class CreateDbSettingsView : UserControl {
+ public CreateDbSettingsView(CreateDbSettingsVM viewModel) {
+ InitializeComponent();
+
+ DataContext = viewModel;
+ }
+}
diff --git a/QS.Launcher.Avalonia/Views/Pages/Database/DatabasesView.axaml b/QS.Launcher.Avalonia/Views/Pages/Database/DatabasesView.axaml
new file mode 100644
index 000000000..6a0ee7e35
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/Database/DatabasesView.axaml
@@ -0,0 +1,132 @@
+
+
+ M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z
+ M12,3C7.58,3 4,4.79 4,7C4,9.21 7.58,11 12,11C16.42,11 20,9.21 20,7C20,4.79 16.42,3 12,3M4,9V12C4,14.21 7.58,16 12,16C16.42,16 20,14.21 20,12V9C20,11.21 16.42,13 12,13C7.58,13 4,11.21 4,9M4,14V17C4,19.21 7.58,21 12,21C16.42,21 20,19.21 20,17V14C20,16.21 16.42,18 12,18C7.58,18 4,16.21 4,14Z
+ M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QS.Launcher.Avalonia/Views/Pages/DataBasesView.axaml.cs b/QS.Launcher.Avalonia/Views/Pages/Database/DatabasesView.axaml.cs
similarity index 57%
rename from QS.Launcher.Avalonia/Views/Pages/DataBasesView.axaml.cs
rename to QS.Launcher.Avalonia/Views/Pages/Database/DatabasesView.axaml.cs
index 08ca5d336..7c89d3b9e 100644
--- a/QS.Launcher.Avalonia/Views/Pages/DataBasesView.axaml.cs
+++ b/QS.Launcher.Avalonia/Views/Pages/Database/DatabasesView.axaml.cs
@@ -2,40 +2,40 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
-using QS.Launcher.ViewModels.PageViewModels;
+using Avalonia.Interactivity;
+using QS.DbManagement.Entities;
+using QS.Launcher.ViewModels.PageViewModels.Database;
using System.Linq;
using System.Threading.Tasks;
namespace QS.Launcher.Views.Pages;
-public partial class DataBasesView : UserControl {
+public partial class DatabasesView : UserControl {
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
- private DataBasesVM ViewModel;
- public DataBasesView(DataBasesVM viewModel) {
+ private readonly DatabasesVM ViewModel;
+
+ public DatabasesView(DatabasesVM viewModel) {
InitializeComponent();
DataContext = ViewModel = viewModel;
- viewModel.StartLaunchProgram += HandleStartMainProgram;
-
- KeyDown += (s, e) => {
- if(e.Key == Key.Enter) {
- TopLevel.GetTopLevel(this).FocusManager.ClearFocus();
- viewModel.ConnectCommand.Execute(null);
- }
- };
+ ViewModel.StartLaunchProgram += HandleStartMainProgram;
+ // Enter обрабатывают KeyBinding списка и кнопка «Запустить» с IsDefault - обе
+ // смотрят на CanExecute команды. Свой обработчик на всём UserControl запускал
+ // подключение и без выбранной базы
}
public async void HandleStartMainProgram(bool shouldCloseLauncher) {
logger.Info($">>> HandleStartMainProgram: shouldCloseLauncher={shouldCloseLauncher}");
-
+
loadingPanel.IsVisible = true;
cogwheel.Classes.Add("rolled");
var transition = cogwheel.Transitions.OfType().FirstOrDefault();
- await Task.Delay(transition.Duration);
+ if(transition != null)
+ await Task.Delay(transition.Duration);
loadingPanel.IsVisible = false;
-
+
if(shouldCloseLauncher) {
logger.Info($">>> HandleStartMainProgram: Вызываем Shutdown!");
// NewProcessRunner: закрываем всё приложение лаунчера (Shutdown)
@@ -49,13 +49,18 @@ public async void HandleStartMainProgram(bool shouldCloseLauncher) {
}
}
- public void Label_PointerPressed(object? sender, PointerPressedEventArgs e) {
- if(!adminPanel.Classes.Remove("invisible"))
- adminPanel.Classes.Add("invisible");
- }
-
private void Databases_OnDoubleTapped(object? sender, TappedEventArgs e) {
if(databases.SelectedItem is not null)
ViewModel.ConnectCommand.Execute(null);
}
+
+ private void BackupDatabase_OnClick(object? sender, RoutedEventArgs e) {
+ if((sender as Control)?.DataContext is DbInfo database)
+ ViewModel.BackupDatabaseCommand.Execute(database);
+ }
+
+ private void DeleteDatabase_OnClick(object? sender, RoutedEventArgs e) {
+ if((sender as Control)?.DataContext is DbInfo database)
+ ViewModel.DeleteDatabaseCommand.Execute(database);
+ }
}
diff --git a/QS.Launcher.Avalonia/Views/Pages/Database/ImportDbSettingsView.axaml b/QS.Launcher.Avalonia/Views/Pages/Database/ImportDbSettingsView.axaml
new file mode 100644
index 000000000..d89fd8714
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/Database/ImportDbSettingsView.axaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QS.Launcher.Avalonia/Views/Pages/Database/ImportDbSettingsView.axaml.cs b/QS.Launcher.Avalonia/Views/Pages/Database/ImportDbSettingsView.axaml.cs
new file mode 100644
index 000000000..1aa3d04b8
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/Database/ImportDbSettingsView.axaml.cs
@@ -0,0 +1,33 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using QS.Launcher.ViewModels.PageViewModels.Database;
+
+namespace QS.Launcher.Views.Pages.Database;
+
+public partial class ImportDbSettingsView : UserControl {
+ public ImportDbSettingsView(ImportDbSettingsVM viewModel) {
+ InitializeComponent();
+
+ DataContext = viewModel;
+ }
+
+ private async void BrowseImportFile_OnClick(object? sender, RoutedEventArgs e) {
+ if(DataContext is not ImportDbSettingsVM vm)
+ return;
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if(topLevel == null)
+ return;
+
+ var options = new FilePickerOpenOptions {
+ Title = "Выбрать дамп базы данных",
+ AllowMultiple = false,
+ FileTypeFilter = new[] { new FilePickerFileType("SQL-скрипт") { Patterns = new[] { "*.sql" } } }
+ };
+
+ var files = await topLevel.StorageProvider.OpenFilePickerAsync(options);
+ if(files.Count > 0)
+ vm.ImportDumpFilePath = files[0].Path.LocalPath;
+ }
+}
diff --git a/QS.Launcher.Avalonia/Views/Pages/LoginView.axaml.cs b/QS.Launcher.Avalonia/Views/Pages/LoginView.axaml.cs
index de2fcf8b0..f8ede92e6 100644
--- a/QS.Launcher.Avalonia/Views/Pages/LoginView.axaml.cs
+++ b/QS.Launcher.Avalonia/Views/Pages/LoginView.axaml.cs
@@ -2,10 +2,9 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media.Transformation;
-using Avalonia.Platform;
using Avalonia.Styling;
+using Avalonia.VisualTree;
using QS.Launcher.ViewModels.PageViewModels;
-using System.Globalization;
namespace QS.Launcher.Views.Pages;
@@ -13,8 +12,8 @@ public partial class LoginView : UserControl
{
private readonly Style upStyle;
- public LoginView(LoginVM viewModel)
- {
+ public LoginView(LoginVM viewModel)
+ {
upStyle = new Style(x => x.OfType().Class("up")) {
Setters =
{
@@ -24,7 +23,7 @@ public LoginView(LoginVM viewModel)
},
};
- InitializeComponent();
+ InitializeComponent();
loginContainer.Styles.Add(upStyle);
@@ -35,12 +34,15 @@ public LoginView(LoginVM viewModel)
};
KeyDown += (s, e) => {
- if(e.Key == Avalonia.Input.Key.Enter) {
- TopLevel.GetTopLevel(this).FocusManager.ClearFocus();
- viewModel.LoginCommand.Execute(null);
- }
+ if(e.Key != Key.Enter || !viewModel.CanLogin)
+ return;
+ if(e.Source is Visual source && createConnection.IsVisualAncestorOf(source))
+ return;
+
+ TopLevel.GetTopLevel(this)?.FocusManager?.ClearFocus();
+ viewModel.LoginCommand.Execute(null);
};
- }
+ }
private void ShowCreationView(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
diff --git a/QS.Launcher.Avalonia/Views/Pages/UserManagementView.axaml b/QS.Launcher.Avalonia/Views/Pages/UserManagementView.axaml
index 563c1f130..265338a3b 100644
--- a/QS.Launcher.Avalonia/Views/Pages/UserManagementView.axaml
+++ b/QS.Launcher.Avalonia/Views/Pages/UserManagementView.axaml
@@ -4,18 +4,110 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:QS.Launcher.ViewModels.PageViewModels;assembly=QS.Launcher"
- d:DesignHeight="450"
- d:DesignWidth="800"
+ xmlns:dbme="clr-namespace:QS.DbManagement.Entities;assembly=QS.DbManagement"
+ d:DesignHeight="650"
+ d:DesignWidth="450"
x:DataType="vm:UserManagementVM"
mc:Ignorable="d">
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QS.Launcher.Avalonia/Views/Pages/UserManagementView.axaml.cs b/QS.Launcher.Avalonia/Views/Pages/UserManagementView.axaml.cs
index ce3f1258f..5a4b62bc7 100644
--- a/QS.Launcher.Avalonia/Views/Pages/UserManagementView.axaml.cs
+++ b/QS.Launcher.Avalonia/Views/Pages/UserManagementView.axaml.cs
@@ -1,8 +1,5 @@
using Avalonia.Controls;
-using Microsoft.Extensions.DependencyInjection;
using QS.Launcher.ViewModels.PageViewModels;
-using System;
-using System.Linq;
namespace QS.Launcher.Views.Pages;
diff --git a/QS.Launcher.Avalonia/Views/Pages/UsersView.axaml b/QS.Launcher.Avalonia/Views/Pages/UsersView.axaml
new file mode 100644
index 000000000..cb7240398
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/UsersView.axaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QS.Launcher.Avalonia/Views/Pages/UsersView.axaml.cs b/QS.Launcher.Avalonia/Views/Pages/UsersView.axaml.cs
new file mode 100644
index 000000000..b7a3331fb
--- /dev/null
+++ b/QS.Launcher.Avalonia/Views/Pages/UsersView.axaml.cs
@@ -0,0 +1,21 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Input;
+using QS.Launcher.ViewModels.PageViewModels;
+
+namespace QS.Launcher.Views.Pages;
+
+public partial class UsersView : UserControl {
+ private readonly UsersVM viewModel;
+
+ public UsersView(UsersVM viewModel) {
+ InitializeComponent();
+
+ DataContext = this.viewModel = viewModel;
+ }
+
+ private void Users_OnDoubleTapped(object? sender, TappedEventArgs e) {
+ if(users.SelectedItem is not null)
+ viewModel.EditUserCommand.Execute().Subscribe();
+ }
+}
diff --git a/QS.Launcher.Gtk/DependencyInjection.cs b/QS.Launcher.Gtk/DependencyInjection.cs
index 55cc41531..10d63d276 100644
--- a/QS.Launcher.Gtk/DependencyInjection.cs
+++ b/QS.Launcher.Gtk/DependencyInjection.cs
@@ -7,7 +7,7 @@ public static IServiceCollection AddLauncherViews(this IServiceCollection servic
return services
.AddSingleton()
.AddSingleton()
- .AddSingleton();
+ .AddSingleton();
}
}
}
diff --git a/QS.Launcher.Gtk/QS.Launcher.Gtk.csproj b/QS.Launcher.Gtk/QS.Launcher.Gtk.csproj
index 873ce51b0..0e913bbb9 100644
--- a/QS.Launcher.Gtk/QS.Launcher.Gtk.csproj
+++ b/QS.Launcher.Gtk/QS.Launcher.Gtk.csproj
@@ -1,96 +1,96 @@
-
-
-
- Debug
- AnyCPU
- {A54C2769-77C5-41B8-BA3F-00F87733AC51}
- Library
- QS.Launcher
- QS.Launcher.Gtk
- v4.6.2
-
-
- true
- full
- false
- bin\Debug
- DEBUG;
- prompt
- 4
-
-
- true
- bin\Release
- prompt
- 4
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- gui.stetic
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {190CEA0D-EA23-4C91-8030-203A5AFED7B7}
- Gamma.Binding
-
-
- {cc0f3006-1b76-49bc-9093-e8cfb53de507}
- QS.DbManagement
-
-
- {5ef2a7da-3886-4809-95df-880bd783a211}
- QS.Project.Desktop
-
-
- {3C0F3944-CBD5-4BD6-83D4-CF33AD6FC68C}
- QS.Project.Gtk
-
-
- {533D2F8B-7EDF-4E91-BE31-DC92CA6F8B93}
- QS.Launcher
-
-
-
-
-
-
-
+
+
+
+ Debug
+ AnyCPU
+ {A54C2769-77C5-41B8-BA3F-00F87733AC51}
+ Library
+ QS.Launcher
+ QS.Launcher.Gtk
+ v4.6.2
+
+
+ true
+ full
+ false
+ bin\Debug
+ DEBUG;
+ prompt
+ 4
+
+
+ true
+ bin\Release
+ prompt
+ 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ gui.stetic
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {190CEA0D-EA23-4C91-8030-203A5AFED7B7}
+ Gamma.Binding
+
+
+ {cc0f3006-1b76-49bc-9093-e8cfb53de507}
+ QS.DbManagement
+
+
+ {5ef2a7da-3886-4809-95df-880bd783a211}
+ QS.Project.Desktop
+
+
+ {3C0F3944-CBD5-4BD6-83D4-CF33AD6FC68C}
+ QS.Project.Gtk
+
+
+ {533D2F8B-7EDF-4E91-BE31-DC92CA6F8B93}
+ QS.Launcher
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QS.Launcher.Gtk/Views/DataBasesView.cs b/QS.Launcher.Gtk/Views/DatabasesView.cs
similarity index 86%
rename from QS.Launcher.Gtk/Views/DataBasesView.cs
rename to QS.Launcher.Gtk/Views/DatabasesView.cs
index aabc416e2..c167a9178 100644
--- a/QS.Launcher.Gtk/Views/DataBasesView.cs
+++ b/QS.Launcher.Gtk/Views/DatabasesView.cs
@@ -5,9 +5,9 @@
namespace QS.Launcher.Views {
[System.ComponentModel.ToolboxItem(true)]
- public partial class DataBasesView : ViewBase {
+ public partial class DatabasesView : ViewBase {
- public DataBasesView(DataBasesVM viewModel) : base(viewModel) {
+ public DatabasesView(DatabasesVM viewModel) : base(viewModel) {
this.Build();
treeBases.CreateFluentColumnsConfig()
@@ -33,7 +33,7 @@ public DataBasesView(DataBasesVM viewModel) : base(viewModel) {
}
protected void OnButtonBackClicked(object sender, EventArgs e) {
- ViewModel.PreviousPageCommand.Execute(null);
+ ViewModel.BackCommand.Execute(null);
}
protected void OnButtonLoginInBaseClicked(object sender, EventArgs e) {
diff --git a/QS.Launcher.Gtk/Views/MainWindow.cs b/QS.Launcher.Gtk/Views/MainWindow.cs
index 21b760cab..bcad2f11e 100644
--- a/QS.Launcher.Gtk/Views/MainWindow.cs
+++ b/QS.Launcher.Gtk/Views/MainWindow.cs
@@ -9,7 +9,7 @@ public partial class MainWindow : Gtk.Window {
public MainWindowVM ViewModel { get; }
- public MainWindow(MainWindowVM viewModel, LauncherOptions options, LoginView loginView, DataBasesView dataBasesView) :
+ public MainWindow(MainWindowVM viewModel, LauncherOptions options, LoginView loginView, DatabasesView dataBasesView) :
base(Gtk.WindowType.Toplevel) {
this.Build();
diff --git a/QS.Launcher.Gtk/gtk-gui/QS.Launcher.Views.DataBasesView.cs b/QS.Launcher.Gtk/gtk-gui/QS.Launcher.Views.DatabasesView.cs
similarity index 94%
rename from QS.Launcher.Gtk/gtk-gui/QS.Launcher.Views.DataBasesView.cs
rename to QS.Launcher.Gtk/gtk-gui/QS.Launcher.Views.DatabasesView.cs
index 3d6fd470b..cc5118156 100644
--- a/QS.Launcher.Gtk/gtk-gui/QS.Launcher.Views.DataBasesView.cs
+++ b/QS.Launcher.Gtk/gtk-gui/QS.Launcher.Views.DatabasesView.cs
@@ -2,7 +2,7 @@
// This file has been generated by the GUI designer. Do not modify.
namespace QS.Launcher.Views
{
- public partial class DataBasesView
+ public partial class DatabasesView
{
private global::Gtk.VBox vbox2;
@@ -21,10 +21,10 @@ public partial class DataBasesView
protected virtual void Build()
{
global::Stetic.Gui.Initialize(this);
- // Widget QS.Launcher.Views.DataBasesView
+ // Widget QS.Launcher.Views.DatabasesView
global::Stetic.BinContainer.Attach(this);
- this.Name = "QS.Launcher.Views.DataBasesView";
- // Container child QS.Launcher.Views.DataBasesView.Gtk.Container+ContainerChild
+ this.Name = "QS.Launcher.Views.DatabasesView";
+ // Container child QS.Launcher.Views.DatabasesView.Gtk.Container+ContainerChild
this.vbox2 = new global::Gtk.VBox();
this.vbox2.Name = "vbox2";
this.vbox2.Spacing = 6;
diff --git a/QS.Launcher.Gtk/gtk-gui/gui.stetic b/QS.Launcher.Gtk/gtk-gui/gui.stetic
index 6cb2a72a9..05199087a 100644
--- a/QS.Launcher.Gtk/gtk-gui/gui.stetic
+++ b/QS.Launcher.Gtk/gtk-gui/gui.stetic
@@ -334,7 +334,7 @@
-
+
False
diff --git a/QS.Launcher/AppRunner/IAppRunner.cs b/QS.Launcher/AppRunner/IAppRunner.cs
index dfeb1f013..a1b3f9976 100644
--- a/QS.Launcher/AppRunner/IAppRunner.cs
+++ b/QS.Launcher/AppRunner/IAppRunner.cs
@@ -1,4 +1,4 @@
-using QS.DbManagement.Responces;
+using QS.DbManagement.Entities;
namespace QS.Launcher.AppRunner {
public interface IAppRunner {
diff --git a/QS.Launcher/AppRunner/InProcessRunner.cs b/QS.Launcher/AppRunner/InProcessRunner.cs
index 956006e06..cfb0f1739 100644
--- a/QS.Launcher/AppRunner/InProcessRunner.cs
+++ b/QS.Launcher/AppRunner/InProcessRunner.cs
@@ -1,5 +1,5 @@
using System;
-using QS.DbManagement.Responces;
+using QS.DbManagement.Entities;
namespace QS.Launcher.AppRunner {
public class InProcessRunner : IAppRunner {
diff --git a/QS.Launcher/AppRunner/NewProcessRunner.cs b/QS.Launcher/AppRunner/NewProcessRunner.cs
index f1640e9e0..a342cdf79 100644
--- a/QS.Launcher/AppRunner/NewProcessRunner.cs
+++ b/QS.Launcher/AppRunner/NewProcessRunner.cs
@@ -1,7 +1,6 @@
-using System;
using System.Diagnostics;
using System.IO;
-using QS.DbManagement.Responces;
+using QS.DbManagement.Entities;
namespace QS.Launcher.AppRunner {
public class NewProcessRunner : IAppRunner {
@@ -13,7 +12,7 @@ public NewProcessRunner(string exeFileName) {
public void Run(LoginToDatabaseResponse loginToDatabase) {
if (!File.Exists(this.exeFileName))
- throw new ArgumentException($"Запускаемого файла {exeFileName} не существует.");
+ throw new FileNotFoundException($"Запускаемого файла {exeFileName} не существует.", exeFileName);
var startInfo = new ProcessStartInfo {
WorkingDirectory = Path.GetDirectoryName(exeFileName),
@@ -27,7 +26,7 @@ public void Run(LoginToDatabaseResponse loginToDatabase) {
foreach (var par in loginToDatabase.Parameters)
startInfo.Environment["QS_" + par.Key] = par.Value;
- Process.Start(startInfo);
+ using(Process.Start(startInfo)) { }
}
}
}
diff --git a/QS.Launcher/Configurator.cs b/QS.Launcher/Configurator.cs
index 3bf0f5c6c..b9aed043f 100644
--- a/QS.Launcher/Configurator.cs
+++ b/QS.Launcher/Configurator.cs
@@ -10,11 +10,11 @@
namespace QS.Launcher {
public class Configurator {
- private NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly LauncherOptions options;
private readonly IInteractiveMessage interactive;
- private readonly IList connectionTypes;
+ private readonly List connectionTypes;
readonly JsonSerializerOptions serializerOptions = new JsonSerializerOptions() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
@@ -23,6 +23,7 @@ public Configurator(LauncherOptions options, IInteractiveMessage interactive, IE
this.interactive = interactive ?? throw new ArgumentNullException(nameof(interactive));
this.connectionTypes = connectionTypes?.ToList() ?? throw new ArgumentNullException(nameof(connectionTypes));
}
+ public IReadOnlyList ConnectionTypes => connectionTypes;
public IList ReadConnections() {
List> connectionDefinitions = new List>();
diff --git a/QS.Launcher/DependencyInjection.cs b/QS.Launcher/DependencyInjection.cs
index e0897f04f..e22c4a3da 100644
--- a/QS.Launcher/DependencyInjection.cs
+++ b/QS.Launcher/DependencyInjection.cs
@@ -1,19 +1,47 @@
using Autofac;
using Microsoft.Extensions.DependencyInjection;
+using QS.DBScripts.Controllers;
using QS.DbManagement;
+using QS.ErrorReporting;
+using QS.ErrorReporting.Handlers;
using QS.Launcher.AppRunner;
+using QS.Launcher.Services;
using QS.Launcher.ViewModels;
using QS.Launcher.ViewModels.PageViewModels;
+using QS.Launcher.ViewModels.PageViewModels.Database;
+using QS.DbManagement.Creation;
+using System;
+using System.Collections.Generic;
namespace QS.Launcher {
public static partial class DependencyInjection {
+ public static IServiceCollection AddLauncherDatabaseCreation(this IServiceCollection services, List<(Type res,Type creator)> resourceCratorMap)
+ {
+ var map = new DbResourcesCreationMap();
+ foreach(var resourceCrator in resourceCratorMap) {
+ map.Register(resourceCrator.res, resourceCrator.creator);
+ }
+
+ return services
+ .AddSingleton(map)
+ .AddSingleton();
+ }
+
public static IServiceCollection AddLauncherViewModels(this IServiceCollection services) {
return services
+ // стек базовых страниц
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
- .AddSingleton()
- .AddSingleton()
- .AddSingleton();
+ .AddSingleton()
+ .AddSingleton()
+ // Страницы разовой операции создаются заново на каждый вызов
+ .AddTransient()
+ .AddTransient()
+ .AddTransient()
+ .AddSingleton()
+
+ .AddSingleton();
}
public static IServiceCollection AddLauncherOptions(this IServiceCollection services, LauncherOptions launcherOptions) {
@@ -23,7 +51,22 @@ public static IServiceCollection AddLauncherOptions(this IServiceCollection serv
public static IServiceCollection AddLauncherDependencies(this IServiceCollection services) {
return services
.AddSingleton();
- }
+ }
+
+ ///
+ /// Разбор ошибок лаунчера
+ ///
+ public static IServiceCollection AddLauncherErrorHandling(this IServiceCollection services) {
+ return services
+ //обработчики просматриваются в порядке регистрации
+ //широкие условия лучше ставить ниже
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton();
+ }
public static IServiceCollection AddConnectionType(this IServiceCollection services, ConnectionTypeBase connectionType) {
services.AddSingleton(connectionType);
diff --git a/QS.Launcher/InteractiveQuestionExtensions.cs b/QS.Launcher/InteractiveQuestionExtensions.cs
new file mode 100644
index 000000000..e8f231832
--- /dev/null
+++ b/QS.Launcher/InteractiveQuestionExtensions.cs
@@ -0,0 +1,9 @@
+using QS.Dialog;
+using System.Threading.Tasks;
+
+namespace QS.Launcher {
+ public static class InteractiveQuestionExtensions {
+ public static Task AskInBackground(this IInteractiveQuestion question, string message, string title = null)
+ => Task.Run(() => question.Question(message, title));
+ }
+}
diff --git a/QS.Launcher/Services/LauncherDbCreatorInteraction.cs b/QS.Launcher/Services/LauncherDbCreatorInteraction.cs
new file mode 100644
index 000000000..4427f4297
--- /dev/null
+++ b/QS.Launcher/Services/LauncherDbCreatorInteraction.cs
@@ -0,0 +1,44 @@
+using Gamma.Utilities;
+using QS.DBScripts.Controllers;
+using QS.Dialog;
+using System;
+using System.Linq;
+
+namespace QS.Launcher.Services {
+ public class LauncherDbCreatorInteraction : IDbCreatorInteraction {
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
+ private readonly IInteractiveQuestion question;
+ private readonly IInteractiveMessage message;
+
+ public LauncherDbCreatorInteraction(
+ IInteractiveQuestion question,
+ IInteractiveMessage message)
+ {
+ this.question = question ?? throw new ArgumentNullException(nameof(question));
+ this.message = message ?? throw new ArgumentNullException(nameof(message));
+ }
+
+ public ToDoWithExistingDatabase AskDropExistingDatabase(string dbName)
+ {
+ ToDoWithExistingDatabase[] options = new ToDoWithExistingDatabase[] { ToDoWithExistingDatabase.Rewrite, ToDoWithExistingDatabase.Recreate, ToDoWithExistingDatabase.Nothing };
+ var buttons = options.Select(o => o.GetEnumTitle()).ToArray();
+ string response = question.Question(buttons,
+ $"База с именем `{dbName}` уже существует на сервере.\n" +
+ "Перезаписать - заменить содержимое базы, сохранив пользователей.\n" +
+ "Пересоздать - полностью удалить базу и создать заново.",
+ "Создание базы данных");
+ int idx = Array.IndexOf(buttons, response);
+ return idx >= 0 ? options[idx] : ToDoWithExistingDatabase.Nothing; // null при закрытии крестиком → Nothing
+ }
+
+ public void ReportError(string text, string lastExecutedStatement) {
+ if(string.IsNullOrEmpty(lastExecutedStatement))
+ logger.Error("Ошибка создания базы: {0}", text);
+ else
+ logger.Error("Ошибка создания базы: {0}. Последний выполненный запрос: {1}", text, lastExecutedStatement);
+
+ message.ShowMessage(ImportanceLevel.Error, text, "Ошибка создания базы");
+ }
+ }
+}
diff --git a/QS.Launcher/ViewModels/LauncherNavigation.cs b/QS.Launcher/ViewModels/LauncherNavigation.cs
new file mode 100644
index 000000000..ce83a4152
--- /dev/null
+++ b/QS.Launcher/ViewModels/LauncherNavigation.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.ObjectModel;
+using QS.Launcher.ViewModels.PageViewModels;
+using QS.ViewModels;
+using ReactiveUI;
+
+namespace QS.Launcher.ViewModels {
+ public class LauncherNavigation : ViewModelBase {
+ private int rootPagesCount;
+
+ public LauncherNavigation() {
+ Pages.CollectionChanged += (sender, e) => this.RaisePropertyChanged(nameof(CurrentPage));
+ }
+
+ public ObservableCollection Pages { get; } = new ObservableCollection();
+
+ private int selectedIndex;
+ public int SelectedIndex {
+ get => selectedIndex;
+ set {
+ this.RaiseAndSetIfChanged(ref selectedIndex, value);
+ this.RaisePropertyChanged(nameof(CurrentPage));
+ }
+ }
+
+ public CarouselPageVM CurrentPage =>
+ selectedIndex >= 0 && selectedIndex < Pages.Count
+ ? Pages[selectedIndex]
+ : null;
+
+ public void SetRoots(params CarouselPageVM[] roots) {
+ if(roots == null)
+ throw new ArgumentNullException(nameof(roots));
+
+ Pages.Clear();
+ foreach(var page in roots)
+ Pages.Add(page);
+
+ rootPagesCount = Pages.Count;
+ SelectedIndex = 0;
+ }
+
+ public void ChangePage(int index) {
+ if(index < 0 || index >= Pages.Count)
+ return;
+ SelectedIndex = index;
+ }
+
+ public void Next() {
+ if(rootPagesCount == 0)
+ return;
+ PopToRoot();
+ ChangePage((SelectedIndex + 1) % rootPagesCount);
+ }
+
+ public void Previous() {
+ if(rootPagesCount == 0)
+ return;
+ PopToRoot();
+ ChangePage((SelectedIndex - 1 + rootPagesCount) % rootPagesCount);
+ }
+
+ /// добавить страницу поверх текущей и перейти на неё
+ public void Push(CarouselPageVM page) {
+ if(page == null)
+ return;
+ Pages.Add(page);
+ SelectedIndex = Pages.Count - 1;
+ }
+
+ /// закрыть текущую нерутовую страницу и вернуться на предыдущую
+ public void Pop() {
+ if(Pages.Count <= rootPagesCount)
+ return;
+ int targetIndex = Pages.Count - 2;
+ SelectedIndex = targetIndex;
+ RemovePagesAbove(targetIndex);
+ }
+
+ /// закрыть все нерутовые страницы и вернуться к корневым вкладкам
+ public void PopToRoot()
+ {
+ int targetIndex = rootPagesCount - 1;
+ if(SelectedIndex > targetIndex)
+ SelectedIndex = targetIndex;
+ RemovePagesAbove(targetIndex);
+ }
+
+ /// Снимает всё, что стоит выше первой страницы указанного типа
+ public void PopTo(Type pageType) {
+ if(pageType == null)
+ return;
+
+ int targetIndex = -1;
+ for(int i = 0; i < Pages.Count; i++) {
+ if(pageType.IsInstanceOfType(Pages[i])) { targetIndex = i; break; }
+ }
+ if(targetIndex < 0)
+ return;
+
+ SelectedIndex = targetIndex;
+ RemovePagesAbove(targetIndex);
+ }
+
+ /// Только после смены SelectedIndex, иначе снятая видимая страница оставляет пустой экран
+ private void RemovePagesAbove(int keepIndex)
+ {
+ while(Pages.Count > keepIndex + 1) {
+ int last = Pages.Count - 1;
+ var page = Pages[last];
+ Pages.RemoveAt(last);
+ (page as IDisposable)?.Dispose();
+ }
+ }
+ }
+}
diff --git a/QS.Launcher/ViewModels/MainWindowVM.cs b/QS.Launcher/ViewModels/MainWindowVM.cs
index 8c04e209f..b715b9d2b 100644
--- a/QS.Launcher/ViewModels/MainWindowVM.cs
+++ b/QS.Launcher/ViewModels/MainWindowVM.cs
@@ -1,46 +1,29 @@
-using QS.Launcher.ViewModels.PageViewModels;
-using ReactiveUI;
using System;
+using QS.Launcher.ViewModels.PageViewModels;
+using QS.Launcher.ViewModels.PageViewModels.Database;
using QS.ViewModels;
namespace QS.Launcher.ViewModels {
public class MainWindowVM : ViewModelBase {
- public int PagesCount { get; set; }
-
- LoginVM login;
+ private readonly LoginVM login;
- private int selectedPageIndex;
- public int SelectedPageIndex {
- get => selectedPageIndex;
- set => this.RaiseAndSetIfChanged(ref selectedPageIndex, value);
- }
+ public LauncherNavigation Navigation { get; }
- public MainWindowVM(DataBasesVM dataBasesVM, LoginVM loginVM, BaseManagementVM baseManagementVM, UserManagementVM userManagementVM
- , IServiceProvider provider)
+ public MainWindowVM(
+ LauncherNavigation navigation,
+ DatabasesVM dataBasesVM,
+ LoginVM loginVM)
{
- CarouselPageVM[] pages = { dataBasesVM, loginVM, baseManagementVM, userManagementVM };
- foreach (var page in pages) {
- page.NextPageCommand = ReactiveCommand.Create(NextPage);
- page.PreviousPageCommand = ReactiveCommand.Create(PreviousPage);
- page.ChangePageCommand = ReactiveCommand.Create(ChangePage);
- }
- login = loginVM;
+ Navigation = navigation ?? throw new ArgumentNullException(nameof(navigation));
+ login = loginVM ?? throw new ArgumentNullException(nameof(loginVM));
+
+ // корни ставим здесь, а не конструктором навигатора: иначе страницы, которые
+ // сами зависят от навигатора, замкнули бы граф зависимостей
+ Navigation.SetRoots(loginVM, dataBasesVM);
}
public void SaveConnections() {
login.SaveConnections();
}
-
- public void ChangePage(int index) {
- SelectedPageIndex = index;
- }
-
- public void NextPage() {
- ChangePage((SelectedPageIndex + 1) % PagesCount);
- }
-
- public void PreviousPage() {
- ChangePage((SelectedPageIndex - 1 + PagesCount) % PagesCount);
- }
}
}
diff --git a/QS.Launcher/ViewModels/PageViewModels/BaseAccessRowVM.cs b/QS.Launcher/ViewModels/PageViewModels/BaseAccessRowVM.cs
new file mode 100644
index 000000000..b4ddd7065
--- /dev/null
+++ b/QS.Launcher/ViewModels/PageViewModels/BaseAccessRowVM.cs
@@ -0,0 +1,106 @@
+using QS.DbManagement.Entities;
+using ReactiveUI;
+
+namespace QS.Launcher.ViewModels.PageViewModels {
+ public class BaseAccessRowVM : ReactiveObject {
+ public int BaseId { get; }
+ public string BaseName { get; }
+ public string Title { get; }
+
+ public bool ShowReadOnly { get; }
+
+ public bool CanEdit { get; }
+
+ public BaseAccessRowVM(DbUserBaseAccess access, bool showReadOnly) {
+ BaseId = access.BaseId;
+ BaseName = access.BaseName;
+ Title = access.Title;
+ ShowReadOnly = showReadOnly;
+ CanEdit = access.CanEdit;
+ hasAccess = originalHasAccess = access.HasAccess;
+ isAdmin = originalIsAdmin = access.IsAdmin;
+ readOnly = originalReadOnly = access.ReadOnly;
+ }
+
+ private bool originalHasAccess;
+ private bool originalIsAdmin;
+ private bool originalReadOnly;
+
+ public bool IsDirty =>
+ HasAccess != originalHasAccess || IsAdmin != originalIsAdmin || ReadOnly != originalReadOnly;
+
+ public void AcceptChanges() {
+ originalHasAccess = HasAccess;
+ originalIsAdmin = IsAdmin;
+ originalReadOnly = ReadOnly;
+ }
+
+ private bool hasAccess;
+ public bool HasAccess {
+ get => hasAccess;
+ set {
+ this.RaiseAndSetIfChanged(ref hasAccess, value);
+ if(!value) {
+ IsAdmin = false;
+ ReadOnly = false;
+ }
+ RaiseLevelChanged();
+ }
+ }
+
+ private bool isAdmin;
+ public bool IsAdmin {
+ get => isAdmin;
+ set {
+ this.RaiseAndSetIfChanged(ref isAdmin, value);
+ if(value) {
+ HasAccess = true;
+ ReadOnly = false;
+ }
+ RaiseLevelChanged();
+ }
+ }
+
+ private bool readOnly;
+ public bool ReadOnly {
+ get => readOnly;
+ set {
+ this.RaiseAndSetIfChanged(ref readOnly, value);
+ if(value) {
+ HasAccess = true;
+ IsAdmin = false;
+ }
+ RaiseLevelChanged();
+ }
+ }
+
+ public bool CanUpdate => HasAccess && !ReadOnly;
+
+ /// Подпись уровня. Показывается подсказкой при наведении на строку
+ public string LevelDescription {
+ get {
+ if(!HasAccess)
+ return "Нет доступа";
+ if(IsAdmin)
+ return "Все права на базу";
+ return ReadOnly ? "Только просмотр, без обновления базы" : "Пользование и обновление базы";
+ }
+ }
+
+ private void RaiseLevelChanged() {
+ this.RaisePropertyChanged(nameof(CanUpdate));
+ this.RaisePropertyChanged(nameof(LevelDescription));
+ }
+
+ public DbUserBaseAccess ToAccess(string name, string email) => new DbUserBaseAccess {
+ BaseId = BaseId,
+ BaseName = BaseName,
+ Title = Title,
+ HasAccess = HasAccess,
+ IsAdmin = IsAdmin,
+ ReadOnly = ReadOnly,
+ Name = name,
+ Email = email
+ };
+ }
+}
diff --git a/QS.Launcher/ViewModels/PageViewModels/BaseManagementVM.cs b/QS.Launcher/ViewModels/PageViewModels/BaseManagementVM.cs
deleted file mode 100644
index 7844609c2..000000000
--- a/QS.Launcher/ViewModels/PageViewModels/BaseManagementVM.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using QS.Launcher.ViewModels.TypeViewModels;
-using System.Collections.Generic;
-
-namespace QS.Launcher.ViewModels.PageViewModels {
- public class BaseManagementVM : CarouselPageVM {
-
- public List Databases { get; set; }
-
- public DatabaseViewModel SelectedDatabase { get; set; }
-
- public BaseManagementVM() {
-
- }
- }
-}
diff --git a/QS.Launcher/ViewModels/PageViewModels/CarouselPageVM.cs b/QS.Launcher/ViewModels/PageViewModels/CarouselPageVM.cs
index 8cfa391a0..1f090df9c 100644
--- a/QS.Launcher/ViewModels/PageViewModels/CarouselPageVM.cs
+++ b/QS.Launcher/ViewModels/PageViewModels/CarouselPageVM.cs
@@ -1,12 +1,28 @@
+using System;
+using System.Linq;
+using System.Reactive.Linq;
using System.Windows.Input;
using QS.ViewModels;
+using ReactiveUI;
namespace QS.Launcher.ViewModels.PageViewModels {
public class CarouselPageVM : ViewModelBase {
- public ICommand NextPageCommand { get; set; }
+ protected LauncherNavigation Navigation { get; }
- public ICommand PreviousPageCommand { get; set; }
+ protected CarouselPageVM(LauncherNavigation navigation) {
+ Navigation = navigation ?? throw new ArgumentNullException(nameof(navigation));
+ }
+
+ private bool isBusy;
+ public bool IsBusy {
+ get => isBusy;
+ private set => this.RaiseAndSetIfChanged(ref isBusy, value);
+ }
+
+ protected void TrackBusy(params ICommand[] commands) =>
+ commands.Cast().Select(command => command.IsExecuting)
+ .CombineLatest(running => running.Any(busy => busy))
+ .Subscribe(busy => IsBusy = busy);
- public ICommand ChangePageCommand { get; set; }
}
}
diff --git a/QS.Launcher/ViewModels/PageViewModels/ChangePasswordVM.cs b/QS.Launcher/ViewModels/PageViewModels/ChangePasswordVM.cs
new file mode 100644
index 000000000..3a9543e0a
--- /dev/null
+++ b/QS.Launcher/ViewModels/PageViewModels/ChangePasswordVM.cs
@@ -0,0 +1,73 @@
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.Dialog;
+using QS.ErrorReporting;
+using ReactiveUI;
+using System;
+using System.Reactive;
+using System.Threading.Tasks;
+
+namespace QS.Launcher.ViewModels.PageViewModels {
+ public class ChangePasswordVM : CarouselPageVM {
+ private IDbUserManager provider;
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+ private readonly IInteractiveMessage interactiveMessage;
+ private readonly IErrorHandlingService errorHandling;
+ private const string MessageTitle = "Смена пароля";
+
+ public ChangePasswordVM(LauncherNavigation navigation, IInteractiveMessage interactiveMessage,
+ IErrorHandlingService errorHandling) : base(navigation) {
+ this.interactiveMessage = interactiveMessage ?? throw new ArgumentNullException(nameof(interactiveMessage));
+ this.errorHandling = errorHandling ?? throw new ArgumentNullException(nameof(errorHandling));
+
+ var canChangeOwnPassword = this.WhenAnyValue(x => x.OwnNewPassword, x => x.OwnConfirmPassword,
+ (pass, confirm) => !string.IsNullOrEmpty(pass) && pass == confirm);
+ ChangeOwnPasswordCommand = ReactiveCommand.CreateFromTask(
+ ChangeOwnPasswordAsync, canChangeOwnPassword);
+ BackCommand = ReactiveCommand.Create(Navigation.Pop);
+
+ TrackBusy(ChangeOwnPasswordCommand);
+ }
+
+ private string ownNewPassword;
+ public string OwnNewPassword {
+ get => ownNewPassword;
+ set => this.RaiseAndSetIfChanged(ref ownNewPassword, value);
+ }
+
+ private string ownConfirmPassword;
+ public string OwnConfirmPassword {
+ get => ownConfirmPassword;
+ set => this.RaiseAndSetIfChanged(ref ownConfirmPassword, value);
+ }
+
+ public ReactiveCommand ChangeOwnPasswordCommand { get; }
+ public ReactiveCommand BackCommand { get; }
+ public void SetProvider(IDbUserManager userManager) {
+ provider = userManager ?? throw new ArgumentNullException(nameof(userManager));
+ }
+
+ private async Task ChangeOwnPasswordAsync() {
+ try {
+ string newPassword = OwnNewPassword;
+ bool ok = await Task.Run(() => provider.ChangeOwnPassword(newPassword));
+ // со страницы уходим только когда пароль действительно сменился:
+ // иначе пользователь теряет форму вместе с сообщением об ошибке
+ if(!ok) {
+ interactiveMessage.ShowMessage(ImportanceLevel.Error, "Не удалось изменить пароль.", MessageTitle);
+ return;
+ }
+
+ OwnNewPassword = null;
+ OwnConfirmPassword = null;
+ interactiveMessage.ShowMessage(ImportanceLevel.Success, "Пароль изменён.", MessageTitle);
+ }
+ catch(Exception ex) {
+ errorHandling.Handle(ex, MessageTitle);
+ return;
+ }
+
+ Navigation.Pop();
+ }
+ }
+}
diff --git a/QS.Launcher/ViewModels/PageViewModels/DataBasesVM.cs b/QS.Launcher/ViewModels/PageViewModels/DataBasesVM.cs
deleted file mode 100644
index 29acce4bb..000000000
--- a/QS.Launcher/ViewModels/PageViewModels/DataBasesVM.cs
+++ /dev/null
@@ -1,130 +0,0 @@
-using DynamicData.Kernel;
-using QS.DbManagement;
-using QS.Dialog;
-using ReactiveUI;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Reactive.Linq;
-using System.Windows.Input;
-using QS.Launcher.AppRunner;
-using QS.Project.Versioning;
-
-namespace QS.Launcher.ViewModels.PageViewModels {
- public class DataBasesVM : CarouselPageVM {
- private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
-
- private Connection currentConnection;
- private Action saveConnectionsAction;
- private IDbProvider provider;
- public IDbProvider Provider {
- get => provider;
- set {
- this.RaiseAndSetIfChanged(ref provider, value);
- Databases = provider.GetUserDatabases(applicationInfo).AsList();
- this.RaisePropertyChanged(nameof(Databases));
-
- // Загружаем и устанавливаем последнюю выбранную базу
- LoadLastSelectedDatabase();
- }
- }
-
- public void SetProvider(IDbProvider dbProvider, Connection connection, Action saveConnections) {
- currentConnection = connection;
- saveConnectionsAction = saveConnections;
- Provider = dbProvider;
- }
-
- public List Databases { get; set; }
-
- private DbInfo selectedDatabase;
- public DbInfo SelectedDatabase {
- get => selectedDatabase;
- set => this.RaiseAndSetIfChanged(ref selectedDatabase, value);
- }
-
- public bool IsAdmin { get; set; } = false;
-
- public bool ShouldCloseLauncherAfterStart { get; set; } = true;
-
- private readonly LauncherOptions launcherOptions;
-
- ///
- /// Указывает, должна ли быть видна галочка "Не закрывать лаунчер после подключения".
- /// Видна только в standalone режиме (когда лаунчер - отдельное приложение).
- ///
- public bool VisibleShouldCloseLauncherCheckBox => launcherOptions?.IsStandalone ?? false;
-
- public ICommand ConnectCommand { get; }
-
- public event Action StartLaunchProgram;
-
- IInteractiveMessage interactiveMessage;
-
- private readonly IAppRunner appRunner;
- private readonly IApplicationInfo applicationInfo;
-
- public DataBasesVM(IAppRunner appRunner, IApplicationInfo applicationInfo, IInteractiveMessage interactiveMessage, LauncherOptions launcherOptions) {
- this.appRunner = appRunner ?? throw new ArgumentNullException(nameof(appRunner));
- this.applicationInfo = applicationInfo ?? throw new ArgumentNullException(nameof(applicationInfo));
- this.interactiveMessage = interactiveMessage ?? throw new ArgumentNullException(nameof(interactiveMessage));
- this.launcherOptions = launcherOptions;
-
- logger.Info($">>> DataBasesVM constructor: launcherOptions={launcherOptions}, IsStandalone={launcherOptions?.IsStandalone}");
-
- IObservable canExecute = this
- .WhenAnyValue(x => x.SelectedDatabase)
- .Select(x => x != null);
- ConnectCommand = ReactiveCommand.Create(Connect, canExecute);
- }
-
- private void LoadLastSelectedDatabase() {
- if(Databases == null || Databases.Count == 0)
- return;
-
- // Используем LastBaseId из текущего подключения
- if(currentConnection?.LastBaseId != null)
- SelectedDatabase = Databases.FirstOrDefault(db => db.BaseId == currentConnection.LastBaseId.Value);
-
- if(SelectedDatabase == null)
- SelectedDatabase = Databases.FirstOrDefault();
- }
-
- public void Connect() {
-
- var resp = provider.LoginToDatabase(SelectedDatabase);
-
- if(!resp.Success) {
- interactiveMessage.ShowMessage(ImportanceLevel.Error, resp.ErrorMessage, "Ошибка подключения к базе данных");
- return;
- }
-
- // Сохраняем последнюю выбранную базу
- SaveLastSelectedDatabase();
-
- // Определяем, нужно ли закрывать лаунчер через Shutdown
- // В standalone режиме учитываем галочку ShouldCloseLauncherAfterStart
- // В in-process режиме НЕ делаем shutdown (возвращаем false)
- var isStandalone = launcherOptions?.IsStandalone ?? false;
- logger.Info($">>> Connect: IsStandalone={isStandalone}, ShouldCloseLauncherAfterStart={ShouldCloseLauncherAfterStart}");
-
- bool shouldCloseLauncher = isStandalone && ShouldCloseLauncherAfterStart;
-
- logger.Info($">>> Connect: shouldCloseLauncher={shouldCloseLauncher}");
-
- StartLaunchProgram?.Invoke(shouldCloseLauncher);
- appRunner.Run(resp);
- }
-
- private void SaveLastSelectedDatabase() {
- if(SelectedDatabase == null || currentConnection == null)
- return;
-
- // Сохраняем BaseId в текущее подключение
- currentConnection.LastBaseId = SelectedDatabase.BaseId;
-
- // Вызываем сохранение подключений
- saveConnectionsAction?.Invoke();
- }
- }
-}
diff --git a/QS.Launcher/ViewModels/PageViewModels/Database/BackupDbSettingsVM.cs b/QS.Launcher/ViewModels/PageViewModels/Database/BackupDbSettingsVM.cs
new file mode 100644
index 000000000..bdb6a1862
--- /dev/null
+++ b/QS.Launcher/ViewModels/PageViewModels/Database/BackupDbSettingsVM.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reactive.Linq;
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.Dialog;
+using ReactiveUI;
+
+namespace QS.Launcher.ViewModels.PageViewModels.Database {
+ public class BackupDbSettingsVM : DbOperationSettingsVM {
+ private readonly DbInfo database;
+
+ public BackupDbSettingsVM(LauncherNavigation navigation, DbInfo database, IDbManager provider,
+ Connection connection, IServiceProvider services, IInteractiveMessage interactiveMessage)
+ : base(navigation, provider, connection, services, interactiveMessage) {
+ this.database = database ?? throw new ArgumentNullException(nameof(database));
+
+ BackupTargetTitle = string.IsNullOrEmpty(database.BaseName) ? database.Title : database.BaseName;
+ BackupFilePath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
+ "Backups",
+ string.Format("{0}-{1:yyMMdd-HHmm}.sql", BackupTargetTitle, DateTime.Now));
+
+ SetValidity(this.WhenAnyValue(x => x.BackupFilePath, path => !string.IsNullOrWhiteSpace(path)));
+ }
+
+ public override string Title => "Создание резервной копии базы данных";
+
+ public string BackupTargetTitle { get; }
+
+ private string backupFilePath;
+ public string BackupFilePath {
+ get => backupFilePath;
+ set => this.RaiseAndSetIfChanged(ref backupFilePath, value);
+ }
+
+ public override IEnumerable BuildPipeline() {
+ return new[] {
+ new DbCreationPhase(
+ "Создание резервной копии базы данных",
+ args => {
+ args.Provider.BackupDatabase(database, BackupFilePath, args.Progress, args.CancellationToken);
+ args.CancellationToken.ThrowIfCancellationRequested();
+ return true;
+ })
+ };
+ }
+ }
+}
diff --git a/QS.Launcher/ViewModels/PageViewModels/Database/CreateDatabaseProgressVM.cs b/QS.Launcher/ViewModels/PageViewModels/Database/CreateDatabaseProgressVM.cs
new file mode 100644
index 000000000..2c18f9d79
--- /dev/null
+++ b/QS.Launcher/ViewModels/PageViewModels/Database/CreateDatabaseProgressVM.cs
@@ -0,0 +1,195 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reactive;
+using System.Threading;
+using System.Threading.Tasks;
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.Dialog;
+using QS.ErrorReporting;
+using ReactiveUI;
+
+namespace QS.Launcher.ViewModels.PageViewModels.Database {
+ ///
+ /// последовательно выполняет пайплайн фаз в одном фоновом потоке
+ ///
+ public class CreateDatabaseProgressVM : CarouselPageVM, IProgressBarDisplayable, IDisposable {
+ private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
+ public IDbManager Provider { get; private set; }
+ public Connection Connection { get; private set; }
+ private IReadOnlyList phases = Array.Empty();
+
+ private string operationTitle = "Создание базы данных";
+ /// Заголовок страницы - задаётся настройками под конкретную операцию.
+ public string OperationTitle {
+ get => operationTitle;
+ set => this.RaiseAndSetIfChanged(ref operationTitle, value);
+ }
+
+ private readonly IServiceProvider services;
+ private readonly IGuiDispatcher guiDispatcher;
+ private readonly IErrorHandlingService errorHandling;
+ private readonly CancellationTokenSource cts;
+
+ #region IProgressBarDisplayable поля
+
+ private double minValue;
+ private double maxValue = 1;
+ private double currentValue;
+ private string currentText;
+ private bool isStarted;
+
+ public double MinValue {
+ get => minValue;
+ private set => this.RaiseAndSetIfChanged(ref minValue, value);
+ }
+ public double MaxValue {
+ get => maxValue;
+ private set => this.RaiseAndSetIfChanged(ref maxValue, value);
+ }
+ public double Value {
+ get => currentValue;
+ private set => this.RaiseAndSetIfChanged(ref currentValue, value);
+ }
+ public string CurrentText {
+ get => currentText;
+ private set => this.RaiseAndSetIfChanged(ref currentText, value);
+ }
+ public bool IsStarted {
+ get => isStarted;
+ private set => this.RaiseAndSetIfChanged(ref isStarted, value);
+ }
+
+ #endregion
+
+ #region Отказ
+
+ private string failureMessage;
+ public string FailureMessage {
+ get => failureMessage;
+ private set {
+ this.RaiseAndSetIfChanged(ref failureMessage, value);
+ this.RaisePropertyChanged(nameof(IsFailed));
+ }
+ }
+
+ public bool IsFailed => !string.IsNullOrEmpty(failureMessage);
+
+ private void Fail(string reason) {
+ FailureMessage = string.IsNullOrEmpty(reason)
+ ? "Операция не выполнена." : reason;
+ }
+
+ #endregion
+
+ public event Action OperationCompleted;
+
+ /// Пользователь закрыл страницу отказа - можно уводить его дальше
+ public event Action CloseRequested;
+
+ public ReactiveCommand StartCommand { get; }
+ public ReactiveCommand CancelCommand { get; }
+ public ReactiveCommand CloseCommand { get; }
+
+ public CreateDatabaseProgressVM(
+ LauncherNavigation navigation,
+ IGuiDispatcher guiDispatcher,
+ IServiceProvider services,
+ IErrorHandlingService errorHandling) : base(navigation)
+ {
+ this.guiDispatcher = guiDispatcher ?? throw new ArgumentNullException(nameof(guiDispatcher));
+ this.services = services ?? throw new ArgumentNullException(nameof(services));
+ this.errorHandling = errorHandling ?? throw new ArgumentNullException(nameof(errorHandling));
+ cts = new CancellationTokenSource();
+
+ StartCommand = ReactiveCommand.CreateFromTask(RunAsync);
+ CancelCommand = ReactiveCommand.Create(() => {
+ cts.Cancel();
+ Navigation.Pop();
+ });
+ CloseCommand = ReactiveCommand.Create(() => CloseRequested?.Invoke());
+ }
+
+ public void Dispose() {
+ cts.Cancel();
+ cts.Dispose();
+ }
+
+ public void SetPipeline(
+ IDbManager provider, Connection connection,
+ IEnumerable phases) {
+ Provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ Connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ if(phases == null) throw new ArgumentNullException(nameof(phases));
+ this.phases = phases.ToList();
+ if(this.phases.Count == 0)
+ throw new ArgumentException("Пайплайн создания базы пуст.", nameof(phases));
+ }
+
+ ///
+ /// выносим всю синхронную цепочку фаз в пул, чтобы UI поток оставался свободным для перерисовки прогрессбара
+ ///
+ public async Task RunAsync() {
+ try {
+ var args = new DbPhaseArgs {
+ Provider = Provider,
+ Progress = this,
+ CancellationToken = cts.Token,
+ ServiceProvider = services
+ };
+
+ bool success = await Task.Run(() => RunPipeline(args), cts.Token);
+
+ if(success)
+ OperationCompleted?.Invoke();
+ else
+ Fail(args.FailureReason);
+ }
+ catch(OperationCanceledException) {
+ logger.Info("Операция с базой отменена.");
+ }
+ catch(Exception ex) {
+ errorHandling.Handle(ex, OperationTitle);
+ Fail(ex.Message);
+ }
+ }
+ private bool RunPipeline(DbPhaseArgs args) {
+ foreach(var phase in phases) {
+ args.CancellationToken.ThrowIfCancellationRequested();
+ guiDispatcher.RunInGuiTread(() => CurrentText = phase.Title);
+ if(!phase.Action(args))
+ return false;
+ }
+ return true;
+ }
+
+ #region IProgressBarDisplayable методы
+ public void Start(double maxValue = 1, double minValue = 0, string text = null, double startValue = 0) {
+ guiDispatcher.RunInGuiTread(() => {
+ MaxValue = maxValue;
+ MinValue = minValue;
+ Value = startValue;
+ if(text != null) CurrentText = text;
+ IsStarted = true;
+ });
+ }
+
+ public void Update(double curValue) => guiDispatcher.RunInGuiTread(() => Value = curValue);
+
+ public void UpdateMax(double maxValue) => guiDispatcher.RunInGuiTread(() => MaxValue = maxValue);
+
+ public void Update(string curText) => guiDispatcher.RunInGuiTread(() => CurrentText = curText);
+
+ public void Add(double addValue = 1, string text = null) {
+ guiDispatcher.RunInGuiTread(() => {
+ Value += addValue;
+ if(text != null) CurrentText = text;
+ });
+ }
+
+ public void Close() => guiDispatcher.RunInGuiTread(() => IsStarted = false);
+ #endregion
+ }
+}
diff --git a/QS.Launcher/ViewModels/PageViewModels/Database/CreateDbSettingsVM.cs b/QS.Launcher/ViewModels/PageViewModels/Database/CreateDbSettingsVM.cs
new file mode 100644
index 000000000..b053d2e7a
--- /dev/null
+++ b/QS.Launcher/ViewModels/PageViewModels/Database/CreateDbSettingsVM.cs
@@ -0,0 +1,59 @@
+using Microsoft.Extensions.DependencyInjection;
+using QS.DbManagement;
+using QS.DbManagement.Creation;
+using QS.DbManagement.Entities;
+using QS.DBScripts;
+using QS.DBScripts.Controllers;
+using QS.DBScripts.Models;
+using QS.Dialog;
+using QS.Project.Versioning;
+using ReactiveUI;
+using System;
+using System.Collections.Generic;
+
+namespace QS.Launcher.ViewModels.PageViewModels.Database {
+ public class CreateDbSettingsVM : DbOperationSettingsVM {
+ public CreateDbSettingsVM(LauncherNavigation navigation, IDbManager provider, Connection connection,
+ IServiceProvider services, IInteractiveMessage interactiveMessage)
+ : base(navigation, provider, connection, services, interactiveMessage) {
+ SetValidity(this.WhenAnyValue(x => x.DbName, x => x.DbTitle,
+ (name, title) => !string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(title)));
+ }
+
+ public override string Title => "Создание базы данных";
+
+ private string dbTitle;
+ public string DbTitle {
+ get => dbTitle;
+ set => this.RaiseAndSetIfChanged(ref dbTitle, value);
+ }
+
+ private string dbName;
+ public string DbName {
+ get => dbName;
+ set => this.RaiseAndSetIfChanged(ref dbName, value);
+ }
+
+ public override IEnumerable BuildPipeline() {
+ return new[] {
+ new DbCreationPhase("Создание базы данных", args => {
+ var factory = args.ServiceProvider.GetRequiredService();
+
+ var request = new DbCreationRequest {
+ DbName = DbName,
+ DbTitle = DbTitle,
+ CreationFactory = factory,
+ Interaction = args.ServiceProvider.GetRequiredService(),
+ // строку подключения заполнит провайдер
+ CreationResources = new EmbeddedCreationResources {
+ Progress = args.Progress,
+ Interactions = args.ServiceProvider.GetRequiredService(),
+ Script = args.ServiceProvider.GetRequiredService().MakeCreationScript(),
+ CancellationToken = args.CancellationToken }
+ };
+ return args.Provider.CreateDatabase(request);
+ })
+ };
+ }
+ }
+}
diff --git a/QS.Launcher/ViewModels/PageViewModels/Database/DatabasesVM.cs b/QS.Launcher/ViewModels/PageViewModels/Database/DatabasesVM.cs
new file mode 100644
index 000000000..15ea47512
--- /dev/null
+++ b/QS.Launcher/ViewModels/PageViewModels/Database/DatabasesVM.cs
@@ -0,0 +1,320 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reactive;
+using System.Reactive.Linq;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using DynamicData.Kernel;
+using Microsoft.Extensions.DependencyInjection;
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.Dialog;
+using QS.ErrorReporting;
+using QS.Launcher.AppRunner;
+using QS.Launcher.ViewModels.PageViewModels;
+using QS.Project.Versioning;
+using ReactiveUI;
+
+namespace QS.Launcher.ViewModels.PageViewModels.Database {
+ public class DatabasesVM : CarouselPageVM {
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
+ private const string DropDatabaseTitle = "Удаление базы данных";
+ private const string RefreshMetadataTitle = "Синхронизация метаинформации";
+
+ private Connection currentConnection;
+ private Action saveConnectionsAction;
+
+ public IDbProvider Provider { get; private set; }
+
+ private DbCapabilitySet capabilitySet = DbCapabilitySet.None;
+ public DbCapabilitySet Capabilities {
+ get => capabilitySet;
+ private set => this.RaiseAndSetIfChanged(ref capabilitySet, value);
+ }
+
+ public Connection CurrentConnection => currentConnection;
+
+ public async Task SetProviderAsync(IDbProvider dbProvider, Connection connection, Action saveConnections) {
+ Provider = dbProvider;
+ currentConnection = connection;
+ saveConnectionsAction = saveConnections;
+ Capabilities = dbCapabilities.For(dbProvider);
+
+ await ReloadDatabasesAsync();
+ LoadLastSelectedDatabase();
+ }
+
+ private List databases;
+ public List Databases {
+ get => databases;
+ set => this.RaiseAndSetIfChanged(ref databases, value);
+ }
+
+ private DbInfo selectedDatabase;
+ public DbInfo SelectedDatabase {
+ get => selectedDatabase;
+ set => this.RaiseAndSetIfChanged(ref selectedDatabase, value);
+ }
+
+ public bool ShouldCloseLauncherAfterStart { get; set; } = true;
+
+ private readonly LauncherOptions launcherOptions;
+
+ ///
+ /// Указывает, должна ли быть видна галочка "Не закрывать лаунчер после подключения".
+ /// Видна только в standalone режиме (когда лаунчер - отдельное приложение).
+ ///
+ public bool VisibleShouldCloseLauncherCheckBox => launcherOptions?.IsStandalone ?? false;
+
+ public event Action StartLaunchProgram;
+
+ public ICommand ConnectCommand { get; }
+ public ReactiveCommand OpenCreateDatabaseCommand { get; }
+ public ReactiveCommand OpenImportDatabaseCommand { get; }
+ public ICommand BackupDatabaseCommand { get; }
+ public ICommand DeleteDatabaseCommand { get; }
+ public ReactiveCommand OpenUserManagementCommand { get; }
+ public ReactiveCommand OpenChangePasswordCommand { get; }
+ public ReactiveCommand RefreshMetadataCommand { get; }
+
+ public ReactiveCommand RefreshDatabasesCommand { get; }
+
+ /// «Назад» - возврат к выбору подключения
+ public ReactiveCommand BackCommand { get; }
+
+ private readonly IInteractiveMessage interactiveMessage;
+ private readonly IInteractiveQuestion interactiveQuestion;
+ private readonly IServiceProvider serviceProvider;
+
+ private readonly IAppRunner appRunner;
+ private readonly DbCapabilities dbCapabilities;
+ private readonly IErrorHandlingService errorHandling;
+
+ public DatabasesVM(
+ IAppRunner appRunner,
+ LauncherNavigation navigation,
+ IInteractiveMessage interactiveMessage,
+ IInteractiveQuestion interactiveQuestion,
+ LauncherOptions launcherOptions,
+ IServiceProvider serviceProvider,
+ DbCapabilities capabilities,
+ IErrorHandlingService errorHandling) : base(navigation)
+ {
+ this.errorHandling = errorHandling ?? throw new ArgumentNullException(nameof(errorHandling));
+ this.appRunner = appRunner ?? throw new ArgumentNullException(nameof(appRunner));
+ this.interactiveMessage = interactiveMessage ?? throw new ArgumentNullException(nameof(interactiveMessage));
+ this.interactiveQuestion = interactiveQuestion ?? throw new ArgumentNullException(nameof(interactiveQuestion));
+ this.launcherOptions = launcherOptions;
+ this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+ this.dbCapabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities));
+
+ // запуск приложения закрывает лаунчер, а с ним и фоновую работу - поэтому
+ // «Запустить» держим закрытым, пока страница занята, иначе Shutdown обрывает
+ // операцию на середине
+ IObservable canExecuteConnection = this
+ .WhenAnyValue(x => x.SelectedDatabase, x => x.IsBusy,
+ (database, busy) => database != null && !busy);
+
+ ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync, canExecuteConnection);
+ OpenCreateDatabaseCommand = ReactiveCommand.Create(OpenCreateDatabase);
+ OpenImportDatabaseCommand = ReactiveCommand.Create(OpenImportDatabase);
+ BackupDatabaseCommand = ReactiveCommand.Create(OpenBackup);
+ DeleteDatabaseCommand = ReactiveCommand.CreateFromTask(DeleteDatabaseAsync);
+ OpenUserManagementCommand = ReactiveCommand.Create(OpenUsers);
+ OpenChangePasswordCommand = ReactiveCommand.Create(ChangePassword);
+ RefreshMetadataCommand = ReactiveCommand.Create(OpenRefreshMetadata);
+ RefreshDatabasesCommand = ReactiveCommand.CreateFromTask(RefreshDatabases);
+ BackCommand = ReactiveCommand.Create(Navigation.Previous);
+
+ TrackBusy(ConnectCommand, RefreshDatabasesCommand, DeleteDatabaseCommand);
+ }
+
+ private void OpenRefreshMetadata() {
+ if(Provider == null || !Capabilities.CanRefreshMetadata)
+ return;
+
+ int syncedBases = 0;
+ int syncedUsers = 0;
+
+ var phases = new[] {
+ new DbCreationPhase("Синхронизация баз данных", args => {
+ var response = args.Provider.RefreshBases();
+ syncedBases = response.SyncedBases;
+ args.FailureReason = response.ErrorMessage;
+ return response.Success;
+ }),
+ new DbCreationPhase("Синхронизация пользователей", args => {
+ var response = args.Provider.RefreshUsers();
+ syncedUsers = response.SyncedUsers;
+ args.FailureReason = response.ErrorMessage;
+ return response.Success;
+ })
+ };
+
+ var progress = serviceProvider.GetRequiredService();
+ progress.OperationTitle = RefreshMetadataTitle;
+ progress.SetPipeline(Provider, currentConnection, phases);
+ progress.OperationCompleted += () => CompleteRefreshMetadata(syncedBases, syncedUsers);
+ // причину отказа страница прогресса показывает сама, нам остаётся только увести
+ // пользователя обратно, когда он её прочитал и закрыл
+ progress.CloseRequested += () => ReturnToDatabases(refreshList: syncedBases > 0);
+ Navigation.Push(progress);
+ }
+
+ private void CompleteRefreshMetadata(int syncedBases, int syncedUsers) {
+ ReturnToDatabases();
+
+ interactiveMessage.ShowMessage(ImportanceLevel.Success,
+ $"Метаинформация обновлена.\nБаз: {syncedBases}, пользователей: {syncedUsers}.",
+ RefreshMetadataTitle);
+ }
+
+ private void ChangePassword() {
+ if(Provider == null)
+ return;
+
+ var vm = serviceProvider.GetRequiredService();
+ vm.SetProvider(Provider);
+ Navigation.Push(vm);
+ }
+
+ private void OpenUsers() {
+ if(Provider == null)
+ return;
+
+ var vm = serviceProvider.GetRequiredService();
+ vm.SetProvider(Provider);
+ Navigation.Push(vm);
+ }
+
+ /// Общий путь всех операций с базой
+ private void OpenOperation(bool allowed, Func createSettings) {
+ if(!allowed)
+ return;
+
+ var settings = createSettings();
+ settings.OperationCompleted += () => OnOperationCompleted(settings);
+ Navigation.Push(settings);
+ }
+
+ /// открывает страницу создания базы
+ private void OpenCreateDatabase() => OpenOperation(Capabilities.CanCreate,
+ () => new CreateDbSettingsVM(Navigation, Provider, CurrentConnection, serviceProvider, interactiveMessage));
+
+ /// открывает страницу импорта базы из дампа
+ private void OpenImportDatabase() => OpenOperation(Capabilities.CanImport,
+ () => new ImportDbSettingsVM(Navigation, Provider, CurrentConnection, serviceProvider, interactiveMessage));
+
+ /// открывает страницу резервного копирования выбранной базы
+ private void OpenBackup(DbInfo database) => OpenOperation(database != null && Capabilities.CanBackup,
+ () => new BackupDbSettingsVM(Navigation, database, Provider, CurrentConnection, serviceProvider, interactiveMessage));
+
+ /// снимаем всё, что стоит выше, и обновляемсписок
+ private void ReturnToDatabases(bool refreshList = true) {
+ Navigation.PopTo(GetType());
+ if(refreshList)
+ RefreshDatabasesCommand.Execute().Subscribe();
+ }
+
+ private void OnOperationCompleted(DbOperationSettingsVM operation) {
+ ReturnToDatabases();
+
+ if(operation is BackupDbSettingsVM backup)
+ interactiveMessage.ShowMessage(ImportanceLevel.Success,
+ $"Резервная копия базы данных сохранена:\n{backup.BackupFilePath}",
+ "Резервное копирование");
+ }
+
+ private async Task DeleteDatabaseAsync(DbInfo database) {
+ if(database == null || !Capabilities.CanDrop)
+ return;
+
+ // вопрос задаём до того, как перекрыли страницу: перекрытие показывает ход
+ // операции, а операция начинается только с согласия пользователя
+ bool confirmed = await interactiveQuestion.AskInBackground(
+ $"Безвозвратно удалить базу данных «{database.Title}»?", DropDatabaseTitle);
+ if(!confirmed)
+ return;
+
+ try {
+ await Task.Run(() => Provider.DropDatabase(database));
+ await RefreshDatabases();
+ interactiveMessage.ShowMessage(ImportanceLevel.Success,
+ $"База данных {database.Title} удалена.", DropDatabaseTitle);
+ }
+ catch(Exception ex) {
+ errorHandling.Handle(ex, DropDatabaseTitle);
+ }
+ }
+
+ public async Task RefreshDatabases() {
+ if(Provider == null)
+ return;
+
+ int? selectedBaseId = SelectedDatabase?.BaseId;
+ await ReloadDatabasesAsync();
+
+ // список пересобран, прежний объект в нём уже другой - ищем по идентификатору.
+ // Иначе после каждой операции выбор прыгает на первую строку
+ SelectedDatabase = Databases.FirstOrDefault(db => db.BaseId == selectedBaseId)
+ ?? Databases.FirstOrDefault();
+ }
+
+ ///
+ /// Слой доступа к базе синхронный и блокирующий, поэтому чтение уводим в фон;
+ /// список и уведомление обновляем уже после await, на потоке интерфейса
+ ///
+ private async Task ReloadDatabasesAsync() {
+ Databases = await Task.Run(() => Provider.GetUserDatabases().AsList());
+ this.RaisePropertyChanged(nameof(Databases));
+ }
+
+ private void LoadLastSelectedDatabase() {
+ if(Databases == null || Databases.Count == 0)
+ return;
+
+ // Используем LastBaseId из текущего подключения
+ if(currentConnection?.LastBaseId != null)
+ SelectedDatabase = Databases.FirstOrDefault(db => db.BaseId == currentConnection.LastBaseId.Value);
+
+ if(SelectedDatabase == null)
+ SelectedDatabase = Databases.FirstOrDefault();
+ }
+
+ /// Синхронный вход для Gtk-представления, у которого обработчики событий не асинхронны
+ public void Connect() => ConnectCommand.Execute(null);
+
+ public async Task ConnectAsync() {
+ // вход в базу это поход на сервер, а в облаке ещё и запуск сессии - уводим в фон,
+ // иначе интерфейс стоит всё время подключения
+ var resp = await Task.Run(() => Provider.LoginToDatabase(SelectedDatabase));
+ if(!resp.Success) {
+ interactiveMessage.ShowMessage(ImportanceLevel.Error, resp.ErrorMessage, "Ошибка подключения к базе данных");
+ return;
+ }
+
+ SaveLastSelectedDatabase();
+
+ // Определяем, нужно ли закрывать лаунчер через Shutdown
+ // В standalone режиме учитываем галочку ShouldCloseLauncherAfterStart
+ // В in-process режиме НЕ делаем shutdown (возвращаем false)
+ var isStandalone = launcherOptions?.IsStandalone ?? false;
+ bool shouldCloseLauncher = isStandalone && ShouldCloseLauncherAfterStart;
+
+ logger.Debug("Запуск приложения: standalone {0}, закрывать лаунчер {1}", isStandalone, shouldCloseLauncher);
+
+ StartLaunchProgram?.Invoke(shouldCloseLauncher);
+ appRunner.Run(resp);
+ }
+
+ private void SaveLastSelectedDatabase() {
+ if(SelectedDatabase == null || currentConnection == null)
+ return;
+ currentConnection.LastBaseId = SelectedDatabase.BaseId;
+
+ saveConnectionsAction?.Invoke();
+ }
+ }
+}
diff --git a/QS.Launcher/ViewModels/PageViewModels/Database/DbOperationSettingsVM.cs b/QS.Launcher/ViewModels/PageViewModels/Database/DbOperationSettingsVM.cs
new file mode 100644
index 000000000..4f313ea12
--- /dev/null
+++ b/QS.Launcher/ViewModels/PageViewModels/Database/DbOperationSettingsVM.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using System.Reactive;
+using System.Reactive.Linq;
+using Microsoft.Extensions.DependencyInjection;
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.Dialog;
+using ReactiveUI;
+
+namespace QS.Launcher.ViewModels.PageViewModels.Database {
+ public abstract class DbOperationSettingsVM : CarouselPageVM {
+ protected IDbManager Provider { get; }
+ protected Connection Connection { get; }
+ protected IServiceProvider Services { get; }
+
+ private readonly IInteractiveMessage interactiveMessage;
+
+ protected DbOperationSettingsVM(LauncherNavigation navigation, IDbManager provider, Connection connection,
+ IServiceProvider services, IInteractiveMessage interactiveMessage) : base(navigation) {
+ Provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ Connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ Services = services ?? throw new ArgumentNullException(nameof(services));
+ this.interactiveMessage = interactiveMessage ?? throw new ArgumentNullException(nameof(interactiveMessage));
+
+ CancelCommand = ReactiveCommand.Create(Navigation.Pop);
+ }
+
+ public abstract string Title { get; }
+
+ public IObservable CanProceed { get; private set; }
+
+ public abstract IEnumerable BuildPipeline();
+
+ public ReactiveCommand ProceedCommand { get; private set; }
+ public ReactiveCommand CancelCommand { get; }
+ public event Action OperationCompleted;
+
+ protected void SetValidity(IObservable canProceed) {
+ CanProceed = canProceed ?? Observable.Return(true);
+ ProceedCommand = ReactiveCommand.Create(GoToProgress, CanProceed);
+ }
+
+ protected virtual string ValidationError() => null;
+
+ private void GoToProgress() {
+ string error = ValidationError();
+ if(error != null) {
+ interactiveMessage.ShowMessage(ImportanceLevel.Warning, error, Title);
+ return;
+ }
+
+ var progress = Services.GetRequiredService();
+ progress.OperationTitle = Title;
+ progress.SetPipeline(Provider, Connection, BuildPipeline());
+ progress.OperationCompleted += () => OperationCompleted?.Invoke();
+ progress.CloseRequested += Navigation.Pop;
+ Navigation.Push(progress);
+ }
+ }
+}
diff --git a/QS.Launcher/ViewModels/PageViewModels/Database/ImportDbSettingsVM.cs b/QS.Launcher/ViewModels/PageViewModels/Database/ImportDbSettingsVM.cs
new file mode 100644
index 000000000..dfd12496a
--- /dev/null
+++ b/QS.Launcher/ViewModels/PageViewModels/Database/ImportDbSettingsVM.cs
@@ -0,0 +1,68 @@
+using Microsoft.Extensions.DependencyInjection;
+using QS.DbManagement;
+using QS.DbManagement.Creation;
+using QS.DbManagement.Entities;
+using QS.DBScripts.Controllers;
+using QS.Dialog;
+using QS.Project.Versioning;
+using ReactiveUI;
+using System;
+using System.Collections.Generic;
+
+namespace QS.Launcher.ViewModels.PageViewModels.Database {
+ public class ImportDbSettingsVM : DbOperationSettingsVM {
+ public ImportDbSettingsVM(LauncherNavigation navigation, IDbManager provider, Connection connection,
+ IServiceProvider services, IInteractiveMessage interactiveMessage)
+ : base(navigation, provider, connection, services, interactiveMessage) {
+ SetValidity(this.WhenAnyValue(x => x.DbName, x => x.DbTitle, x => x.ImportDumpFilePath,
+ (name, title, dump) => !string.IsNullOrWhiteSpace(name)
+ && !string.IsNullOrWhiteSpace(title)
+ && !string.IsNullOrWhiteSpace(dump)));
+ }
+
+ public override string Title => "Импорт базы данных из дампа";
+
+ private string dbTitle;
+ public string DbTitle {
+ get => dbTitle;
+ set => this.RaiseAndSetIfChanged(ref dbTitle, value);
+ }
+
+ private string dbName;
+ public string DbName {
+ get => dbName;
+ set => this.RaiseAndSetIfChanged(ref dbName, value);
+ }
+
+ private string importDumpFilePath;
+ public string ImportDumpFilePath {
+ get => importDumpFilePath;
+ set => this.RaiseAndSetIfChanged(ref importDumpFilePath, value);
+ }
+
+ protected override string ValidationError() => SqlDumpFileValidator.Validate(ImportDumpFilePath);
+
+ public override IEnumerable BuildPipeline() {
+ // Наполнение из дампа.
+ return new[] {
+ new DbCreationPhase("Импорт базы данных из дампа", args => {
+ var factory = args.ServiceProvider.GetRequiredService();
+
+ var request = new DbCreationRequest {
+ DbName = DbName,
+ DbTitle = DbTitle,
+ CreationFactory = factory,
+ Interaction = args.ServiceProvider.GetRequiredService(),
+ // строку подключения заполнит провайдер
+ CreationResources = new DbDumpResources {
+ Progress = args.Progress,
+ Interactions = args.ServiceProvider.GetRequiredService(),
+ DumpFilePath = ImportDumpFilePath,
+ CancellationToken = args.CancellationToken }
+ };
+ return args.Provider.CreateDatabase(request);
+ })
+ };
+ }
+ }
+}
diff --git a/QS.Launcher/ViewModels/PageViewModels/LoginVM.cs b/QS.Launcher/ViewModels/PageViewModels/LoginVM.cs
index 3dc7b311e..7abfdeeb0 100644
--- a/QS.Launcher/ViewModels/PageViewModels/LoginVM.cs
+++ b/QS.Launcher/ViewModels/PageViewModels/LoginVM.cs
@@ -1,7 +1,10 @@
-using System;
using QS.DbManagement;
using QS.Dialog;
+using QS.ErrorReporting;
+using QS.Launcher.ViewModels.PageViewModels.Database;
+using QS.Project.Versioning;
using ReactiveUI;
+using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
@@ -10,6 +13,8 @@
namespace QS.Launcher.ViewModels.PageViewModels {
public class LoginVM : CarouselPageVM {
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
private byte[] companyImage;
public byte[] CompanyImage {
get => companyImage;
@@ -26,8 +31,6 @@ public Connection SelectedConnection {
set {
this.RaiseAndSetIfChanged(ref selectedConnection, value);
this.RaisePropertyChanged(nameof(CanLogin));
- this.RaisePropertyChanged(nameof(Connections));
- this.RaisePropertyChanged(nameof(SelectedConnection.CustomParameters));
}
}
@@ -47,6 +50,7 @@ public string Password {
Password.Where(c => char.IsLetter(c)).All(c => c <= '~');
protected IDbProvider dbProvider;
+ private readonly IApplicationInfo applicationInfo;
public ICommand LoginCommand { get; }
public ICommand AddCommand { get; }
@@ -58,30 +62,39 @@ public string Password {
protected IInteractiveMessage interactiveMessage;
private readonly Configurator configurator;
- private readonly DataBasesVM dbVM;
+ private readonly DatabasesVM dbVM;
+ private readonly IErrorHandlingService errorHandling;
public LoginVM(
- IEnumerable connectionTypes,
+ LauncherNavigation navigation,
LauncherOptions options,
Configurator configurator,
- DataBasesVM dbVM,
- IInteractiveMessage interactiveMessage) : base()
+ IApplicationInfo applicationInfo,
+ DatabasesVM dbVM,
+ IInteractiveMessage interactiveMessage,
+ IErrorHandlingService errorHandling) : base(navigation)
{
this.configurator = configurator ?? throw new ArgumentNullException(nameof(configurator));
this.dbVM = dbVM;
this.interactiveMessage = interactiveMessage;
+ this.errorHandling = errorHandling ?? throw new ArgumentNullException(nameof(errorHandling));
CompanyImage = options.LogoImage;
AppTitle = options.AppTitle;
- ConnectionTypes = connectionTypes.ToList();
- Connections = new ObservableCollection(configurator.ReadConnections());
+ this.applicationInfo = applicationInfo;
+ // типы подключений берём у конфигуратора: он же по ним и разбирает файл,
+ // и второй список тех же типов означал бы два источника правды
+ ConnectionTypes = configurator.ConnectionTypes.ToList();
+ Connections = new ObservableCollection(configurator.ReadConnections());
SelectedConnection = Connections.FirstOrDefault(c => c.Last);
- LoginCommand = ReactiveCommand.Create(Login);
+ LoginCommand = ReactiveCommand.CreateFromTask(Login);
NewCommand = ReactiveCommand.Create(CreateNewConnection);
DeleteCommand = ReactiveCommand.Create(DeleteSelectedConnection);
CloneCommand = ReactiveCommand.Create(CloneConnection);
SaveCommand = ReactiveCommand.Create(SaveConnections);
+
+ TrackBusy(LoginCommand);
}
public void DeleteSelectedConnection() {
@@ -104,22 +117,26 @@ public void CreateNewConnection() {
SelectedConnection = newCon;
}
- public void Login() {
- if(SelectedConnection is null)
+ public async Task Login() {
+ var connection = SelectedConnection;
+ if(connection is null)
return;
-
- dbProvider = SelectedConnection.CreateProvider(Password);
- var resp = dbProvider.LoginToServer();
-
- Task.Run(() => SaveCommand.Execute(null));
- if(resp.Success) {
- dbVM.SetProvider(dbProvider, SelectedConnection, SaveConnections);
- dbVM.IsAdmin = resp.IsAdmin;
- NextPageCommand?.Execute(null);
+ try {
+ dbProvider = connection.CreateProvider(Password, applicationInfo.ProductCode);
+ var resp = await Task.Run(() => dbProvider.LoginToServer());
+ SaveConnections();
+
+ if(resp.Success) {
+ await dbVM.SetProviderAsync(dbProvider, connection, SaveConnections);
+ Navigation.Next();
+ }
+ else
+ interactiveMessage.ShowMessage(ImportanceLevel.Error, resp.ErrorMessage, "Не удалось войти");
+ }
+ catch(Exception ex) {
+ errorHandling.Handle(ex, "Не удалось войти");
}
- else
- interactiveMessage.ShowMessage(ImportanceLevel.Error, resp.ErrorMessage, "Не удалось войти");
}
public bool CanLogin => SelectedConnection != null &&
diff --git a/QS.Launcher/ViewModels/PageViewModels/UserCardVM.cs b/QS.Launcher/ViewModels/PageViewModels/UserCardVM.cs
new file mode 100644
index 000000000..28a750494
--- /dev/null
+++ b/QS.Launcher/ViewModels/PageViewModels/UserCardVM.cs
@@ -0,0 +1,151 @@
+using System;
+using QS.DbManagement.Entities;
+using ReactiveUI;
+
+namespace QS.Launcher.ViewModels.PageViewModels {
+ public class UserCardVM : ReactiveObject {
+ public UserCardVM() {
+ AcceptChanges();
+ }
+
+ #region Поля
+
+ private string login;
+ public string Login {
+ get => login;
+ set => this.RaiseAndSetIfChanged(ref login, value);
+ }
+
+ private string name;
+ public string Name {
+ get => name;
+ set => this.RaiseAndSetIfChanged(ref name, value);
+ }
+
+ private string email;
+ public string Email {
+ get => email;
+ set => this.RaiseAndSetIfChanged(ref email, value);
+ }
+
+ private string phone;
+ public string Phone {
+ get => phone;
+ set => this.RaiseAndSetIfChanged(ref phone, value);
+ }
+
+ private string post;
+ public string Post {
+ get => post;
+ set => this.RaiseAndSetIfChanged(ref post, value);
+ }
+
+ private string comment;
+ public string Comment {
+ get => comment;
+ set => this.RaiseAndSetIfChanged(ref comment, value);
+ }
+
+ private bool disabled;
+ public bool Disabled {
+ get => disabled;
+ set => this.RaiseAndSetIfChanged(ref disabled, value);
+ }
+
+ private bool isAdmin;
+ public bool IsAdmin {
+ get => isAdmin;
+ set => this.RaiseAndSetIfChanged(ref isAdmin, value);
+ }
+
+ private string newPassword;
+ public string NewPassword {
+ get => newPassword;
+ set => this.RaiseAndSetIfChanged(ref newPassword, value);
+ }
+
+ private bool isNew;
+ public bool IsNew {
+ get => isNew;
+ set {
+ this.RaiseAndSetIfChanged(ref isNew, value);
+ this.RaisePropertyChanged(nameof(PasswordWatermark));
+ }
+ }
+
+ public string PasswordWatermark => IsNew
+ ? "задайте пароль нового пользователя"
+ : "оставьте пустым, чтобы не менять";
+
+ #endregion
+
+ #region Видимость полей
+
+ public bool ShowName { get; private set; }
+ public bool ShowEmail { get; private set; }
+ public bool ShowPhone { get; private set; }
+ public bool ShowPost { get; private set; }
+ public bool ShowComment { get; private set; }
+ public bool ShowAdminFlag { get; private set; }
+ public bool ShowDisabling { get; private set; }
+
+ public void ApplySupportedFields(DbUserFields fields) {
+ ShowName = fields.HasFlag(DbUserFields.Name);
+ ShowEmail = fields.HasFlag(DbUserFields.Email);
+ ShowPhone = fields.HasFlag(DbUserFields.Phone);
+ ShowPost = fields.HasFlag(DbUserFields.Post);
+ ShowComment = fields.HasFlag(DbUserFields.Comment);
+ ShowAdminFlag = fields.HasFlag(DbUserFields.AdminFlag);
+ ShowDisabling = fields.HasFlag(DbUserFields.Disabling);
+ }
+
+ #endregion
+
+ #region Изменения
+
+ private static readonly string signatureSeparator = ((char)1).ToString();
+
+ private string loadedSignature;
+
+ private string Signature() => string.Join(signatureSeparator,
+ Login, Name, Email, Phone, Post, Comment,
+ Disabled ? "1" : "0", IsAdmin ? "1" : "0");
+
+ /// есть что писать
+ public bool IsDirty => Signature() != loadedSignature;
+
+ /// текущее состояние карточки исходным
+ public void AcceptChanges() => loadedSignature = Signature();
+
+ #endregion
+
+ /// исходное состояние
+ public void Load(DbUserInfo user) {
+ if(user == null)
+ throw new ArgumentNullException(nameof(user));
+
+ Login = user.Login;
+ Name = user.Name;
+ Email = user.Email;
+ Phone = user.Phone;
+ Post = user.Post;
+ Comment = user.Comment;
+ Disabled = user.Disabled;
+ IsAdmin = user.IsAdmin;
+ NewPassword = null;
+
+ AcceptChanges();
+ }
+
+ public DbUserInfo ToUser() => new DbUserInfo {
+ Login = Login,
+ Name = Name,
+ Email = Email,
+ Phone = Phone,
+ Post = Post,
+ Comment = Comment,
+ Disabled = Disabled,
+ IsAdmin = IsAdmin
+ };
+ }
+}
diff --git a/QS.Launcher/ViewModels/PageViewModels/UserManagementVM.cs b/QS.Launcher/ViewModels/PageViewModels/UserManagementVM.cs
index 9c0c017f3..6a5c25527 100644
--- a/QS.Launcher/ViewModels/PageViewModels/UserManagementVM.cs
+++ b/QS.Launcher/ViewModels/PageViewModels/UserManagementVM.cs
@@ -1,5 +1,228 @@
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.Dialog;
+using QS.ErrorReporting;
+using QS.Project.Versioning;
+using ReactiveUI;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Reactive;
+using System.Reactive.Linq;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+
namespace QS.Launcher.ViewModels.PageViewModels {
public class UserManagementVM : CarouselPageVM {
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
+ private readonly IInteractiveMessage interactiveMessage;
+ private readonly IInteractiveQuestion interactiveQuestion;
+ private readonly IErrorHandlingService errorHandling;
+
+ private readonly string messageTitle = "Управление пользователями";
+ private const string AccessTitle = "Доступ к базам";
+
+ private IDbUserManager provider;
+
+ public UserManagementVM(
+ LauncherNavigation navigation,
+ IInteractiveMessage interactiveMessage,
+ IInteractiveQuestion interactiveQuestion,
+ IErrorHandlingService errorHandling) : base(navigation) {
+ this.interactiveMessage = interactiveMessage ?? throw new ArgumentNullException(nameof(interactiveMessage));
+ this.interactiveQuestion = interactiveQuestion ?? throw new ArgumentNullException(nameof(interactiveQuestion));
+ this.errorHandling = errorHandling ?? throw new ArgumentNullException(nameof(errorHandling));
+
+ Card = new UserCardVM();
+ BaseAccesses = new ObservableCollection();
+
+ var canSaveUser = this.WhenAnyValue(x => x.Card.Login, x => x.Card.NewPassword, x => x.Card.IsNew,
+ (login, pass, isNew) => !string.IsNullOrWhiteSpace(login) && (!isNew || !string.IsNullOrEmpty(pass)));
+ SaveCommand = ReactiveCommand.CreateFromTask(Save, canSaveUser);
+
+ BackCommand = ReactiveCommand.CreateFromTask(GoBack);
+
+ LoadSelectedUserCommand = ReactiveCommand.CreateFromTask(LoadSelectedUser);
+
+ TrackBusy(SaveCommand, LoadSelectedUserCommand);
+
+ this.WhenAnyValue(x => x.SelectedUser).InvokeCommand(LoadSelectedUserCommand);
+
+ this.WhenAnyValue(x => x.SelectedUser, x => x.Card.IsNew)
+ .Subscribe(_ => this.RaisePropertyChanged(nameof(ShowEditForm)));
+ }
+
+ public void SetContext(IDbUserManager userManager, DbUserInfo user, bool isCreating) {
+ provider = userManager ?? throw new ArgumentNullException(nameof(userManager));
+
+ Card.ApplySupportedFields(provider.SupportedUserFields);
+ ShowReadOnly = provider.SupportedUserFields.HasFlag(DbUserFields.BaseReadOnly);
+
+ if(isCreating)
+ Card.IsNew = true;
+ else
+ SelectedUser = user;
+ }
+
+ #region Управление пользователями
+
+ private UserCardVM card;
+ public UserCardVM Card {
+ get => card;
+ private set => this.RaiseAndSetIfChanged(ref card, value);
+ }
+
+ public bool CanManageUsers => provider?.CanManageUsers == true;
+
+ /// Может ли текущий пользователь сохранять доступы к базам
+ public bool CanManageBaseAccess => provider?.CanManageBaseAccess == true;
+
+ public bool ShowReadOnly { get; private set; }
+
+ private DbUserInfo selectedUser;
+ public DbUserInfo SelectedUser {
+ get => selectedUser;
+ set => this.RaiseAndSetIfChanged(ref selectedUser, value);
+ }
+
+ public bool HasSelectedUser => SelectedUser != null;
+
+ public bool ShowEditForm => Card.IsNew || SelectedUser != null;
+
+ public ReactiveCommand SaveCommand { get; }
+ public ReactiveCommand BackCommand { get; }
+
+ public ReactiveCommand LoadSelectedUserCommand { get; }
+
+ private async Task LoadSelectedUser(DbUserInfo user) {
+ this.RaisePropertyChanged(nameof(HasSelectedUser));
+ BaseAccesses.Clear();
+ this.RaisePropertyChanged(nameof(BaseAccessLocked));
+ Card.IsNew = false;
+ if(user == null)
+ return;
+
+ Card.Load(user);
+ await LoadBaseAccess(user.Login);
+ // снимок снимаем после LoadBaseAccess
+ Card.AcceptChanges();
+ }
+
+ private async Task SaveUserAsync() {
+ var newUser = Card.ToUser();
+
+ bool creating = Card.IsNew;
+ string password = Card.NewPassword;
+
+ try {
+ await Task.Run(() => {
+ if(creating)
+ provider.CreateUser(newUser, password);
+ else
+ provider.UpdateUser(newUser, password);
+ });
+ interactiveMessage.ShowMessage(ImportanceLevel.Success,
+ creating ? "Пользователь создан." : "Изменения сохранены.", messageTitle);
+ }
+ catch(Exception ex) {
+ errorHandling.Handle(ex, messageTitle);
+ return false;
+ }
+ return true;
+ }
+ #endregion
+
+ #region Доступ к базам
+
+ public ObservableCollection BaseAccesses { get; }
+
+ public bool BaseAccessLocked => BaseAccesses.Count > 0 && BaseAccesses.All(r => !r.CanEdit);
+
+ private async Task LoadBaseAccess(string login) {
+ try {
+ // чтение блокирующее и срабатывает на каждый выбор в списке - уводим в фон
+ List rows = await Task.Run(() => provider.GetUserBaseAccess(login));
+
+ BaseAccesses.Clear();
+ foreach(var row in rows)
+ BaseAccesses.Add(new BaseAccessRowVM(row, ShowReadOnly));
+
+ var byBase = rows.OrderBy(r => r.BaseName, StringComparer.Ordinal).ToList();
+
+ if(string.IsNullOrEmpty(Card.Name))
+ Card.Name = byBase.FirstOrDefault(n => !string.IsNullOrEmpty(n.Name))?.Name;
+ if(string.IsNullOrEmpty(Card.Email))
+ Card.Email = byBase.FirstOrDefault(e => !string.IsNullOrEmpty(e.Email))?.Email;
+ }
+ catch(Exception ex) {
+ errorHandling.Handle(ex, AccessTitle);
+ }
+ this.RaisePropertyChanged(nameof(BaseAccessLocked));
+ }
+
+ private async Task SaveAccessAsync() {
+ var user = SelectedUser;
+ if(user == null)
+ return true;
+ // Сохраняем только изменённые строки - каждая строка это отдельный запрос к серверу
+ var changedRows = BaseAccesses.Where(r => r.IsDirty).ToList();
+ if(changedRows.Count == 0)
+ return true;
+ string name = Card.Name;
+ string email = Card.Email;
+ try {
+ await Task.Run(() => {
+ foreach(var row in changedRows)
+ provider.SetUserBaseAccess(user.Login, row.ToAccess(name, email));
+ });
+ foreach(var row in changedRows)
+ row.AcceptChanges();
+ interactiveMessage.ShowMessage(ImportanceLevel.Success, "Доступы сохранены", AccessTitle);
+ }
+ catch(Exception ex) {
+ errorHandling.Handle(ex, AccessTitle);
+ return false;
+ }
+ return true;
+ }
+ #endregion
+
+ public event Action OperationCompleted;
+
+ /// Писать пользователя незачем, если карточку не трогали и пароль не задан
+ private bool NeedSaveUser => Card.IsNew || Card.IsDirty || !string.IsNullOrEmpty(Card.NewPassword);
+
+ private async Task Save() {
+ bool isBaseAccessSavedSuccessfully = true;
+ if(SelectedUser != null && CanManageBaseAccess && !BaseAccessLocked) {
+ isBaseAccessSavedSuccessfully = await SaveAccessAsync();
+ }
+
+ bool isUserSavedSuccessfully = !NeedSaveUser
+ || await SaveUserAsync();
+
+ if(isUserSavedSuccessfully && isBaseAccessSavedSuccessfully) {
+ Navigation.Pop();
+ OperationCompleted?.Invoke();
+ }
+ }
+
+ private async Task GoBack() {
+ bool hasChanges = Card.IsDirty
+ || !string.IsNullOrEmpty(Card.NewPassword)
+ || BaseAccesses.Any(r => r.IsDirty);
+ if(!hasChanges) {
+ Navigation.Pop();
+ return;
+ }
+
+ bool confirmed = await interactiveQuestion.AskInBackground(
+ "Есть несохранённые изменения. Выйти без сохранения?", messageTitle);
+ if(!confirmed) return;
+ Navigation.Pop();
+ }
}
}
diff --git a/QS.Launcher/ViewModels/PageViewModels/UsersVM.cs b/QS.Launcher/ViewModels/PageViewModels/UsersVM.cs
new file mode 100644
index 000000000..670c84dc0
--- /dev/null
+++ b/QS.Launcher/ViewModels/PageViewModels/UsersVM.cs
@@ -0,0 +1,130 @@
+using DynamicData;
+using Microsoft.Extensions.DependencyInjection;
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.Dialog;
+using QS.ErrorReporting;
+using ReactiveUI;
+using System;
+using System.Collections.ObjectModel;
+using System.Reactive;
+using System.Reactive.Linq;
+using System.Threading.Tasks;
+
+namespace QS.Launcher.ViewModels.PageViewModels {
+ public class UsersVM : CarouselPageVM
+ {
+ private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
+
+ private readonly IInteractiveMessage interactiveMessage;
+ private readonly IInteractiveQuestion interactiveQuestion;
+ private readonly IServiceProvider serviceProvider;
+ private readonly IErrorHandlingService errorHandling;
+
+ private IDbUserManager provider;
+
+ public UsersVM(LauncherNavigation navigation,
+ IInteractiveMessage interactiveMessage,
+ IInteractiveQuestion interactiveQuestion,
+ IServiceProvider serviceProvider,
+ IErrorHandlingService errorHandling) : base(navigation) {
+ this.interactiveMessage = interactiveMessage ?? throw new ArgumentNullException(nameof(interactiveMessage));
+ this.interactiveQuestion = interactiveQuestion ?? throw new ArgumentNullException(nameof(interactiveQuestion));
+ this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+ this.errorHandling = errorHandling ?? throw new ArgumentNullException(nameof(errorHandling));
+
+ var hasSelectedUser = this.WhenAnyValue(x => x.SelectedUser).Select(u => u != null);
+
+ NewUserCommand = ReactiveCommand.Create(StartNewUser);
+ EditUserCommand = ReactiveCommand.Create(EditUser, hasSelectedUser);
+ DeleteUserCommand = ReactiveCommand.CreateFromTask(DeleteUserAsync, hasSelectedUser);
+ RefreshUsersCommand = ReactiveCommand.CreateFromTask(RefreshUsers);
+ BackCommand = ReactiveCommand.Create(Navigation.Pop);
+
+ TrackBusy(RefreshUsersCommand, DeleteUserCommand);
+
+ Users = new ObservableCollection();
+ }
+
+ public ReactiveCommand RefreshUsersCommand { get; }
+ public ReactiveCommand BackCommand { get; }
+ public ReactiveCommand NewUserCommand { get; }
+ public ReactiveCommand EditUserCommand { get; }
+ public ReactiveCommand DeleteUserCommand { get; }
+
+
+ public ObservableCollection Users { get; }
+ private DbUserInfo selectedUser;
+ public DbUserInfo SelectedUser {
+ get => selectedUser;
+ set {
+ this.RaiseAndSetIfChanged(ref selectedUser, value);
+ }
+ }
+
+ public bool HasSelectedUser => SelectedUser != null;
+ public bool CanManageUsers => provider?.CanManageUsers == true;
+
+ public void SetProvider(IDbUserManager userManager) {
+ provider = userManager ?? throw new ArgumentNullException(nameof(userManager));
+
+ this.RaisePropertyChanged(nameof(CanManageUsers));
+ RefreshUsersCommand.Execute().Subscribe();
+ }
+
+ public async Task RefreshUsers() {
+ Users.Clear();
+ SelectedUser = null;
+ if(!CanManageUsers)
+ return;
+
+ try {
+ var users = await Task.Run(() => provider.GetUsers());
+ Users.AddRange(users);
+ }
+ catch(Exception ex) {
+ errorHandling.Handle(ex, "Управление пользователями");
+ }
+ }
+
+ private void EditUser() {
+ OpenEditor(SelectedUser, isCreating: false);
+ }
+
+ private void StartNewUser() {
+ SelectedUser = null;
+ OpenEditor(null, isCreating: true);
+ }
+
+ private void OpenEditor(DbUserInfo user, bool isCreating) {
+ var vm = serviceProvider.GetRequiredService();
+ vm.SetContext(provider, user, isCreating);
+ vm.OperationCompleted += () => RefreshUsersCommand.Execute().Subscribe();
+ Navigation.Push(vm);
+ }
+
+ private async Task DeleteUserAsync() {
+ var user = SelectedUser;
+ if(user == null)
+ return;
+ if(user.IsCurrentUser) {
+ interactiveMessage.ShowMessage(ImportanceLevel.Warning, "Нельзя удалить собственного пользователя.", "Управление пользователями");
+ return;
+ }
+
+ bool confirmed = await interactiveQuestion.AskInBackground(
+ $"Удалить пользователя «{user.Login}»?", "Управление пользователями");
+ if(!confirmed)
+ return;
+
+ try {
+ await Task.Run(() => provider.DeleteUser(user.Login));
+ interactiveMessage.ShowMessage(ImportanceLevel.Success, "Пользователь удалён.", "Управление пользователями");
+ await RefreshUsers();
+ }
+ catch(Exception ex) {
+ errorHandling.Handle(ex, "Управление пользователями");
+ }
+ }
+ }
+}
diff --git a/QS.Launcher/ViewModels/TypeViewModels/DatabaseViewModel.cs b/QS.Launcher/ViewModels/TypeViewModels/DatabaseViewModel.cs
deleted file mode 100644
index b9a9db3b4..000000000
--- a/QS.Launcher/ViewModels/TypeViewModels/DatabaseViewModel.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace QS.Launcher.ViewModels.TypeViewModels {
- public class DatabaseViewModel {
- public string Name { get; set; }
-
- public string ConnectionString { get; set; }
-
- public string Size { get; set; }
- }
-}
diff --git a/QS.LibsTest.Core/ErrorReporting/CrashProbe.cs b/QS.LibsTest.Core/ErrorReporting/CrashProbe.cs
new file mode 100644
index 000000000..6bab962f2
--- /dev/null
+++ b/QS.LibsTest.Core/ErrorReporting/CrashProbe.cs
@@ -0,0 +1,88 @@
+using Microsoft.Extensions.DependencyInjection;
+using QS.ErrorReporting;
+using QS.Launcher;
+using QS.Utilities.Extensions;
+using System;
+using System.IO;
+using System.Reflection;
+using System.Threading;
+
+namespace QS.LibsTest.Core.ErrorReporting {
+ ///
+ /// Точка входа сборки. Тестовый хост её не зовёт - она нужна, чтобы ту же сборку можно было
+ /// запустить отдельным процессом и уронить его: см.
+ ///
+ public static class CrashProbe {
+ /// Признак запуска пробы. Без него сборку запустили как обычно, и делать нечего
+ public const string Argument = "crash-probe";
+
+ /// Падение при сборке конфигурации лаунчера - до контейнера и до Avalonia
+ public const string StartupScenario = "startup";
+
+ /// Падение в фоновом потоке: сети потока интерфейса его не видят
+ public const string BackgroundScenario = "background";
+
+ public const string BackgroundCrashMessage = "падение в фоновом потоке";
+
+ /// Имени такого ресурса в сборке нет - в этом и смысл
+ private const string MissingResource = "QS.LibsTest.Core.Icons.logo.png";
+
+ public static int Main(string[] args) {
+ if(args.Length < 4 || args[0] != Argument)
+ return 0;
+
+ var reporting = new CrashReporting { Reporter = new MarkerWriter(args[1]) };
+ if(args[2] == "subscribe")
+ reporting.Subscribe();
+
+ Crash(args[3]);
+ return 0;
+ }
+
+ private static void Crash(string scenario) {
+ if(scenario == StartupScenario) {
+ ConfigureLauncher();
+ return;
+ }
+
+ // обычный фоновый поток: ни ReactiveUI, ни диспетчер Avalonia о нём не знают,
+ // а try/catch вокруг Start бесполезен - исключение всплывает уже в чужом потоке
+ new Thread(() => throw new InvalidOperationException(BackgroundCrashMessage)).Start();
+
+ Thread.Sleep(TimeSpan.FromMinutes(1)); //столько процесс не проживёт
+ }
+
+ ///
+ /// Сборка настроек лаунчера так же, как её делает продукт: логотип и иконка берутся
+ /// встроенным ресурсом по имени-строке. Имя не сходится - и GetResourceByteArray
+ /// роняет старт, не добравшись даже до контейнера
+ ///
+ private static void ConfigureLauncher() {
+ var options = new LauncherOptions {
+ AppTitle = "проба",
+ LogoImage = typeof(CrashProbe).GetTypeInfo().Assembly.GetResourceByteArray(MissingResource)
+ };
+
+ new ServiceCollection()
+ .AddLauncherOptions(options)
+ .AddLauncherDependencies()
+ .AddLauncherViewModels()
+ .AddLauncherErrorHandling()
+ .BuildServiceProvider();
+ }
+
+ /// Отправка отчёта, которую видно снаружи процесса
+ private sealed class MarkerWriter : IErrorReporter {
+ private readonly string path;
+
+ public MarkerWriter(string path) {
+ this.path = path;
+ }
+
+ public bool SendReport(Exception exception, ErrorType type = ErrorType.Automatic) {
+ File.WriteAllText(path, $"{type}: {exception?.GetType().Name}: {exception?.Message}");
+ return true;
+ }
+ }
+ }
+}
diff --git a/QS.LibsTest.Core/ErrorReporting/CrashProbeTest.cs b/QS.LibsTest.Core/ErrorReporting/CrashProbeTest.cs
new file mode 100644
index 000000000..fd9da69e8
--- /dev/null
+++ b/QS.LibsTest.Core/ErrorReporting/CrashProbeTest.cs
@@ -0,0 +1,116 @@
+using NUnit.Framework;
+using QS.ErrorReporting;
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Threading;
+
+namespace QS.LibsTest.Core.ErrorReporting {
+ ///
+ /// Единственный способ проверить по-настоящему - уронить процесс.
+ /// В процессе тестового хоста этого не сделать, поэтому падение устраивается в дочернем:
+ /// та же сборка запускается с аргументом-признаком и падает по указанному сценарию.
+ ///
+ [TestFixture(TestOf = typeof(CrashReporting))]
+ [NonParallelizable]
+ public class CrashProbeTest {
+ private static readonly TimeSpan Patience = TimeSpan.FromSeconds(20);
+
+ /// Столько ждём отсутствия отчёта - вдвое больше, чем уходит на его отправку
+ private static readonly TimeSpan ShortWait = TimeSpan.FromSeconds(5);
+
+ private string markerPath;
+
+ [SetUp]
+ public void SetUp() =>
+ markerPath = Path.Combine(Path.GetTempPath(), "qs-crash-probe-" + Guid.NewGuid().ToString("N") + ".txt");
+
+ [TearDown]
+ public void TearDown() {
+ if(File.Exists(markerPath))
+ File.Delete(markerPath);
+ }
+
+ [Test(Description = "Настоящее падение на старте: логотип берётся ресурсом, имя ресурса не сошлось")]
+ public void StartupCrash_Subscribed_ReportSentBeforeProcessDies() {
+ bool reported, exited;
+ using(var probe = StartProbe(CrashProbe.StartupScenario, subscribe: true)) {
+ reported = WaitForMarker(Patience);
+ exited = probe.WaitForExit((int)Patience.TotalMilliseconds);
+ Kill(probe);
+ }
+
+ Assert.That(reported, Is.True,
+ "на старте контейнера ещё нет, сети потока интерфейса не подняты - ловить больше нечем");
+ Assert.That(File.ReadAllText(markerPath),
+ Does.Contain(nameof(NullReferenceException)).And.Contain(ErrorType.Automatic.ToString()),
+ "отсутствующий ресурс приходит пустым потоком, а падает уже на копировании; " +
+ "отправляется само - спрашивать пользователя уже некому");
+ Assert.That(exited, Is.True,
+ "процесс всё равно умирает - перехват даёт только успеть отправить отчёт");
+ }
+
+ [Test(Description = "Без подписки то же падение не оставляет следов: приложение умирает молча")]
+ public void StartupCrash_NotSubscribed_DiesSilently() {
+ bool reported;
+ using(var probe = StartProbe(CrashProbe.StartupScenario, subscribe: false)) {
+ reported = WaitForMarker(ShortWait);
+ Kill(probe);
+ }
+
+ Assert.That(reported, Is.False,
+ "отчёт взяться неоткуда: AppDomain.UnhandledException - единственное событие про это падение");
+ }
+
+ [Test(Description = "Падение фонового потока идёт мимо ReactiveUI и диспетчера - тоже только сюда")]
+ public void BackgroundThreadCrash_Subscribed_ReportSent() {
+ bool reported;
+ using(var probe = StartProbe(CrashProbe.BackgroundScenario, subscribe: true)) {
+ reported = WaitForMarker(Patience);
+ probe.WaitForExit((int)ShortWait.TotalMilliseconds);
+ Kill(probe);
+ }
+
+ Assert.That(reported, Is.True, "фоновый поток поймать больше некому");
+ }
+
+ private Process StartProbe(string scenario, bool subscribe) {
+ string assembly = typeof(CrashProbeTest).GetTypeInfo().Assembly.Location;
+ var start = new ProcessStartInfo("dotnet") {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+ foreach(var argument in new[] {
+ "exec", assembly, CrashProbe.Argument, markerPath,
+ subscribe ? "subscribe" : "no-subscribe", scenario })
+ start.ArgumentList.Add(argument);
+
+ var process = Process.Start(start);
+ Assert.That(process, Is.Not.Null, "не удалось запустить дочерний процесс пробы");
+ return process;
+ }
+
+ private bool WaitForMarker(TimeSpan timeout) {
+ var elapsed = Stopwatch.StartNew();
+ while(elapsed.Elapsed < timeout) {
+ if(File.Exists(markerPath))
+ return true;
+ Thread.Sleep(50);
+ }
+ return File.Exists(markerPath);
+ }
+
+ ///
+ /// Труп процесса на Windows может задержать отчёт системы об ошибке - тест этого не ждёт
+ ///
+ private static void Kill(Process process) {
+ try {
+ if(!process.HasExited)
+ process.Kill();
+ }
+ catch(InvalidOperationException) { } //успел выйти сам между проверкой и Kill
+ }
+ }
+}
diff --git a/QS.LibsTest.Core/ErrorReporting/UnknownErrorHandlingTest.cs b/QS.LibsTest.Core/ErrorReporting/UnknownErrorHandlingTest.cs
new file mode 100644
index 000000000..bfd1a9536
--- /dev/null
+++ b/QS.LibsTest.Core/ErrorReporting/UnknownErrorHandlingTest.cs
@@ -0,0 +1,122 @@
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using NUnit.Framework;
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.Dialog;
+using QS.ErrorReporting;
+using QS.Launcher;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+
+namespace QS.LibsTest.Core.ErrorReporting {
+ ///
+ /// Что происходит с ошибкой, которую не узнал ни один .
+ /// Цепочка собирается не руками, а вызовом AddLauncherErrorHandling: если набор обработчиков
+ /// в регистрации изменится и незнакомая ошибка вдруг окажется «разобранной» - тест упадёт.
+ ///
+ [TestFixture(TestOf = typeof(ErrorHandlingService))]
+ public class UnknownErrorHandlingTest {
+ private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
+
+ private const string SendButton = "Отправить отчёт";
+ private const string OperationTitle = "Смена пароля";
+
+ private IInteractiveMessage message;
+ private IInteractiveQuestion question;
+ private IErrorReporter reporter;
+ private ErrorReportingSettings settings;
+ private IErrorHandlingService handling;
+ private IEnumerable handlers;
+
+ [SetUp]
+ public void SetUp() {
+ message = Substitute.For();
+ question = Substitute.For();
+ reporter = Substitute.For();
+ reporter.SendReport(Arg.Any(), Arg.Any()).Returns(true);
+ settings = new ErrorReportingSettings(
+ requestEmail: false, requestDescription: false, sendAutomatically: true, logRowCount: null);
+
+ var container = new ServiceCollection()
+ .AddSingleton(message)
+ .AddSingleton(question)
+ .AddSingleton(reporter)
+ .AddSingleton(settings)
+ .AddLauncherErrorHandling()
+ .BuildServiceProvider();
+
+ handling = container.GetRequiredService();
+ handlers = container.GetServices();
+ }
+
+ [Test(Description = "Ошибку из настоящего метода не узнаёт ни один обработчик - она доходит до отправки отчёта")]
+ public void Handle_ExceptionFromProviderMethod_NoHandlerTakesIt_ReportSent() {
+ // без обработчиков проверять было бы нечего
+ Assume.That(handlers, Is.Not.Empty);
+ var exception = ExceptionFromChangeOwnPassword();
+
+ handling.Handle(exception, OperationTitle);
+
+ reporter.Received(1).SendReport(exception, ErrorType.Automatic);
+ // у каждого обработчика свой заголовок окна: сообщение ровно одно и с заголовком операции
+ Assert.That(message.ReceivedCalls().Count(), Is.EqualTo(1),
+ "незнакомую ошибку не должен показывать ни один обработчик");
+ message.Received(1).ShowMessage(ImportanceLevel.Error,
+ Arg.Is(text => text.StartsWith(exception.Message, StringComparison.Ordinal)), OperationTitle);
+ }
+
+ [Test(Description = "Без автоотправки отчёт уходит только после согласия пользователя")]
+ public void Handle_AutoSendDisabled_AsksUserBeforeSending() {
+ settings.SendAutomatically = false;
+ question.Question(Arg.Any(), Arg.Any(), Arg.Any()).Returns(SendButton);
+ var exception = ExceptionFromChangeOwnPassword();
+
+ handling.Handle(exception, OperationTitle);
+
+ // вопрос блокирующий и задаётся из чужого потока - без ожидания тест увидел бы пустоту
+ Assert.That(WaitUntil(() => reporter.ReceivedCalls().Any(), Patience), Is.True,
+ "после согласия пользователя отчёт обязан уйти");
+ reporter.Received(1).SendReport(exception, ErrorType.User);
+ }
+
+ private static Exception ExceptionFromChangeOwnPassword() => RealSource.FromChangeOwnPassword();
+
+ ///
+ /// Настоящее исключение из настоящего метода: пустой пароль ломает контракт
+ /// ещё до обращения к серверу
+ ///
+ private static class RealSource {
+ public static Exception FromChangeOwnPassword() {
+ var parameters = new List {
+ new ConnectionParameterValue(new ConnectionParameter("Server", "Сервер"), "localhost"),
+ new ConnectionParameterValue(new ConnectionParameter("Login", "Пользователь"), "someone")
+ };
+
+ using(var provider = new MariaDBProvider(parameters, productCode: 1, password: "pass")) {
+ try {
+ provider.ChangeOwnPassword(string.Empty);
+ }
+ catch(ArgumentException ex) {
+ return ex;
+ }
+ }
+ throw new InvalidOperationException(
+ "ChangeOwnPassword перестал проверять пустой пароль - тест потерял источник ошибки");
+ }
+ }
+
+ private static bool WaitUntil(Func condition, TimeSpan timeout) {
+ var elapsed = Stopwatch.StartNew();
+ while(elapsed.Elapsed < timeout) {
+ if(condition())
+ return true;
+ Thread.Sleep(20);
+ }
+ return condition();
+ }
+ }
+}
diff --git a/QS.LibsTest.Core/Launcher/Base/AppBase.sql b/QS.LibsTest.Core/Launcher/Base/AppBase.sql
new file mode 100644
index 000000000..0ec88c7d5
--- /dev/null
+++ b/QS.LibsTest.Core/Launcher/Base/AppBase.sql
@@ -0,0 +1,27 @@
+-- Скелет прикладной базы продукта - ровно то, что от неё нужно лаунчеру.
+--
+-- base_parameters читает ParametersService: по ним провайдер опознаёт, что база принадлежит
+-- продукту, и достаёт заголовок с версией.
+-- users - таблица пользователей самой базы, в неё лаунчер пишет профиль и снимает доступ
+-- через deactivated.
+--
+-- name NOT NULL без дефолта - как в реальных базах продуктов; на этом держится проверка
+-- того, что синхронизация подставляет логин вместо пустого имени.
+
+CREATE TABLE `base_parameters` (
+ `name` VARCHAR(64) NOT NULL,
+ `str_value` VARCHAR(255) NULL,
+ PRIMARY KEY (`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `users` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `login` VARCHAR(80) NOT NULL,
+ `name` VARCHAR(255) NOT NULL,
+ `email` VARCHAR(255) NULL,
+ `description` TEXT NULL,
+ `admin` TINYINT(1) NOT NULL DEFAULT 0,
+ `deactivated` TINYINT(1) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_users_login` (`login`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/QS.LibsTest.Core/Launcher/Base/QSLauncher.sql b/QS.LibsTest.Core/Launcher/Base/QSLauncher.sql
new file mode 100644
index 000000000..054076e05
--- /dev/null
+++ b/QS.LibsTest.Core/Launcher/Base/QSLauncher.sql
@@ -0,0 +1,29 @@
+CREATE TABLE `server_users` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `login` VARCHAR(80) NOT NULL,
+ `name` VARCHAR(255) NULL,
+ `email` VARCHAR(255) NULL,
+ `phone` VARCHAR(50) NULL,
+ `is_admin` TINYINT(1) NOT NULL DEFAULT 0,
+ `disabled` TINYINT(1) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_server_users_login` (`login`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `bases` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `product_id` TINYINT UNSIGNED NOT NULL,
+ `base_name` VARCHAR(64) NOT NULL,
+ `base_title` VARCHAR(255) NULL,
+ `version` VARCHAR(32) NULL,
+ `disabled` TINYINT(1) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_bases_identity` (`product_id`, `base_name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `base_update_rights` (
+ `user_id` INT NOT NULL,
+ `base_id` INT NOT NULL,
+ `can_update` TINYINT(1) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`user_id`, `base_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/QS.LibsTest.Core/Launcher/Cloud/CloudClientAuthTest.cs b/QS.LibsTest.Core/Launcher/Cloud/CloudClientAuthTest.cs
new file mode 100644
index 000000000..7bcf59096
--- /dev/null
+++ b/QS.LibsTest.Core/Launcher/Cloud/CloudClientAuthTest.cs
@@ -0,0 +1,46 @@
+using Grpc.Core;
+using NUnit.Framework;
+using QS.Cloud.Client;
+using System;
+using System.Text;
+
+namespace QS.Launcher.Test.Cloud {
+ /// Сборка запроса к облак
+ [TestFixture(TestOf = typeof(CloudClientByBasicAuth))]
+ public class CloudClientAuthTest
+ {
+ private sealed class Probe : CloudClientByBasicAuth {
+ public Probe(IBasicAuthInfoProvider auth, string address, int port) : base(auth, address, port) { }
+
+ public string AuthorizationHeader => headers.GetValue("authorization");
+ public string ChannelTarget => Channel.Target;
+ }
+
+ [Test(Description = "Логин с аккаунтом и пароль уезжают заголовком Basic в base64")]
+ public void BasicAuth_PacksAccountLoginAndPassword() {
+ using(var probe = NewProbe(new BasicAuthInfoProvider(@"testaccount\admin", "s3cret"))) {
+ string header = probe.AuthorizationHeader;
+
+ Assert.That(header, Does.StartWith("Basic "));
+ Assert.That(Decode(header), Is.EqualTo(@"testaccount\admin:s3cret"),
+ "облако разбирает пару как «аккаунт\\логин:пароль»");
+ }
+ }
+
+ [Test(Description = "После смены пароля клиент подписывается новым - со старым облако ответит Unauthenticated")]
+ public void UpdatePassword_RebuildsAuthorizationHeader() {
+ using(var probe = NewProbe(new BasicAuthInfoProvider(@"testaccount\admin", "old"))) {
+ probe.UpdatePassword("n3w");
+
+ Assert.That(Decode(probe.AuthorizationHeader), Is.EqualTo(@"testaccount\admin:n3w"));
+ }
+ }
+
+ private static string Decode(string authorizationHeader) =>
+ Encoding.UTF8.GetString(
+ Convert.FromBase64String(authorizationHeader.Substring("Basic ".Length)));
+
+ private static Probe NewProbe(IBasicAuthInfoProvider auth) =>
+ new Probe(auth, "core.cloud.qsolution.ru", 443);
+ }
+}
diff --git a/QS.LibsTest.Core/Launcher/Cloud/CloudPages_IntegrationTest.cs b/QS.LibsTest.Core/Launcher/Cloud/CloudPages_IntegrationTest.cs
new file mode 100644
index 000000000..8f8e60739
--- /dev/null
+++ b/QS.LibsTest.Core/Launcher/Cloud/CloudPages_IntegrationTest.cs
@@ -0,0 +1,66 @@
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using NUnit.Framework;
+using QS.Cloud.Client.Database;
+using QS.Cloud.Core;
+using QS.DbManagement.Entities;
+using QS.Dialog;
+using QS.Launcher.ViewModels.PageViewModels.Database;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace QS.Launcher.Test.Cloud {
+ [TestFixture(TestOf = typeof(DatabasesVM))]
+ public class CloudPages_IntegrationTest : CloudProviderTestFixtureBase {
+ private LauncherPagesHarness Pages { get; set; }
+
+ [OneTimeSetUp]
+ public void SetUpSchedulers() => LauncherPagesHarness.UseImmediateSchedulers();
+
+ [SetUp]
+ public void SetUpPages() =>
+ Pages = new LauncherPagesHarness(new QsCloudConnectionTypeBase(), "Облако", TestProductCode);
+
+ [Test(Description = "Синхронизация метаинформации в облаке недоступна - реестр баз ведёт оно само")]
+ public async Task Capabilities_Cloud_HidesRefreshMetadata()
+ {
+ LoginClient.GetBasesForUser(Arg.Any()).Returns(new List { Base(1, "caps_base") });
+
+ var page = await Pages.OpenDatabasesPage(LoginAs());
+
+ Assert.That(page.Capabilities.CanRefreshMetadata, Is.False);
+ Assert.That(page.Capabilities.CanCreate, Is.True);
+ }
+
+ [Test(Description = "Кнопка Подключиться отдаёт запускателю идентификатор сессии - у свободного подключения его нет")]
+ public async Task ConnectCommand_PassesCloudSessionToAppRunner() {
+ LoginClient.GetBasesForUser(Arg.Any()).Returns(new List { Base(2, "to_run", "Рабочая") });
+ LoginClient.StartSession(2).Returns(new StartSessionResponse {
+ Success = true, SessionId = "session-2", IsAdmin = true,
+ Db = new BaseConnection {
+ Login = "db_user", Password = "db_pass", Server = "db.example", BaseName = "to_run", Port = 3306
+ }
+ });
+
+ var page = await Pages.OpenDatabasesPage(LoginAs());
+ page.SelectedDatabase = page.Databases.First(d => d.BaseName == "to_run");
+
+ await page.ConnectAsync();
+
+ Pages.AppRunner.Received(1).Run(Arg.Is(
+ r => r.Success && r.Parameters["SessionId"] == "session-2"));
+ }
+
+ [Test(Description = "Недоступное облако страница показывает сообщением, а не падением")]
+ public void UsersPage_CloudUnavailable_ShowsError() {
+ var provider = LoginAs();
+ UserClient.GetUsers().Throws(Refusal(Grpc.Core.StatusCode.Unavailable));
+
+ Assert.DoesNotThrowAsync(async () => await Pages.OpenUsersPage(provider));
+
+ Pages.InteractiveMessage.Received().ShowMessage(ImportanceLevel.Error,
+ Arg.Any(), "Управление пользователями");
+ }
+ }
+}
diff --git a/QS.LibsTest.Core/Launcher/Cloud/CloudProviderTestFixtureBase.cs b/QS.LibsTest.Core/Launcher/Cloud/CloudProviderTestFixtureBase.cs
new file mode 100644
index 000000000..9bccc03d8
--- /dev/null
+++ b/QS.LibsTest.Core/Launcher/Cloud/CloudProviderTestFixtureBase.cs
@@ -0,0 +1,55 @@
+using Grpc.Core;
+using NSubstitute;
+using NUnit.Framework;
+using QS.Cloud.Client;
+using QS.Cloud.Client.Clients;
+using QS.Cloud.Client.Database;
+using QS.Cloud.Core;
+
+namespace QS.Launcher.Test.Cloud {
+ public abstract class CloudProviderTestFixtureBase {
+ protected const string AccountName = "testaccount";
+ protected const string AdminLogin = "admin";
+ protected const byte TestProductCode = 1;
+
+ protected LoginManagementCloudClient LoginClient { get; private set; }
+ protected DatabaseManagementCloudClient DbClient { get; private set; }
+ protected UserManagementCloudClient UserClient { get; private set; }
+
+ [SetUp]
+ public void CreateClients() {
+ var auth = new BasicAuthInfoProvider($@"{AccountName}\{AdminLogin}", "pass");
+
+ LoginClient = Substitute.For(auth);
+ DbClient = Substitute.For(auth, (uint)TestProductCode);
+ UserClient = Substitute.For(auth);
+
+ DbClient.CanConnect.Returns(true);
+ }
+
+ protected QSCloudProvider CreateProvider(string login = AdminLogin, byte productCode = TestProductCode) =>
+ new QSCloudProvider(AccountName, login, productCode, LoginClient, DbClient, UserClient);
+
+ protected QSCloudProvider LoginAs(string login = AdminLogin, bool isAdmin = true) {
+ LoginClient.Start(Arg.Any())
+ .Returns(new StartResponse { YouAccountAdmin = isAdmin });
+
+ var provider = CreateProvider(login);
+ var response = provider.LoginToServer();
+ Assert.That(response.Success, Is.True, response.ErrorMessage);
+ return provider;
+ }
+
+ protected static RpcException Refusal(StatusCode code, string detail = "облако недоступно") =>
+ new RpcException(new Status(code, detail));
+
+ protected static BaseInfo Base(int id, string name, string title = null, string version = "1.0") =>
+ new BaseInfo { BaseId = id, BaseName = name, BaseTitle = title ?? name, BaseVersion = version };
+
+ protected static UserInfo User(string login, string name = "", bool isAdmin = false, bool disabled = false) =>
+ new UserInfo {
+ Login = login, Name = name, Email = string.Empty, Phone = string.Empty,
+ Post = string.Empty, Comment = string.Empty, Disabled = disabled, IsAccountAdmin = isAdmin
+ };
+ }
+}
diff --git a/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_BaseAccessTest.cs b/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_BaseAccessTest.cs
new file mode 100644
index 000000000..4f4402d74
--- /dev/null
+++ b/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_BaseAccessTest.cs
@@ -0,0 +1,89 @@
+using NSubstitute;
+using NUnit.Framework;
+using QS.Cloud.Client.Database;
+using QS.Cloud.Core;
+using QS.DbManagement.Entities;
+using System.Collections.Generic;
+using System.Linq;
+using OperationRefusedException = QS.ErrorReporting.OperationRefusedException;
+
+namespace QS.Launcher.Test.Cloud {
+ /// Доступ к базам
+ [TestFixture(TestOf = typeof(QSCloudProvider))]
+ public class CloudProvider_BaseAccessTest : CloudProviderTestFixtureBase {
+ private const string Worker = "worker";
+ private const int BaseId = 7;
+
+ [Test(Description = "Список доступов приходит с флагами по каждой базе")]
+ public void GetUserBaseAccess_MapsFlagsPerBase() {
+ UserClient.GetUserBaseAccess(Worker, TestProductCode).Returns(new List {
+ new BaseAccessInfo { BaseId = BaseId, BaseTitle = "Рабочая", HasAccess = true, Admin = true, ReadOnly = true },
+ new BaseAccessInfo { BaseId = 8, BaseTitle = "Вторая", HasAccess = false }
+ });
+
+ var rows = LoginAs().GetUserBaseAccess(Worker);
+
+ Assert.That(rows.Select(r => r.BaseId), Is.EquivalentTo(new[] { BaseId, 8 }));
+ var granted = rows.Single(r => r.BaseId == BaseId);
+ Assert.That(granted.Title, Is.EqualTo("Рабочая"));
+ Assert.That(granted.HasAccess, Is.True);
+ Assert.That(granted.IsAdmin, Is.True, "Admin облака - это IsAdmin в карточке доступа");
+ Assert.That(granted.ReadOnly, Is.True);
+ Assert.That(rows.Single(r => r.BaseId == 8).HasAccess, Is.False);
+ }
+
+ [TestCase(false, false, TestName = "Доступ без дополнительных прав")]
+ [TestCase(true, false, TestName = "Администратор базы")]
+ [TestCase(false, true, TestName = "Только чтение")]
+ public void SetUserBaseAccess_Granted_SendsFlags(bool admin, bool readOnly) {
+ UserClient.ChangeBaseAccess(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new ChangeBaseAccessResponse { Success = true });
+
+ LoginAs().SetUserBaseAccess(Worker, new DbUserBaseAccess {
+ BaseId = BaseId, HasAccess = true, IsAdmin = admin, ReadOnly = readOnly
+ });
+
+ UserClient.Received(1).ChangeBaseAccess(Worker, BaseId, true, admin, readOnly, TestProductCode);
+ }
+
+ [Test(Description = "Снятие доступа уходит тем же вызовом с grant = false")]
+ public void SetUserBaseAccess_Revoked_SendsGrantFalse() {
+ UserClient.ChangeBaseAccess(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new ChangeBaseAccessResponse { Success = true });
+
+ LoginAs().SetUserBaseAccess(Worker, new DbUserBaseAccess { BaseId = BaseId, HasAccess = false });
+
+ UserClient.Received(1).ChangeBaseAccess(Worker, BaseId, false, false, false, TestProductCode);
+ }
+
+ [Test(Description = "Отказ облака объясняется его же текстом")]
+ public void SetUserBaseAccess_Refused_ThrowsWithCloudMessage() {
+ UserClient.ChangeBaseAccess(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new ChangeBaseAccessResponse { Success = false, Message = "База не найдена" });
+ var provider = LoginAs();
+
+ // тип важен не меньше текста: по нему цепочка IErrorHandler отличает отказ по делу
+ // от поломки и не предлагает пользователю отправить отчёт об ошибке
+ var exception = Assert.Throws(
+ () => provider.SetUserBaseAccess(Worker, new DbUserBaseAccess { BaseId = 9999, HasAccess = true }));
+
+ Assert.That(exception.Message, Is.EqualTo("База не найдена"));
+ }
+
+ [Test(Description = "Отказ без текста всё равно объясняется - пустое сообщение пользователю бесполезно")]
+ public void SetUserBaseAccess_RefusedWithoutMessage_ThrowsWithFallbackText() {
+ UserClient.ChangeBaseAccess(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new ChangeBaseAccessResponse { Success = false, Message = string.Empty });
+ var provider = LoginAs();
+
+ var exception = Assert.Throws(
+ () => provider.SetUserBaseAccess(Worker, new DbUserBaseAccess { BaseId = BaseId, HasAccess = true }));
+
+ Assert.That(exception.Message, Does.Contain("Не удалось изменить доступ"));
+ }
+ }
+}
diff --git a/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_DatabasesTest.cs b/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_DatabasesTest.cs
new file mode 100644
index 000000000..8c29742ff
--- /dev/null
+++ b/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_DatabasesTest.cs
@@ -0,0 +1,239 @@
+using Grpc.Core;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using NUnit.Framework;
+using QS.Cloud.Client.Database;
+using QS.Cloud.Core;
+using QS.DbManagement.Creation;
+using QS.DbManagement.Entities;
+using QS.DBScripts.Controllers;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OperationRefusedException = QS.ErrorReporting.OperationRefusedException;
+
+namespace QS.Launcher.Test.Cloud {
+ ///
+ /// Список баз, подключение и создание
+ ///
+ [TestFixture(TestOf = typeof(QSCloudProvider))]
+ public class CloudProvider_DatabasesTest : CloudProviderTestFixtureBase {
+
+ public sealed class FakeResources : DbCreationResources { }
+
+ public sealed class FakeCreationModel : IDbCreatorModel {
+ public static bool WasRun { get; set; }
+ public static string ConnectionStringSeen { get; set; }
+
+ private readonly DbCreationResources resources;
+ public FakeCreationModel(DbCreationResources resources) => this.resources = resources;
+
+ public bool RunCreation(string dbName, string dbTitle) {
+ WasRun = true;
+ ConnectionStringSeen = resources.ConnectionString;
+ return true;
+ }
+ }
+
+ private IDbCreatorInteraction interaction;
+
+ [SetUp]
+ public void ResetCreationModel() {
+ FakeCreationModel.WasRun = false;
+ FakeCreationModel.ConnectionStringSeen = null;
+ interaction = Substitute.For();
+ }
+
+ private DbCreationRequest CreationRequest(string dbName, string title = null) {
+ var map = new DbResourcesCreationMap();
+ map.Register(typeof(FakeResources), typeof(FakeCreationModel));
+
+ return new DbCreationRequest {
+ DbName = dbName,
+ DbTitle = title ?? dbName,
+ Interaction = interaction,
+ CreationFactory = new DbCreationFactory(map),
+ CreationResources = new FakeResources()
+ };
+ }
+
+ private void SessionOpens(int baseId, string baseName, bool isAdmin = true) =>
+ LoginClient.StartSession(baseId).Returns(new StartSessionResponse {
+ Success = true, SessionId = $"session-{baseId}", IsAdmin = isAdmin,
+ Db = new BaseConnection {
+ Login = "db_user", Password = "db_pass", Server = "db.example",
+ BaseName = baseName, Port = 3306
+ }
+ });
+
+ [Test(Description = "Список баз приходит из облака со всеми полями")]
+ public void GetUserDatabases_MapsBasesFromCloud() {
+ LoginClient.GetBasesForUser(TestProductCode)
+ .Returns(new List { Base(3, "cloud_base", "Облачная", "4.2") });
+
+ var found = LoginAs().GetUserDatabases().Single();
+
+ Assert.That(found.BaseName, Is.EqualTo("cloud_base"));
+ Assert.That(found.Title, Is.EqualTo("Облачная"));
+ Assert.That(found.Version, Is.EqualTo("4.2"));
+ Assert.That(found.BaseId, Is.EqualTo(3), "идентификатор базы ведёт облако");
+ }
+
+ [Test(Description = "Удаление базы отправляет её идентификатор в облако")]
+ public void DropDatabase_SendsBaseIdToCloud() {
+ DbClient.DropDatabase(5).Returns(new DropDatabaseResponse { Success = true });
+
+ bool dropped = LoginAs().DropDatabase(new DbInfo { BaseId = 5, BaseName = "to_drop" });
+
+ Assert.That(dropped, Is.True);
+ DbClient.Received(1).DropDatabase(5);
+ }
+
+ [Test(Description = "Отказ облака в удалении не выдаётся за успех")]
+ public void DropDatabase_CloudRefused_Throws() {
+ DbClient.DropDatabase(7).Returns(new DropDatabaseResponse { Success = false });
+ var provider = LoginAs();
+
+ // иначе страница показала бы «База данных удалена», а база осталась бы на месте
+ Assert.Throws(
+ () => provider.DropDatabase(new DbInfo { BaseId = 7, BaseName = "kept" }));
+ }
+
+ [Test(Description = "Подключение отдаёт строку из облака и идентификатор сессии")]
+ public void LoginToDatabase_ReturnsCloudConnectionAndSessionId() {
+ SessionOpens(4, "to_connect");
+
+ var response = LoginAs().LoginToDatabase(new DbInfo { BaseId = 4, Title = "Рабочая" });
+
+ Assert.That(response.Success, Is.True, response.ErrorMessage);
+ Assert.That(response.ConnectionString, Does.Contain("to_connect"), "имя базы даёт облако");
+ Assert.That(response.ConnectionString, Does.Contain("db.example"), "адрес сервера тоже от облака");
+ Assert.That(response.Parameters["SessionId"], Is.EqualTo("session-4"),
+ "идентификатор сессии - облачное понятие, у свободного подключения его нет");
+ Assert.That(response.Parameters["BaseTitle"], Is.EqualTo("Рабочая"));
+ }
+
+ [Test(Description = "Отказ облака в сессии - Response с ошибкой, приложение не падает")]
+ public void LoginToDatabase_SessionRefused_ReturnsFailure() {
+ LoginClient.StartSession(Arg.Any())
+ .Returns(new StartSessionResponse { Success = false, Description = "Сессии недоступны" });
+
+ var response = LoginAs().LoginToDatabase(new DbInfo { BaseId = 6 });
+
+ Assert.That(response.Success, Is.False);
+ }
+
+ [Test(Description = "Новой базы в облаке нет - заводим запись и наполняем")]
+ public void CreateDatabase_NewBase_RegistersInCloudAndFillsIt() {
+ DbClient.CheckDatabaseExists("fresh_base").Returns(new CheckDatabaseExistsResponse { Exists = false });
+ DbClient.CreateDatabase("fresh_base", "Свежая").Returns(new CreateDatabaseResponse { BaseId = 11 });
+ SessionOpens(11, "fresh_base");
+
+ bool created = LoginAs().CreateDatabase(CreationRequest("fresh_base", "Свежая"));
+
+ Assert.That(created, Is.True);
+ DbClient.Received(1).CreateDatabase("fresh_base", "Свежая");
+ Assert.That(FakeCreationModel.WasRun, Is.True, "наполнение должно запуститься");
+ Assert.That(FakeCreationModel.ConnectionStringSeen, Does.Contain("fresh_base"),
+ "наполнение идёт по строке подключения из сессии облака");
+ }
+
+ [Test(Description = "Пересоздать сносит запись в облаке и заводит новую")]
+ public void CreateDatabase_Recreate_DropsThenCreates() {
+ DbClient.CheckDatabaseExists("busy_base").Returns(new CheckDatabaseExistsResponse { Exists = true, BaseId = 12 });
+ DbClient.DropDatabase(12).Returns(new DropDatabaseResponse { Success = true });
+ DbClient.CreateDatabase("busy_base", "busy_base").Returns(new CreateDatabaseResponse { BaseId = 13 });
+ SessionOpens(13, "busy_base");
+ interaction.AskDropExistingDatabase("busy_base").Returns(ToDoWithExistingDatabase.Recreate);
+
+ Assert.That(LoginAs().CreateDatabase(CreationRequest("busy_base")), Is.True);
+
+ Received.InOrder(() => {
+ DbClient.DropDatabase(12);
+ DbClient.CreateDatabase("busy_base", "busy_base");
+ });
+ }
+
+ [Test(Description = "Перезаписать чистит базу, сохраняя запись реестра и доступы")]
+ public void CreateDatabase_Rewrite_ClearsAndKeepsRegistry() {
+ DbClient.CheckDatabaseExists("keep_base").Returns(new CheckDatabaseExistsResponse { Exists = true, BaseId = 14 });
+ DbClient.ClearDatabase(14).Returns(new ClearDatabaseResponse { Success = true });
+ SessionOpens(14, "keep_base");
+ interaction.AskDropExistingDatabase("keep_base").Returns(ToDoWithExistingDatabase.Rewrite);
+
+ Assert.That(LoginAs().CreateDatabase(CreationRequest("keep_base")), Is.True);
+
+ DbClient.Received(1).ClearDatabase(14);
+ DbClient.DidNotReceive().DropDatabase(Arg.Any());
+ DbClient.DidNotReceive().CreateDatabase(Arg.Any(), Arg.Any());
+ }
+
+ [Test(Description = "Ничего не делать")]
+ public void CreateDatabase_Nothing_LeavesEverythingAlone() {
+ DbClient.CheckDatabaseExists("untouched").Returns(new CheckDatabaseExistsResponse { Exists = true, BaseId = 15 });
+ interaction.AskDropExistingDatabase("untouched").Returns(ToDoWithExistingDatabase.Nothing);
+
+ Assert.That(LoginAs().CreateDatabase(CreationRequest("untouched")), Is.False);
+
+ DbClient.DidNotReceive().DropDatabase(Arg.Any());
+ DbClient.DidNotReceive().ClearDatabase(Arg.Any());
+ Assert.That(FakeCreationModel.WasRun, Is.False, "наполнение запускаться не должно");
+ }
+
+ [Test(Description = "Не удалось очистить базу - пользователь получает объяснение, наполнение не идёт")]
+ public void CreateDatabase_ClearFailed_ReportsErrorAndStops() {
+ DbClient.CheckDatabaseExists("stuck").Returns(new CheckDatabaseExistsResponse { Exists = true, BaseId = 16 });
+ DbClient.ClearDatabase(16).Returns(new ClearDatabaseResponse { Success = false });
+ interaction.AskDropExistingDatabase("stuck").Returns(ToDoWithExistingDatabase.Rewrite);
+
+ Assert.That(LoginAs().CreateDatabase(CreationRequest("stuck")), Is.False);
+
+ interaction.Received().ReportError(Arg.Is(m => m.Contains("очистить")), Arg.Any());
+ Assert.That(FakeCreationModel.WasRun, Is.False);
+ }
+
+ [Test(Description = "Нет прав администратора базы - наполнение не начинается, пользователь получает объяснение")]
+ public void CreateDatabase_SessionWithoutAdmin_ReportsErrorAndStops() {
+ DbClient.CheckDatabaseExists("no_rights").Returns(new CheckDatabaseExistsResponse { Exists = false });
+ DbClient.CreateDatabase(Arg.Any(), Arg.Any()).Returns(new CreateDatabaseResponse { BaseId = 17 });
+ SessionOpens(17, "no_rights", isAdmin: false);
+
+ Assert.That(LoginAs().CreateDatabase(CreationRequest("no_rights")), Is.False);
+
+ interaction.Received().ReportError(
+ Arg.Is(m => m.Contains("прав администратора")), Arg.Any());
+ Assert.That(FakeCreationModel.WasRun, Is.False);
+ }
+
+ [Test(Description = "Облако не открыло сессию - тоже объяснение")]
+ public void CreateDatabase_SessionRefused_ReportsError() {
+ DbClient.CheckDatabaseExists("no_session").Returns(new CheckDatabaseExistsResponse { Exists = false });
+ DbClient.CreateDatabase(Arg.Any(), Arg.Any()).Returns(new CreateDatabaseResponse { BaseId = 18 });
+ LoginClient.StartSession(18)
+ .Returns(new StartSessionResponse { Success = false, Description = "сессия недоступна" });
+
+ Assert.That(LoginAs().CreateDatabase(CreationRequest("no_session")), Is.False);
+
+ interaction.Received().ReportError(Arg.Is(m => m.Contains("сессию")), Arg.Any());
+ }
+
+ [Test(Description = "Пустой запрос на создание - нарушение контракта, а не ответ пользователю")]
+ public void CreateDatabase_NullRequest_Throws() {
+ var provider = LoginAs();
+
+ Assert.Throws(() => provider.CreateDatabase(null));
+ }
+
+ [Test(Description = "Сбой облака при создании превращается в исключение с текстом сервера")]
+ public void CreateDatabase_CloudFailure_ThrowsWithServerDetail() {
+ DbClient.CheckDatabaseExists(Arg.Any())
+ .Throws(Refusal(StatusCode.Internal, "реестр баз недоступен"));
+ var provider = LoginAs();
+
+ var exception = Assert.Throws(
+ () => provider.CreateDatabase(CreationRequest("broken_base")));
+
+ Assert.That(exception.Message, Does.Contain("реестр баз недоступен"));
+ }
+ }
+}
diff --git a/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_LoginTest.cs b/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_LoginTest.cs
new file mode 100644
index 000000000..76f966e43
--- /dev/null
+++ b/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_LoginTest.cs
@@ -0,0 +1,73 @@
+using Grpc.Core;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using NUnit.Framework;
+using QS.Cloud.Client.Database;
+using QS.Cloud.Core;
+
+namespace QS.Launcher.Test.Cloud {
+ /// Вход в облако
+ [TestFixture(TestOf = typeof(QSCloudProvider))]
+ public class CloudProvider_LoginTest : CloudProviderTestFixtureBase {
+
+ // единственный флаг, которым облако управляет правами: из него выводятся и управление
+ // пользователями, и создание баз. Обычный пользователь не должен увидеть ни того, ни другого
+ [TestCase(true, TestName = "Администратор аккаунта получает права управления")]
+ [TestCase(false, TestName = "Обычному пользователю управление недоступно")]
+ public void LoginToServer_AdminFlagFromCloud_DrivesRights(bool isAdmin) {
+ var provider = LoginAs("someone", isAdmin);
+
+ Assert.That(provider.IsAdmin, Is.EqualTo(isAdmin));
+ Assert.That(provider.CanManageUsers, Is.EqualTo(isAdmin));
+ Assert.That(provider.CanCreateDatabase, Is.EqualTo(isAdmin));
+ }
+
+ [TestCase(StatusCode.Unauthenticated, TestName = "Неверный логин или пароль")]
+ [TestCase(StatusCode.PermissionDenied, TestName = "Учётная запись отключена")]
+ public void LoginToServer_Refused_ReturnsFailureResponse(StatusCode code) {
+ LoginClient.Start(Arg.Any()).Throws(Refusal(code, "нет доступа"));
+
+ var response = CreateProvider().LoginToServer();
+
+ Assert.That(response.Success, Is.False);
+ Assert.That(response.ErrorMessage, Does.Contain("Неверные данные для входа"));
+ Assert.That(response.ErrorMessage, Does.Contain("нет доступа"), "текст сервера должен дойти до пользователя");
+ }
+
+ [Test(Description = "Смена пароля переподписывает все клиенты, а не только тот, через который меняли")]
+ public void ChangeOwnPassword_Succeeded_UpdatesEveryClient() {
+ LoginClient.ChangePassword("n3w").Returns(new ChangePasswordResponse { Success = true });
+ var provider = LoginAs();
+
+ Assert.That(provider.ChangeOwnPassword("n3w"), Is.True);
+
+ // пропущенный клиент отвалится с Unauthenticated на первом же запросе
+ LoginClient.Received(1).UpdatePassword("n3w");
+ DbClient.Received(1).UpdatePassword("n3w");
+ UserClient.Received(1).UpdatePassword("n3w");
+ }
+
+ [Test(Description = "Облако пароль не сменило - заголовки остаются прежними")]
+ public void ChangeOwnPassword_Refused_KeepsOldHeaders() {
+ LoginClient.ChangePassword(Arg.Any()).Returns(new ChangePasswordResponse { Success = false });
+ var provider = LoginAs();
+
+ Assert.That(provider.ChangeOwnPassword("n3w"), Is.False);
+
+ // иначе клиент подписался бы паролем, которого в облаке нет
+ LoginClient.DidNotReceive().UpdatePassword(Arg.Any());
+ DbClient.DidNotReceive().UpdatePassword(Arg.Any());
+ UserClient.DidNotReceive().UpdatePassword(Arg.Any());
+ }
+
+ [Test(Description = "Облако недоступно - отдельное сообщение")]
+ public void LoginToServer_CloudUnavailable_ReturnsFailureResponse() {
+ LoginClient.Start(Arg.Any()).Throws(Refusal(StatusCode.Unavailable, "связи нет"));
+
+ var response = CreateProvider().LoginToServer();
+
+ Assert.That(response.Success, Is.False);
+ Assert.That(response.ErrorMessage, Does.Contain("Не удалось подключиться к облаку"));
+ }
+ }
+}
diff --git a/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_UsersTest.cs b/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_UsersTest.cs
new file mode 100644
index 000000000..ba942b8da
--- /dev/null
+++ b/QS.LibsTest.Core/Launcher/Cloud/CloudProvider_UsersTest.cs
@@ -0,0 +1,110 @@
+using Grpc.Core;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using NUnit.Framework;
+using QS.Cloud.Client.Database;
+using QS.Cloud.Core;
+using QS.DbManagement.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OperationRefusedException = QS.ErrorReporting.OperationRefusedException;
+
+namespace QS.Launcher.Test.Cloud {
+ /// Управление пользователями
+ [TestFixture(TestOf = typeof(QSCloudProvider))]
+ public class CloudProvider_UsersTest : CloudProviderTestFixtureBase {
+
+ [Test(Description = "Профиль из облака доезжает до карточки целиком")]
+ public void GetUsers_MapsFullProfile() {
+ UserClient.GetUsers().Returns(new List {
+ new UserInfo {
+ Login = "worker", Name = "Работник", Email = "w@example.com", Phone = "+7-900",
+ Post = "Кладовщик", Comment = "примечание", Disabled = true, IsAccountAdmin = true
+ }
+ });
+
+ var user = LoginAs().GetUsers().Single();
+
+ Assert.That(user.Login, Is.EqualTo("worker"));
+ Assert.That(user.Name, Is.EqualTo("Работник"));
+ Assert.That(user.Email, Is.EqualTo("w@example.com"));
+ Assert.That(user.Phone, Is.EqualTo("+7-900"));
+ Assert.That(user.Post, Is.EqualTo("Кладовщик"));
+ Assert.That(user.Comment, Is.EqualTo("примечание"));
+ Assert.That(user.Disabled, Is.True);
+ Assert.That(user.IsAdmin, Is.True, "администратор аккаунта в облаке - это IsAdmin в карточке");
+ }
+
+ [Test(Description = "Себя в списке пользователь видит помеченным, независимо от регистра логина")]
+ public void GetUsers_MarksCurrentUser() {
+ UserClient.GetUsers().Returns(new List { User(AdminLogin.ToUpperInvariant()), User("someone") });
+
+ var users = LoginAs().GetUsers();
+
+ Assert.That(users.Single(u => u.Login == AdminLogin.ToUpperInvariant()).IsCurrentUser, Is.True);
+ Assert.That(users.Single(u => u.Login == "someone").IsCurrentUser, Is.False);
+ }
+
+ [Test(Description = "Создание отправляет в облако весь профиль и пароль")]
+ public void CreateUser_SendsProfileAndPassword() {
+ UserClient.CreateUser(Arg.Any(), Arg.Any())
+ .Returns(new CreateUserResponse { Success = true });
+
+ LoginAs().CreateUser(new DbUserInfo {
+ Login = "newbie", Name = "Новичок", Email = "n@example.com", IsAdmin = true
+ }, "newbie-pass");
+
+ UserClient.Received(1).CreateUser(
+ Arg.Is(u => u.Login == "newbie" && u.Name == "Новичок"
+ && u.Email == "n@example.com" && u.IsAccountAdmin),
+ "newbie-pass");
+ }
+
+ [Test(Description = "Незаполненные поля уходят пустыми строками - protobuf не принимает null")]
+ public void CreateUser_EmptyProfile_SendsEmptyStringsNotNull() {
+ UserClient.CreateUser(Arg.Any(), Arg.Any())
+ .Returns(new CreateUserResponse { Success = true });
+
+ LoginAs().CreateUser(new DbUserInfo { Login = "bare" }, null);
+
+ UserClient.Received(1).CreateUser(
+ Arg.Is(u => u.Name == "" && u.Email == "" && u.Phone == ""
+ && u.Post == "" && u.Comment == ""),
+ Arg.Any());
+ }
+
+ [Test(Description = "Отказ облака становится исключением с его же текстом")]
+ public void CreateUser_CloudRefused_ThrowsWithCloudMessage() {
+ UserClient.CreateUser(Arg.Any(), Arg.Any())
+ .Returns(new CreateUserResponse { Success = false, Message = "Логин уже занят" });
+ var provider = LoginAs();
+
+ var exception = Assert.Throws(
+ () => provider.CreateUser(new DbUserInfo { Login = "occupied" }, "pass"));
+
+ Assert.That(exception.Message, Is.EqualTo("Логин уже занят"));
+ }
+
+ [Test(Description = "Нехватка прав приходит тем же типом, что и от MariaDBProvider - иначе её не разберёт NotEnoughRights")]
+ public void CreateUser_PermissionDenied_ThrowsUnauthorizedAccess() {
+ UserClient.CreateUser(Arg.Any(), Arg.Any())
+ .Throws(Refusal(StatusCode.PermissionDenied, "Недостаточно прав для добавления пользователя"));
+ var provider = LoginAs();
+
+ var exception = Assert.Throws(
+ () => provider.CreateUser(new DbUserInfo { Login = "newbie" }, "pass"));
+
+ Assert.That(exception.Message, Is.EqualTo("Недостаточно прав для добавления пользователя"));
+ }
+ [Test(Description = "Обрыв связи превращается в исключение с текстом сервера, а не в молчание")]
+ public void GetUsers_CloudUnavailable_ThrowsWithServerDetail() {
+ UserClient.GetUsers().Throws(Refusal(StatusCode.Unavailable, "связь потеряна"));
+ var provider = LoginAs();
+
+ var exception = Assert.Throws(() => provider.GetUsers());
+
+ Assert.That(exception.Message, Does.Contain("связь потеряна"));
+ }
+ }
+}
diff --git a/QS.LibsTest.Core/Launcher/ConfiguratorTest.cs b/QS.LibsTest.Core/Launcher/ConfiguratorTest.cs
index b68265cf1..71cf4fc34 100644
--- a/QS.LibsTest.Core/Launcher/ConfiguratorTest.cs
+++ b/QS.LibsTest.Core/Launcher/ConfiguratorTest.cs
@@ -6,6 +6,7 @@
using NSubstitute;
using NUnit.Framework;
using QS.DbManagement;
+using QS.DbManagement.Entities;
using QS.Dialog;
using QS.Launcher;
@@ -96,21 +97,13 @@ public void SaveConnections_RemovesTempFile_AfterSuccess() {
var connections = new List();
configurator.SaveConnections(connections);
-
- Assert.That(File.Exists(testConfigFile + ".tmp"), Is.False, "Временный файл должен быть удален");
- }
-
- [Test(Description = "Проверка что файл блокировки удаляется после записи")]
- public void SaveConnections_RemovesLockFile_AfterCompletion() {
- var configurator = new Configurator(options, interactive, connectionTypes);
- var connections = new List();
-
- configurator.SaveConnections(connections);
-
- Assert.That(File.Exists(testConfigFile + ".lock"), Is.False, "Файл блокировки должен быть удален");
+
+ // имя временного файла уникально на запись, поэтому ищем любые остатки
+ Assert.That(Directory.GetFiles(Path.GetDirectoryName(testConfigFile), "*.tmp"), Is.Empty,
+ "временных файлов после успешной записи остаться не должно");
}
- [Test(Description = "Проверка восстановления из backup при пустом основном файле")]
+[Test(Description = "Проверка восстановления из backup при пустом основном файле")]
public void ReadConnections_RestoresFromBackup_WhenMainFileIsEmpty() {
// Создаем backup с пустым массивом
CreateTestConfig(@"[]");
@@ -146,14 +139,13 @@ public void SaveConnections_DoesNotCorruptMainFile_OnInterruption() {
Assert.That(fileInfo.Length, Is.GreaterThan(0), "Файл конфигурации не должен быть пустым");
}
- [Test(Description = "Проверка защиты от конкурентного доступа")]
- public void SaveConnections_PreventsRaceCondition_WithFileLock() {
+ [Test(Description = "Конкурентная запись не роняет вызывающего и не портит файл")]
+ public void SaveConnections_ConcurrentWrites_LeaveFileValid() {
var configurator1 = new Configurator(options, interactive, connectionTypes);
var configurator2 = new Configurator(options, interactive, connectionTypes);
-
+
var connections = new List();
-
- // Первая запись создает блокировку
+
var task1 = Task.Run(() => {
for(int i = 0; i < 5; i++) {
configurator1.SaveConnections(connections);
@@ -162,7 +154,7 @@ public void SaveConnections_PreventsRaceCondition_WithFileLock() {
});
var task2 = Task.Run(() => {
- Thread.Sleep(5); // Небольшая задержка чтобы первый поток успел создать блокировку
+ Thread.Sleep(5); // чтобы записи наверняка пересеклись
for(int i = 0; i < 5; i++) {
configurator2.SaveConnections(connections);
Thread.Sleep(10);
@@ -426,7 +418,7 @@ public TestConnectionType(string name) {
public override bool CanConnect(IEnumerable parameters) => true;
- public override IDbProvider CreateProvider(IList parameters, string password = null)
+ public override IDbProvider CreateProvider(IList parameters, byte productCode, string password = null)
=> Substitute.For();
}
}
diff --git a/QS.LibsTest.Core/Launcher/LauncherDbTestFixtureBase.cs b/QS.LibsTest.Core/Launcher/LauncherDbTestFixtureBase.cs
new file mode 100644
index 000000000..528265a03
--- /dev/null
+++ b/QS.LibsTest.Core/Launcher/LauncherDbTestFixtureBase.cs
@@ -0,0 +1,516 @@
+using Dapper;
+using MySqlConnector;
+using NUnit.Framework;
+using QS.DbManagement;
+using QS.DbManagement.Entities;
+using QS.Testing.DB;
+using Testcontainers.MariaDb;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace QS.Launcher.Test {
+ public abstract class LauncherDbTestFixtureBase : MariaDbTestContainerSqlFixtureBase {
+ protected const string LauncherDbName = "QSLauncher";
+ protected const string RootLogin = "root";
+ protected const string RootPassword = "root";
+
+ protected const byte TestProductCode = 42;
+
+ /// его базы наш лаунчер видеть не должен
+ protected const byte OtherProductCode = 41;
+
+ /// Базы, которые ResetServer никогда не трогает
+ private static readonly string[] KeptDatabases =
+ { "information_schema", "mysql", "performance_schema", "sys", LauncherDbName };
+
+ /// Учётки сервера, которые ResetServer никогда не трогает
+ private static readonly string[] KeptLogins = { "root", "mariadb.sys", "mysql", "PUBLIC", "" };
+
+ private readonly List createdProviders = new List();
+ private Stopwatch testTimer;
+
+ [OneTimeSetUp]
+ public override async Task OneTimeSetUp() {
+ ConfigureLogging();
+ MariaDbContainer = await TestMariaDbServer.GetAsync();
+ await DeployMetabase();
+ }
+
+ [OneTimeTearDown]
+ public override Task OneTimeTearDown() => Task.CompletedTask;
+
+ private static void ConfigureLogging() {
+ if(NLog.LogManager.Configuration != null)
+ return;
+
+ var configuration = new NLog.Config.LoggingConfiguration();
+ var target = new TestProgressTarget {
+ Layout = " ${level:uppercase=true:padding=-5} ${logger:shortName=true} | ${message}${onexception: -> ${exception:format=Message}}"
+ };
+ configuration.AddRule(NLog.LogLevel.Debug, NLog.LogLevel.Fatal, target, "QS.*");
+ configuration.AddRule(NLog.LogLevel.Info, NLog.LogLevel.Fatal, target, "*");
+ NLog.LogManager.Configuration = configuration;
+ }
+
+ private sealed class TestProgressTarget : NLog.Targets.TargetWithLayout {
+ protected override void Write(NLog.LogEventInfo logEvent) {
+ Log(RenderLogEvent(Layout, logEvent));
+ }
+ }
+
+ private static void Log(string line) {
+ TestContext.Out.WriteLine(line);
+ }
+
+ protected static void LogStep(string format, params object[] args) {
+ Log(" · " + (args.Length == 0 ? format : string.Format(format, args)));
+ }
+
+ [SetUp]
+ public virtual async Task ResetServer() {
+ testTimer = Stopwatch.StartNew();
+ var test = TestContext.CurrentContext.Test;
+ Log($"┌─ {test.ClassName?.Split('.').Last()}.{test.Name}");
+ if(test.Properties.Get("Description") is string description)
+ Log($"│ {description}");
+
+ await DropApplicationDatabases();
+ await DropTestLogins();
+ await TruncateMetabase();
+ await SeedMetabaseUser(RootLogin, isAdmin: true);
+ LogStep("сервер приведён в исходное: в метабазе только {0}", RootLogin);
+ }
+
+ [TearDown]
+ public virtual async Task FinishTest() {
+ foreach(var provider in createdProviders)
+ provider.Dispose();
+ createdProviders.Clear();
+
+ await MySqlConnection.ClearAllPoolsAsync();
+
+ var result = TestContext.CurrentContext.Result;
+ if(result.Outcome.Status == NUnit.Framework.Interfaces.TestStatus.Failed)
+ await DumpServerState();
+
+ TestContext.Progress.WriteLine(
+ $"└─ {result.Outcome.Status}, {testTimer?.ElapsedMilliseconds ?? 0} мс\n");
+ }
+
+ ///
+ /// Снимок сервера на момент падения
+ ///
+ private async Task DumpServerState() {
+ Log("│ СОСТОЯНИЕ СЕРВЕРА НА МОМЕНТ ПАДЕНИЯ:");
+ try {
+ Log($"│ базы на сервере: {Describe(await ListApplicationDatabases())}");
+
+ if(await DatabaseExists(LauncherDbName)) {
+ foreach(var row in await ReadMetabaseBases())
+ Log($"│ метабаза/bases: id={row.Id} {row.BaseName} «{row.Title}» v{row.Version} disabled={row.Disabled}");
+ foreach(var row in await ReadMetabaseUsers())
+ Log($"│ метабаза/server_users: id={row.Id} {row.Login} «{row.Name}» admin={row.IsAdmin} disabled={row.Disabled}");
+ foreach(var row in await ReadBaseUpdateRights())
+ Log($"│ метабаза/base_update_rights: {row.Login} -> {row.BaseName} canUpdate={row.CanUpdate}");
+ }
+ else
+ Log("│ метабазы нет");
+
+ using(var connection = CreateConnection(withoutDb: true)) {
+ await connection.OpenAsync();
+ var logins = (await connection.QueryAsync(
+ "SELECT DISTINCT User FROM mysql.user WHERE User NOT IN @kept AND User NOT LIKE 'mysql.%' ORDER BY User;",
+ new { kept = KeptLogins })).ToList();
+ Log($"│ учётки сервера: {Describe(logins)}");
+ }
+ }
+ catch(Exception ex) {
+ Log($"│ снять состояние не удалось: {ex.Message}");
+ }
+ }
+
+ private static string Describe(ICollection items) =>
+ items.Count == 0 ? "нет" : string.Join(", ", items);
+
+ protected static async Task ReadScript(string fileName) {
+ string resource = ScriptResourcePrefix + fileName;
+ using(var stream = typeof(LauncherDbTestFixtureBase).Assembly.GetManifestResourceStream(resource)) {
+ if(stream == null)
+ throw new InvalidOperationException($"В сборке нет встроенного ресурса {resource}");
+
+ using(var reader = new StreamReader(stream))
+ return await reader.ReadToEndAsync();
+ }
+ }
+
+ private const string ScriptResourcePrefix = "QS.Launcher.Test.Base.";
+
+ #region Метабаза
+
+ protected async Task DeployMetabase() {
+ var script = await ReadScript("QSLauncher.sql");
+ await PrepareDatabase(script, dbName: LauncherDbName);
+ }
+
+ /// Сносит метабазу целиком - для проверки работы без неё.
+ protected async Task DropMetabase() {
+ using(var connection = CreateConnection(withoutDb: true)) {
+ await connection.OpenAsync();
+ await connection.ExecuteAsync($"DROP DATABASE IF EXISTS `{LauncherDbName}`;");
+ }
+ LogStep("метабаза {0} снесена", LauncherDbName);
+ }
+
+ private async Task TruncateMetabase() {
+ if(!await DatabaseExists(LauncherDbName)) {
+ await DeployMetabase();
+ return;
+ }
+
+ using(var connection = CreateConnection(LauncherDbName)) {
+ await connection.OpenAsync();
+ await connection.ExecuteAsync(
+ "DELETE FROM `base_update_rights`; DELETE FROM `bases`; DELETE FROM `server_users`;");
+ }
+ }
+
+ protected async Task SeedMetabaseUser(string login, bool isAdmin = false,
+ string name = null, string email = null, bool disabled = false, string phone = null) {
+ using(var connection = CreateConnection(LauncherDbName)) {
+ await connection.OpenAsync();
+ await connection.ExecuteAsync(
+ "INSERT INTO `server_users` (login, name, email, phone, is_admin, disabled) " +
+ "VALUES (@login, @name, @email, @phone, @admin, @disabled);",
+ new { login, name, email, phone, admin = isAdmin, disabled });
+ int id = await connection.ExecuteScalarAsync("SELECT LAST_INSERT_ID();");
+ LogStep("в метабазу добавлен пользователь {0} (id {1}, управляет пользователями: {2})", login, id, isAdmin);
+ return id;
+ }
+ }
+
+ protected async Task SeedMetabase(string baseName, string title = null,
+ byte product = TestProductCode, string version = "1.0", bool disabled = false) {
+ using(var connection = CreateConnection(LauncherDbName)) {
+ await connection.OpenAsync();
+ await connection.ExecuteAsync(
+ "INSERT INTO `bases` (product_id, base_name, base_title, version, disabled) " +
+ "VALUES (@product, @name, @title, @version, @disabled);",
+ new { product, name = baseName, title = title ?? baseName, version, disabled });
+ return await connection.ExecuteScalarAsync("SELECT LAST_INSERT_ID();");
+ }
+ }
+
+ protected async Task GrantBaseUpdateRight(int userId, int baseId, bool canUpdate = true) {
+ using(var connection = CreateConnection(LauncherDbName)) {
+ await connection.OpenAsync();
+ await connection.ExecuteAsync(
+ "INSERT INTO `base_update_rights` (user_id, base_id, can_update) VALUES (@user, @base, @canUpdate) " +
+ "ON DUPLICATE KEY UPDATE can_update = VALUES(can_update);",
+ new { user = userId, @base = baseId, canUpdate });
+ }
+ LogStep("в метабазе отмечено право на обновление: пользователь {0} -> база {1} ({2})",
+ userId, baseId, canUpdate);
+ }
+
+ protected async Task> ReadMetabaseBases() {
+ using(var connection = CreateConnection(LauncherDbName)) {
+ await connection.OpenAsync();
+ return (await connection.QueryAsync(
+ "SELECT id AS Id, base_name AS BaseName, base_title AS Title, " +
+ "version AS Version, disabled AS Disabled, product_id AS ProductId " +
+ "FROM `bases` ORDER BY base_name;")).ToList();
+ }
+ }
+
+ protected async Task ReadMetabaseBase(string baseName) =>
+ (await ReadMetabaseBases()).FirstOrDefault(b =>
+ string.Equals(b.BaseName, baseName, StringComparison.OrdinalIgnoreCase));
+
+ protected async Task> ReadMetabaseUsers() {
+ using(var connection = CreateConnection(LauncherDbName)) {
+ await connection.OpenAsync();
+ return (await connection.QueryAsync(
+ "SELECT id AS Id, login AS Login, name AS Name, email AS Email, phone AS Phone, " +
+ "is_admin AS IsAdmin, disabled AS Disabled " +
+ "FROM `server_users` ORDER BY login;")).ToList();
+ }
+ }
+
+ protected async Task ReadMetabaseUser(string login) =>
+ (await ReadMetabaseUsers()).FirstOrDefault(u =>
+ string.Equals(u.Login, login, StringComparison.OrdinalIgnoreCase));
+
+ protected async Task> ReadBaseUpdateRights() {
+ using(var connection = CreateConnection(LauncherDbName)) {
+ await connection.OpenAsync();
+ return (await connection.QueryAsync(
+ "SELECT r.user_id AS UserId, r.base_id AS BaseId, r.can_update AS CanUpdate, " +
+ "u.login AS Login, b.base_name AS BaseName " +
+ "FROM `base_update_rights` r " +
+ "LEFT JOIN `server_users` u ON u.id = r.user_id " +
+ "LEFT JOIN `bases` b ON b.id = r.base_id;")).ToList();
+ }
+ }
+
+ #endregion
+
+ #region Прикладные базы
+
+ protected async Task CreateApplicationDatabase(string dbName, string title = null,
+ byte product = TestProductCode, string version = "1.0",
+ bool withUsersTable = true, bool withDeactivatedColumn = true, bool withParameters = true) {
+ var script = await ReadScript("AppBase.sql");
+ await PrepareDatabase(script, dbName: dbName);
+
+ using(var connection = CreateConnection(dbName)) {
+ await connection.OpenAsync();
+
+ if(withParameters)
+ await WriteBaseParameters(connection, dbName, title, product, version);
+
+ await AdjustUsersTable(connection, withUsersTable, withDeactivatedColumn);
+ }
+
+ LogStep("создана база {0} «{1}» (продукт {2}, версия {3}{4})",
+ dbName, title ?? dbName, product, version,
+ DescribeOmissions(withParameters, withUsersTable, withDeactivatedColumn));
+ }
+
+ private static Task WriteBaseParameters(MySqlConnection connection, string dbName,
+ string title, byte product, string version) =>
+ connection.ExecuteAsync(
+ "INSERT INTO `base_parameters` (name, str_value) VALUES " +
+ "('ProductCode', @product), ('BaseTitle', @title), ('version', @version), ('BaseGuid', @guid);",
+ new {
+ product = product.ToString(), title = title ?? dbName, version,
+ guid = Guid.NewGuid().ToString()
+ });
+
+ private static async Task AdjustUsersTable(MySqlConnection connection, bool withUsersTable, bool withDeactivatedColumn) {
+ if(!withUsersTable)
+ await connection.ExecuteAsync("DROP TABLE `users`;");
+ else if(!withDeactivatedColumn)
+ await connection.ExecuteAsync("ALTER TABLE `users` DROP COLUMN `deactivated`;");
+ }
+
+ private static string DescribeOmissions(bool withParameters, bool withUsersTable, bool withDeactivatedColumn) {
+ var omissions = new List();
+ if(!withParameters)
+ omissions.Add("без base_parameters");
+ if(!withUsersTable)
+ omissions.Add("без таблицы users");
+ else if(!withDeactivatedColumn)
+ omissions.Add("без колонки deactivated");
+
+ return omissions.Count == 0 ? string.Empty : ", " + string.Join(", ", omissions);
+ }
+
+ protected async Task CreateForeignDatabase(string dbName) {
+ using(var connection = CreateConnection(withoutDb: true)) {
+ await connection.OpenAsync();
+ await connection.ExecuteAsync($"DROP DATABASE IF EXISTS `{dbName}`; CREATE DATABASE `{dbName}`;");
+ }
+ using(var connection = CreateConnection(dbName)) {
+ await connection.OpenAsync();
+ await connection.ExecuteAsync("CREATE TABLE `something` (`id` INT PRIMARY KEY);");
+ }
+ LogStep("создана чужая база {0} - без base_parameters, продукту не принадлежит", dbName);
+ }
+
+ protected async Task> ReadBaseUsers(string dbName) {
+ using(var connection = CreateConnection(dbName)) {
+ await connection.OpenAsync();
+ return (await connection.QueryAsync