diff --git a/src/Apps/NetPad.Apps.App/App/src/core/@application/api.ts b/src/Apps/NetPad.Apps.App/App/src/core/@application/api.ts index 416f7305..78db524d 100644 --- a/src/Apps/NetPad.Apps.App/App/src/core/@application/api.ts +++ b/src/Apps/NetPad.Apps.App/App/src/core/@application/api.ts @@ -3530,6 +3530,11 @@ export abstract class DataConnection implements IDataConnection { result.init(data); return result; } + if (data["discriminator"] === "MySqlDatabaseConnection") { + let result = new MySqlDatabaseConnection(); + result.init(data); + return result; + } throw new Error("The abstract class 'DataConnection' cannot be instantiated."); } @@ -3553,7 +3558,7 @@ export interface IDataConnection { type: DataConnectionType; } -export type DataConnectionType = "MSSQLServer" | "PostgreSQL" | "SQLite"; +export type DataConnectionType = "MSSQLServer" | "PostgreSQL" | "SQLite" | "MySQL"; export class DataConnectionTestResult implements IDataConnectionTestResult { success!: boolean; @@ -5376,6 +5381,7 @@ export class Types implements ITypes { msSqlServerDatabaseConnection?: MsSqlServerDatabaseConnection | undefined; postgreSqlDatabaseConnection?: PostgreSqlDatabaseConnection | undefined; sqLiteDatabaseConnection?: SQLiteDatabaseConnection | undefined; + mySqlDatabaseConnection?: MySqlDatabaseConnection | undefined; constructor(data?: ITypes) { if (data) { @@ -5424,6 +5430,7 @@ export class Types implements ITypes { this.msSqlServerDatabaseConnection = _data["msSqlServerDatabaseConnection"] ? MsSqlServerDatabaseConnection.fromJS(_data["msSqlServerDatabaseConnection"]) : undefined; this.postgreSqlDatabaseConnection = _data["postgreSqlDatabaseConnection"] ? PostgreSqlDatabaseConnection.fromJS(_data["postgreSqlDatabaseConnection"]) : undefined; this.sqLiteDatabaseConnection = _data["sqLiteDatabaseConnection"] ? SQLiteDatabaseConnection.fromJS(_data["sqLiteDatabaseConnection"]) : undefined; + this.mySqlDatabaseConnection = _data["mySqlDatabaseConnection"] ? MySqlDatabaseConnection.fromJS(_data["mySqlDatabaseConnection"]) : undefined; } } @@ -5472,6 +5479,7 @@ export class Types implements ITypes { data["msSqlServerDatabaseConnection"] = this.msSqlServerDatabaseConnection ? this.msSqlServerDatabaseConnection.toJSON() : undefined; data["postgreSqlDatabaseConnection"] = this.postgreSqlDatabaseConnection ? this.postgreSqlDatabaseConnection.toJSON() : undefined; data["sqLiteDatabaseConnection"] = this.sqLiteDatabaseConnection ? this.sqLiteDatabaseConnection.toJSON() : undefined; + data["mySqlDatabaseConnection"] = this.mySqlDatabaseConnection ? this.mySqlDatabaseConnection.toJSON() : undefined; return data; } @@ -5520,6 +5528,7 @@ export interface ITypes { msSqlServerDatabaseConnection?: MsSqlServerDatabaseConnection | undefined; postgreSqlDatabaseConnection?: PostgreSqlDatabaseConnection | undefined; sqLiteDatabaseConnection?: SQLiteDatabaseConnection | undefined; + mySqlDatabaseConnection?: MySqlDatabaseConnection | undefined; } export type YesNoCancel = "Yes" | "No" | "Cancel"; @@ -7380,6 +7389,11 @@ export abstract class DatabaseConnection extends DataConnection implements IData result.init(data); return result; } + if (data["discriminator"] === "MySqlDatabaseConnection") { + let result = new MySqlDatabaseConnection(); + result.init(data); + return result; + } throw new Error("The abstract class 'DatabaseConnection' cannot be instantiated."); } @@ -7448,6 +7462,11 @@ export abstract class EntityFrameworkDatabaseConnection extends DatabaseConnecti result.init(data); return result; } + if (data["discriminator"] === "MySqlDatabaseConnection") { + let result = new MySqlDatabaseConnection(); + result.init(data); + return result; + } throw new Error("The abstract class 'EntityFrameworkDatabaseConnection' cannot be instantiated."); } @@ -7497,6 +7516,11 @@ export abstract class EntityFrameworkRelationalDatabaseConnection extends Entity result.init(data); return result; } + if (data["discriminator"] === "MySqlDatabaseConnection") { + let result = new MySqlDatabaseConnection(); + result.init(data); + return result; + } throw new Error("The abstract class 'EntityFrameworkRelationalDatabaseConnection' cannot be instantiated."); } @@ -7663,6 +7687,41 @@ export class PostgreSqlDatabaseConnection extends EntityFrameworkRelationalDatab export interface IPostgreSqlDatabaseConnection extends IEntityFrameworkRelationalDatabaseConnection { } +export class MySqlDatabaseConnection extends EntityFrameworkRelationalDatabaseConnection implements IMySqlDatabaseConnection { + + constructor(data?: IMySqlDatabaseConnection) { + super(data); + this._discriminator = "MySqlDatabaseConnection"; + } + + init(_data?: any) { + super.init(_data); + } + + static fromJS(data: any): MySqlDatabaseConnection { + data = typeof data === 'object' ? data : {}; + let result = new MySqlDatabaseConnection(); + result.init(data); + return result; + } + + toJSON(data?: any) { + data = typeof data === 'object' ? data : {}; + super.toJSON(data); + return data; + } + + clone(): MySqlDatabaseConnection { + const json = this.toJSON(); + let result = new MySqlDatabaseConnection(); + result.init(json); + return result; + } +} + +export interface IMySqlDatabaseConnection extends IEntityFrameworkRelationalDatabaseConnection { +} + export class SQLiteDatabaseConnection extends EntityFrameworkRelationalDatabaseConnection implements ISQLiteDatabaseConnection { constructor(data?: ISQLiteDatabaseConnection) { diff --git a/src/Apps/NetPad.Apps.App/App/src/core/@application/data-connections/data-connection-name/data-connection-name.ts b/src/Apps/NetPad.Apps.App/App/src/core/@application/data-connections/data-connection-name/data-connection-name.ts index a37a2470..01576400 100644 --- a/src/Apps/NetPad.Apps.App/App/src/core/@application/data-connections/data-connection-name/data-connection-name.ts +++ b/src/Apps/NetPad.Apps.App/App/src/core/@application/data-connections/data-connection-name/data-connection-name.ts @@ -12,6 +12,8 @@ export class DataConnectionName { return "/img/postgresql2.png"; case "SQLite": return "/img/sqlite.png"; + case "MySQL": + return "/img/mysql.png"; default: return ""; } diff --git a/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/connection-views/data-connection-view.ts b/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/connection-views/data-connection-view.ts index 34edb85d..95f10a89 100644 --- a/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/connection-views/data-connection-view.ts +++ b/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/connection-views/data-connection-view.ts @@ -4,7 +4,8 @@ import { DataConnection, MsSqlServerDatabaseConnection, PostgreSqlDatabaseConnection, - SQLiteDatabaseConnection + SQLiteDatabaseConnection, + MySqlDatabaseConnection } from "@application"; import {IDataConnectionView} from "./idata-connection-view"; import {IDataConnectionViewComponent} from "./components/idata-connection-view-component"; @@ -57,6 +58,8 @@ export abstract class DataConnectionView connection.type = "PostgreSQL"; } else if (ctor.name === SQLiteDatabaseConnection.name) { connection.type = "SQLite"; + } else if (ctor.name === MySqlDatabaseConnection.name) { + connection.type = "MySQL"; } else { throw new Error("Unhandled data connection type: " + ctor.name); } diff --git a/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/connection-views/mysql/mysql-view.html b/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/connection-views/mysql/mysql-view.html new file mode 100644 index 00000000..f7b48a44 --- /dev/null +++ b/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/connection-views/mysql/mysql-view.html @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/connection-views/mysql/mysql-view.ts b/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/connection-views/mysql/mysql-view.ts new file mode 100644 index 00000000..904c0e10 --- /dev/null +++ b/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/connection-views/mysql/mysql-view.ts @@ -0,0 +1,25 @@ +import {DataConnection, IDataConnectionService, MySqlDatabaseConnection,} from "@application"; +import {HostAndPortComponent} from "../components/host-and-port-component"; +import {AuthComponent} from "../components/auth-component"; +import {DatabaseComponent} from "../components/database-component"; +import {DataConnectionView} from "../data-connection-view"; + +export class MysqlView extends DataConnectionView { + constructor(connection: DataConnection | undefined, dataConnectionService: IDataConnectionService) { + super(MySqlDatabaseConnection, connection); + + this.components = [ + new HostAndPortComponent(this.connection), + new AuthComponent(this.connection, dataConnectionService), + new DatabaseComponent( + this.connection, + undefined, + { + enabled: true, + requirementsToLoadAreMet: () => this.components.slice(0, 2).every(c => !c.validationError), + dataConnectionService: dataConnectionService + } + ) + ] + } +} diff --git a/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/window.ts b/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/window.ts index b4f2f516..c8425746 100644 --- a/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/window.ts +++ b/src/Apps/NetPad.Apps.App/App/src/windows/data-connection/window.ts @@ -6,6 +6,7 @@ import {IDataConnectionView} from "./connection-views/idata-connection-view"; import {MssqlView} from "./connection-views/mssql/mssql-view"; import {PostgresqlView} from "./connection-views/postgresql/postgresql-view"; import {SqliteView} from "./connection-views/sqlite/sqlite-view"; +import {MysqlView} from "./connection-views/mysql/mysql-view"; export class Window extends WindowBase { public connectionView?: IDataConnectionView; @@ -36,6 +37,10 @@ export class Window extends WindowBase { label: ' PostgreSQL', type: "PostgreSQL" }, + { + label: ' MySQL', + type: "MySQL" + } ]; // Until we implement a way to add a SQLite file in the browser, this option will only be available in Electron app @@ -152,6 +157,10 @@ export class Window extends WindowBase { return new SqliteView(connection, this.dataConnectionService); } + if (connectionType === "MySQL") { + return new MysqlView(connection, this.dataConnectionService); + } + return undefined; } diff --git a/src/Apps/NetPad.Apps.App/Controllers/TypesController.cs b/src/Apps/NetPad.Apps.App/Controllers/TypesController.cs index 16ea8712..e6fab4a1 100644 --- a/src/Apps/NetPad.Apps.App/Controllers/TypesController.cs +++ b/src/Apps/NetPad.Apps.App/Controllers/TypesController.cs @@ -64,5 +64,6 @@ private class Types public MsSqlServerDatabaseConnection? MsSqlServerDatabaseConnection { get; set; } public PostgreSqlDatabaseConnection? PostgreSqlDatabaseConnection { get; set; } public SQLiteDatabaseConnection? SQLiteDatabaseConnection { get; set; } + public MySqlDatabaseConnection? MySqlDatabaseConnection { get; set; } } } diff --git a/src/Apps/NetPad.Apps.App/wwwroot/img/mysql.png b/src/Apps/NetPad.Apps.App/wwwroot/img/mysql.png new file mode 100644 index 00000000..23d83057 Binary files /dev/null and b/src/Apps/NetPad.Apps.App/wwwroot/img/mysql.png differ diff --git a/src/Apps/NetPad.Apps.Common/Data/EntityFrameworkCore/DataConnections/MySqlDatabaseConnection.cs b/src/Apps/NetPad.Apps.Common/Data/EntityFrameworkCore/DataConnections/MySqlDatabaseConnection.cs new file mode 100644 index 00000000..b462779a --- /dev/null +++ b/src/Apps/NetPad.Apps.Common/Data/EntityFrameworkCore/DataConnections/MySqlDatabaseConnection.cs @@ -0,0 +1,77 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore; +using NetPad.Apps.Data.EntityFrameworkCore.Scaffolding; +using NetPad.Data; + +namespace NetPad.Apps.Data.EntityFrameworkCore.DataConnections; + +public sealed class MySqlDatabaseConnection(Guid id, string name, ScaffoldOptions? scaffoldOptions = null) + : EntityFrameworkRelationalDatabaseConnection(id, name, DataConnectionType.MySQL, + "Pomelo.EntityFrameworkCore.MySql", scaffoldOptions) +{ + public override string GetConnectionString(IDataConnectionPasswordProtector passwordProtector) + { + ConnectionStringBuilder connectionStringBuilder = []; + + string host = Host ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(Port)) + { + host += $";Port={Port}"; + } + + connectionStringBuilder.TryAdd("Server", host); + connectionStringBuilder.TryAdd("Database", DatabaseName); + + if (UserId != null) + { + connectionStringBuilder.TryAdd("Uid", UserId); + } + + if (Password != null) + { + connectionStringBuilder.TryAdd("Pwd", passwordProtector.Unprotect(Password)); + } + + if (!string.IsNullOrWhiteSpace(ConnectionStringAugment)) + { + connectionStringBuilder.Augment(new ConnectionStringBuilder(ConnectionStringAugment)); + } + + return connectionStringBuilder.Build(); + } + + public override Task ConfigureDbContextOptionsAsync(DbContextOptionsBuilder builder, IDataConnectionPasswordProtector passwordProtector) + { + var connectionString = GetConnectionString(passwordProtector); + + var serverVersion = MySqlServerVersion.AutoDetect(connectionString); + + builder.UseMySql(connectionString, serverVersion, options => + { + options.EnableRetryOnFailure(); + }); + + return Task.CompletedTask; + } + + public override async Task> GetDatabasesAsync(IDataConnectionPasswordProtector passwordProtector) + { + await using DatabaseContext context = CreateDbContext(passwordProtector); + await using DbCommand command = context.Database.GetDbConnection().CreateCommand(); + + command.CommandText = "select schema_name from information_schema.schemata;"; + await context.Database.OpenConnectionAsync(); + + await using DbDataReader result = await command.ExecuteReaderAsync(); + + List databases = []; + + while (await result.ReadAsync()) + { + databases.Add((string)result["schema_name"]); + } + + return databases; + } +} \ No newline at end of file diff --git a/src/Apps/NetPad.Apps.Common/Data/EntityFrameworkCore/DataConnections/MySqlDatabaseSchemaChangeDetectionStrategy.cs b/src/Apps/NetPad.Apps.Common/Data/EntityFrameworkCore/DataConnections/MySqlDatabaseSchemaChangeDetectionStrategy.cs new file mode 100644 index 00000000..e3de3b82 --- /dev/null +++ b/src/Apps/NetPad.Apps.Common/Data/EntityFrameworkCore/DataConnections/MySqlDatabaseSchemaChangeDetectionStrategy.cs @@ -0,0 +1,103 @@ +using System.Security.Cryptography; +using System.Text; +using NetPad.Application; +using NetPad.Data; + +namespace NetPad.Apps.Data.EntityFrameworkCore.DataConnections; + +internal class MySqlDatabaseSchemaChangeDetectionStrategy( + IDataConnectionResourcesRepository dataConnectionResourcesRepository, + IDataConnectionPasswordProtector passwordProtector) + : EntityFrameworkSchemaChangeDetectionStrategyBase(dataConnectionResourcesRepository, passwordProtector), + IDataConnectionSchemaChangeDetectionStrategy +{ + public bool CanSupport(DataConnection dataConnection) + { + return dataConnection is MySqlDatabaseConnection; + } + + public async Task DidSchemaChangeAsync(DataConnection dataConnection) + { + if (dataConnection is not MySqlDatabaseConnection connection) + { + return null; + } + + var schemaCompareInfo = await _dataConnectionResourcesRepository.GetSchemaCompareInfoAsync(connection.Id); + + if (schemaCompareInfo == null) + { + return null; + } + + if (schemaCompareInfo.GeneratedUsingStaleAppVersion()) + { + return true; + } + + var hash = await GetSchemaHashAsync(connection); + + if (hash == null) + { + return null; + } + + return hash != schemaCompareInfo.SchemaHash; + } + + public async Task GenerateSchemaCompareInfoAsync(DataConnection dataConnection) + { + if (dataConnection is not MySqlDatabaseConnection connection) + { + return null; + } + + var hash = await GetSchemaHashAsync(connection); + + return hash == null ? null : new MySqlSchemaCompareInfo(hash) + { + GeneratedOnAppVersion = AppIdentifier.PRODUCT_VERSION + }; + } + + private async Task GetSchemaHashAsync(MySqlDatabaseConnection connection) + { + string[] interestingColumns = [ "table_schema", "table_name", "column_name", "is_nullable", "data_type" ]; + + var sql = $""" + SELECT {string.Join(",", interestingColumns)} + FROM information_schema.columns + WHERE table_schema NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys') + ORDER BY table_schema, table_name, column_name; + """; + + StringBuilder sb = new(); + + await ExecuteSqlCommandAsync(connection, sql, async result => + { + while (await result.ReadAsync()) + { + foreach (var column in interestingColumns) + { + var value = result[column] as string; + sb.Append(value); + } + } + }); + + if (sb.Length == 0) + { + return null; + } + + using var md5 = MD5.Create(); + var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); + + return Convert.ToBase64String(hash); + } + + private class MySqlSchemaCompareInfo(string schemaHash) : SchemaCompareInfo(DateTime.UtcNow) + { + public string? SchemaHash { get; } = schemaHash; + } +} diff --git a/src/Apps/NetPad.Apps.Common/Data/EntityFrameworkCore/DependencyInjection.cs b/src/Apps/NetPad.Apps.Common/Data/EntityFrameworkCore/DependencyInjection.cs index b8b1f3b7..edea8884 100644 --- a/src/Apps/NetPad.Apps.Common/Data/EntityFrameworkCore/DependencyInjection.cs +++ b/src/Apps/NetPad.Apps.Common/Data/EntityFrameworkCore/DependencyInjection.cs @@ -18,6 +18,7 @@ public static DataConnectionFeatureBuilder AddEntityFrameworkCoreDataConnectionD services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); return builder; } diff --git a/src/Apps/NetPad.Apps.Common/NetPad.Apps.Common.csproj b/src/Apps/NetPad.Apps.Common/NetPad.Apps.Common.csproj index dbf8495b..62ab79b4 100644 --- a/src/Apps/NetPad.Apps.Common/NetPad.Apps.Common.csproj +++ b/src/Apps/NetPad.Apps.Common/NetPad.Apps.Common.csproj @@ -10,10 +10,11 @@ - - + + - + + diff --git a/src/Core/NetPad.Runtime/Common/GlobalConsts.cs b/src/Core/NetPad.Runtime/Common/GlobalConsts.cs index 4647921f..e381425f 100644 --- a/src/Core/NetPad.Runtime/Common/GlobalConsts.cs +++ b/src/Core/NetPad.Runtime/Common/GlobalConsts.cs @@ -67,6 +67,19 @@ public static string EntityFrameworkLibVersion(DotNetFrameworkVersion dotNetFram }; } + if (providerName == "Pomelo.EntityFrameworkCore.MySql") + { + return dotNetFrameworkVersion switch + { + DotNetFrameworkVersion.DotNet6 => "6.0.3", + DotNetFrameworkVersion.DotNet7 => "7.0.0", + DotNetFrameworkVersion.DotNet8 => "8.0.2", + DotNetFrameworkVersion.DotNet9 => "9.0.0-preview.1", + _ => throw new ArgumentOutOfRangeException(nameof(dotNetFrameworkVersion), dotNetFrameworkVersion, + $"Unsupported framework version: {dotNetFrameworkVersion}") + }; + } + throw new InvalidOperationException($"Could not determine version for provider: '{providerName}' and .NET version: '{dotNetFrameworkVersion}'"); } } diff --git a/src/Core/NetPad.Runtime/Data/DataConnectionType.cs b/src/Core/NetPad.Runtime/Data/DataConnectionType.cs index 35c03f6e..84ebf87a 100644 --- a/src/Core/NetPad.Runtime/Data/DataConnectionType.cs +++ b/src/Core/NetPad.Runtime/Data/DataConnectionType.cs @@ -5,4 +5,5 @@ public enum DataConnectionType MSSQLServer, PostgreSQL, SQLite, + MySQL } diff --git a/src/Tests/NetPad.Apps.Common.Tests/Data/EntityFrameworkCore/DataConnections/MySqlDatabaseConnectionTests.cs b/src/Tests/NetPad.Apps.Common.Tests/Data/EntityFrameworkCore/DataConnections/MySqlDatabaseConnectionTests.cs new file mode 100644 index 00000000..51f17f8c --- /dev/null +++ b/src/Tests/NetPad.Apps.Common.Tests/Data/EntityFrameworkCore/DataConnections/MySqlDatabaseConnectionTests.cs @@ -0,0 +1,51 @@ +using NetPad.Apps.Data.EntityFrameworkCore.DataConnections; +using NetPad.Data; + +namespace NetPad.Apps.Common.Tests.Data.EntityFrameworkCore.DataConnections; + +public class MySqlDatabaseConnectionTests() : CommonTests(DataConnectionType.MySQL, "Pomelo.EntityFrameworkCore.MySql") +{ + [Theory] + [MemberData(nameof(ConnectionStringTestData))] + public void GetConnectionString_ShouldReturnCorrectlyFormattedConnectionString( + string? server, + string? port, + string? databaseName, + string? userId, + string? password, + string? connectionStringAugment, + string expected + ) + { + var connection = CreateConnection(); + + connection.Host = server; + connection.Port = port; + connection.DatabaseName = databaseName; + connection.UserId = userId; + connection.Password = password; + connection.ConnectionStringAugment = connectionStringAugment; + + var connectionString = connection.GetConnectionString(new NullDataConnectionPasswordProtector()); + + Assert.Equal(expected, connectionString); + } + + public static IEnumerable ConnectionStringTestData => + [ + ["localhost", "port", "db name", "user id", "password", null, "Server=localhost;Port=port;Database=db name;Uid=user id;Pwd=password;"], + [null, "port", "db name", "user id", "password", null, "Server=;Port=port;Database=db name;Uid=user id;Pwd=password;"], + ["localhost", null, "db name", "user id", "password", null, "Server=localhost;Database=db name;Uid=user id;Pwd=password;"], + ["localhost", "port", null, "user id", "password", null, "Server=localhost;Port=port;Database=;Uid=user id;Pwd=password;"], + ["localhost", "port", "db name", null, "password", null, "Server=localhost;Port=port;Database=db name;Pwd=password;"], + ["localhost", "port", "db name", "user id", null, null, "Server=localhost;Port=port;Database=db name;Uid=user id;"], + ["localhost", "port", "db name", null, null, null, "Server=localhost;Port=port;Database=db name;"], + ["localhost", "port", "db name", null, null, "Server=new host:new port", "Server=new host:new port;Database=db name;"], + new[] { "localhost", "port", "db name", null, null, "Server=new host;Command Timeout=300", "Server=new host;Database=db name;Command Timeout=300;" }, + ]; + + protected override EntityFrameworkDatabaseConnection CreateConnection() + { + return new MySqlDatabaseConnection(Guid.NewGuid(), "name"); + } +} \ No newline at end of file