diff --git a/src/Microsoft.Data.Entity.Design.DatabaseGeneration/DatabaseScriptGenerator.cs b/src/Microsoft.Data.Entity.Design.DatabaseGeneration/DatabaseScriptGenerator.cs index 54b7afd9..84555db4 100644 --- a/src/Microsoft.Data.Entity.Design.DatabaseGeneration/DatabaseScriptGenerator.cs +++ b/src/Microsoft.Data.Entity.Design.DatabaseGeneration/DatabaseScriptGenerator.cs @@ -29,7 +29,7 @@ namespace Microsoft.Data.Entity.Design.DatabaseGeneration /// /// /// - /// var generator = new DatabaseScriptGenerator(new SsdlToDdlGenerator()); + /// var generator = new DatabaseScriptGenerator(); /// DatabaseScript script = generator.Generate(edmItemCollection, existingSsdl, parameters); /// Console.WriteLine(script.Ddl); /// @@ -46,6 +46,15 @@ public sealed class DatabaseScriptGenerator #region Constructors + /// + /// Creates a generator that uses the in-box , , and + /// generators. + /// + public DatabaseScriptGenerator() + : this(new SsdlToDdl()) + { + } + /// /// Creates a generator that uses the in-box and generators. /// diff --git a/src/Microsoft.Data.Entity.Design.DatabaseGeneration/OutputGenerators/IDdlGenerator.cs b/src/Microsoft.Data.Entity.Design.DatabaseGeneration/OutputGenerators/IDdlGenerator.cs index e654d9f1..8f3095ad 100644 --- a/src/Microsoft.Data.Entity.Design.DatabaseGeneration/OutputGenerators/IDdlGenerator.cs +++ b/src/Microsoft.Data.Entity.Design.DatabaseGeneration/OutputGenerators/IDdlGenerator.cs @@ -6,9 +6,9 @@ namespace Microsoft.Data.Entity.Design.DatabaseGeneration.OutputGenerators /// Produces the data definition language (DDL) that creates a database for a store model. /// /// - /// The in-box implementation runs a T4 template through Visual Studio's text templating service, which is why it - /// lives in Microsoft.Data.Entity.Design rather than here. Inverting the dependency this way keeps this assembly - /// free of any Visual Studio reference, so it can target .NET Standard alongside .NET Framework. + /// The in-box implementation is , which lives in this assembly so the Visual Studio wizard + /// and the command-line tool share one generator. A Visual Studio host may still supply a T4-backed implementation + /// when the user has chosen a custom .tt file. /// public interface IDdlGenerator { @@ -18,7 +18,7 @@ public interface IDdlGenerator /// /// The store model to create objects for. /// The store model currently recorded in the .edmx file, used to drop stale objects. May be empty. - /// The parameters the generator needs, such as the provider invariant name and DDL template path. + /// The parameters the generator needs, such as the provider invariant name and database name. /// The generated DDL script. string Generate(string ssdl, string existingSsdl, EdmParameterBag edmParameterBag); } diff --git a/src/Microsoft.Data.Entity.Design.DatabaseGeneration/OutputGenerators/SsdlToDdl.cs b/src/Microsoft.Data.Entity.Design.DatabaseGeneration/OutputGenerators/SsdlToDdl.cs new file mode 100644 index 00000000..d942b900 --- /dev/null +++ b/src/Microsoft.Data.Entity.Design.DatabaseGeneration/OutputGenerators/SsdlToDdl.cs @@ -0,0 +1,546 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.Data.Entity.Design.DatabaseGeneration.Properties; +using Microsoft.Data.Entity.Design.EntityFramework; +using System; +using System.Collections.Generic; +using System.Data.Entity.Core.Metadata.Edm; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace Microsoft.Data.Entity.Design.DatabaseGeneration.OutputGenerators +{ + /// + /// Renders Transact-SQL from a store model. This is the in-box replacement for the + /// SSDLToSQL10.tt text template the Generate Database Wizard used to run through Visual Studio's + /// T4 host. + /// + /// + /// + /// The script drops the objects described by the existing store model, then creates the tables, + /// primary keys, and foreign keys described by the new store model. SQL Server scripts qualify + /// objects with a schema and guard drops with OBJECT_ID checks; SQL Server Compact scripts + /// omit schemas, USE, and those guards. + /// + /// + /// This lives in Microsoft.Data.Entity.Design.DatabaseGeneration so the Visual Studio wizard + /// and the command-line tool can share one implementation. Custom .tt files remain an + /// optional override in the Visual Studio host. + /// + /// + /// + /// + /// var generator = new SsdlToDdl(); + /// string ddl = generator.Generate(ssdl, existingSsdl, parameters); + /// File.WriteAllText("Model.sql", ddl); + /// + /// + public sealed class SsdlToDdl : IDdlGenerator + { + #region Fields + + private const string SqlCeProviderPrefix = "System.Data.SqlServerCe"; + private readonly DateTime _generatedAt; + + #endregion + + #region Constructors + + /// + /// Creates a generator that stamps the script with the current local time. + /// + public SsdlToDdl() + : this(DateTime.Now) + { + } + + /// + /// Creates a generator that stamps the script with . + /// + /// The timestamp written into the script header. + public SsdlToDdl(DateTime generatedAt) + { + _generatedAt = generatedAt; + } + + #endregion + + #region Public Methods + + /// + /// Generates the DDL that drops the objects described by and creates those + /// described by . + /// + /// The store model to create objects for. + /// The store model currently recorded in the .edmx file. May be empty. + /// + /// Supplies the provider invariant name, database schema name, optional database name, optional .edmx path, + /// and target Entity Framework version. + /// + /// The generated T-SQL script. + /// Thrown when is . + /// + /// Thrown when a required parameter is missing, the target Entity Framework version is unrecognized, or + /// is not a valid store model. + /// + public string Generate(string ssdl, string existingSsdl, EdmParameterBag edmParameterBag) + { + if (edmParameterBag is null) + { + throw new ArgumentNullException(nameof(edmParameterBag)); + } + + var targetFrameworkVersion = edmParameterBag.GetParameter(EdmParameterBag.ParameterName.TargetVersion); + if (targetFrameworkVersion is null) + { + throw new InvalidOperationException( + String.Format( + CultureInfo.CurrentCulture, + DatabaseGenerationResources.ErrorNoParameterDefined, + EdmParameterBag.ParameterName.TargetVersion)); + } + + if (false == EntityFrameworkVersion.IsValidVersion(targetFrameworkVersion)) + { + throw new InvalidOperationException( + String.Format(CultureInfo.CurrentCulture, DatabaseGenerationResources.ErrorNonValidTargetVersion, targetFrameworkVersion)); + } + + var providerInvariantName = edmParameterBag.GetParameter(EdmParameterBag.ParameterName.ProviderInvariantName); + if (String.IsNullOrWhiteSpace(providerInvariantName)) + { + throw new InvalidOperationException( + String.Format( + CultureInfo.CurrentCulture, + DatabaseGenerationResources.ErrorNoParameterDefined, + EdmParameterBag.ParameterName.ProviderInvariantName)); + } + + var databaseSchemaName = edmParameterBag.GetParameter(EdmParameterBag.ParameterName.DatabaseSchemaName); + if (String.IsNullOrWhiteSpace(databaseSchemaName)) + { + throw new InvalidOperationException( + String.Format( + CultureInfo.CurrentCulture, + DatabaseGenerationResources.ErrorNoParameterDefined, + EdmParameterBag.ParameterName.DatabaseSchemaName)); + } + + var store = EdmExtension.CreateAndValidateStoreItemCollection( + ssdl, targetFrameworkVersion, DependencyResolver.Instance, false); + + var existingStore = TryCreateExistingStore(existingSsdl, targetFrameworkVersion, out var existingStoreIsValid); + + var isSqlCe = IsSqlCe(providerInvariantName); + var script = new StringBuilder(); + + if (false == existingStoreIsValid) + { + script.AppendLine("-- Warning: There were errors validating the existing SSDL. Drop statements"); + script.AppendLine("-- will not be generated."); + } + + AppendHeader(script, edmParameterBag, isSqlCe); + + if (false == isSqlCe) + { + script.AppendLine("SET QUOTED_IDENTIFIER OFF;"); + AppendGo(script); + + var databaseName = edmParameterBag.GetParameter(EdmParameterBag.ParameterName.DatabaseName); + if (false == String.IsNullOrEmpty(databaseName)) + { + script.AppendLine("USE [" + EscapeIdentifier(databaseName) + "];"); + AppendGo(script); + } + + AppendCreateSchemas(script, store); + } + + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine("-- Dropping existing FOREIGN KEY constraints"); + if (isSqlCe) + { + script.AppendLine("-- NOTE: if the constraint does not exist, an ignorable error will be reported."); + } + + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine(); + if (existingStoreIsValid && existingStore is not null) + { + AppendDropForeignKeys(script, existingStore, isSqlCe); + } + + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine("-- Dropping existing tables"); + if (isSqlCe) + { + script.AppendLine("-- NOTE: if the table does not exist, an ignorable error will be reported."); + } + + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine(); + if (existingStoreIsValid && existingStore is not null) + { + AppendDropTables(script, existingStore, isSqlCe); + } + + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine("-- Creating all tables"); + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine(); + AppendCreateTables(script, store, targetFrameworkVersion, isSqlCe); + + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine("-- Creating all PRIMARY KEY constraints"); + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine(); + AppendCreatePrimaryKeys(script, store, isSqlCe); + + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine("-- Creating all FOREIGN KEY constraints"); + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine(); + AppendCreateForeignKeys(script, store, isSqlCe); + + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine("-- Script has ended"); + script.AppendLine("-- --------------------------------------------------"); + + return script.ToString(); + } + + #endregion + + #region Private Methods + + /// + /// Writes CREATE INDEX and ALTER TABLE ... ADD CONSTRAINT statements for each association. + /// + private static void AppendCreateForeignKeys(StringBuilder script, StoreItemCollection store, bool isSqlCe) + { + foreach (var associationSet in store.GetAllAssociationSets()) + { + var constraint = associationSet.ElementType.ReferentialConstraints.Single(); + var dependentSetEnd = associationSet.AssociationSetEnds.Single( + ase => ase.CorrespondingAssociationEndMember == constraint.ToRole); + var principalSetEnd = associationSet.AssociationSetEnds.Single( + ase => ase.CorrespondingAssociationEndMember == constraint.FromRole); + var schemaName = EscapeIdentifier(dependentSetEnd.EntitySet.GetSchemaName()); + var dependentTableName = EscapeIdentifier(dependentSetEnd.EntitySet.GetTableName()); + var principalTableName = EscapeIdentifier(principalSetEnd.EntitySet.GetTableName()); + var constraintName = WriteFkConstraintName(constraint); + + script.AppendLine( + "-- Creating foreign key on " + WriteColumns(constraint.ToProperties, ',') + " in table '" + dependentTableName + "'"); + script.AppendLine("ALTER TABLE " + Qualify(schemaName, dependentTableName, isSqlCe)); + script.AppendLine("ADD CONSTRAINT [" + constraintName + "]"); + script.AppendLine(" FOREIGN KEY (" + WriteColumns(constraint.ToProperties, ',') + ")"); + script.AppendLine( + " REFERENCES " + Qualify(schemaName, principalTableName, isSqlCe)); + script.AppendLine(" (" + WriteColumns(constraint.FromProperties, ',') + ")"); + script.AppendLine(" ON DELETE " + GetDeleteAction(constraint) + " ON UPDATE NO ACTION;"); + AppendGo(script); + + var keyProperties = dependentSetEnd.EntitySet.ElementType.GetKeyProperties() + .Take(constraint.ToProperties.Count()) + .OrderBy(p => p.Name); + var fkProperties = constraint.ToProperties.OrderBy(p => p.Name); + if (false == keyProperties.SequenceEqual(fkProperties)) + { + script.AppendLine("-- Creating non-clustered index for FOREIGN KEY '" + constraintName + "'"); + script.AppendLine("CREATE INDEX [IX_" + constraintName + "]"); + script.AppendLine("ON " + Qualify(schemaName, dependentTableName, isSqlCe)); + script.AppendLine(" (" + WriteColumns(constraint.ToProperties, ',') + ");"); + AppendGo(script); + } + } + } + + /// + /// Writes a clustered (SQL Server) or unclustered (SQL Compact) primary key for each entity set. + /// + private static void AppendCreatePrimaryKeys(StringBuilder script, StoreItemCollection store, bool isSqlCe) + { + foreach (var entitySet in store.GetAllEntitySets()) + { + var schemaName = EscapeIdentifier(entitySet.GetSchemaName()); + var tableName = EscapeIdentifier(entitySet.GetTableName()); + var keyColumns = WriteColumns(entitySet.ElementType.GetKeyProperties(), ','); + + script.AppendLine("-- Creating primary key on " + keyColumns + " in table '" + tableName + "'"); + script.AppendLine("ALTER TABLE " + Qualify(schemaName, tableName, isSqlCe)); + script.AppendLine("ADD CONSTRAINT [PK_" + tableName + "]"); + if (isSqlCe) + { + script.AppendLine(" PRIMARY KEY (" + keyColumns + " );"); + } + else + { + script.AppendLine(" PRIMARY KEY CLUSTERED (" + keyColumns + " ASC);"); + } + + AppendGo(script); + } + } + + /// + /// Writes CREATE SCHEMA statements for every distinct schema in . + /// + private static void AppendCreateSchemas(StringBuilder script, StoreItemCollection store) + { + foreach (var unescapedSchemaName in store.GetAllEntitySets().Select(es => es.GetSchemaName()).Distinct()) + { + script.AppendLine( + "IF SCHEMA_ID(N'" + EscapeLiteral(unescapedSchemaName) + "') IS NULL EXECUTE(N'CREATE SCHEMA [" + + EscapeIdentifier(unescapedSchemaName) + "]');"); + } + + AppendGo(script); + } + + /// + /// Writes a CREATE TABLE statement for each entity set. + /// + private static void AppendCreateTables( + StringBuilder script, StoreItemCollection store, Version targetFrameworkVersion, bool isSqlCe) + { + foreach (var entitySet in store.GetAllEntitySets()) + { + var schemaName = EscapeIdentifier(entitySet.GetSchemaName()); + var tableName = EscapeIdentifier(entitySet.GetTableName()); + + script.AppendLine("-- Creating table '" + tableName + "'"); + script.AppendLine("CREATE TABLE " + Qualify(schemaName, tableName, isSqlCe) + " ("); + + var properties = entitySet.ElementType.Properties; + for (var p = 0; p < properties.Count; p++) + { + var prop = properties[p]; + var comma = p < properties.Count - 1 ? "," : String.Empty; + script.AppendLine( + " [" + EscapeIdentifier(prop.Name) + "] " + prop.ToStoreType() + " " + + WriteIdentity(prop, targetFrameworkVersion) + " " + WriteNullable(prop.Nullable) + comma); + } + + script.AppendLine(");"); + AppendGo(script); + } + } + + /// + /// Writes drop statements for every foreign key in . + /// + private static void AppendDropForeignKeys(StringBuilder script, StoreItemCollection existingStore, bool isSqlCe) + { + foreach (var associationSet in existingStore.GetAllAssociationSets()) + { + var constraint = associationSet.ElementType.ReferentialConstraints.Single(); + var constraintName = EscapeIdentifier(WriteFkConstraintName(constraint)); + var dependentSetEnd = associationSet.AssociationSetEnds.Single( + ase => ase.CorrespondingAssociationEndMember == constraint.ToRole); + var schemaName = EscapeIdentifier(dependentSetEnd.EntitySet.GetSchemaName()); + var dependentTableName = EscapeIdentifier(dependentSetEnd.EntitySet.GetTableName()); + + if (false == isSqlCe) + { + script.AppendLine( + "IF OBJECT_ID(N'[" + EscapeLiteral(schemaName) + "].[" + EscapeLiteral(constraintName) + "]', 'F') IS NOT NULL"); + } + + script.AppendLine( + " ALTER TABLE " + Qualify(schemaName, dependentTableName, isSqlCe) + " DROP CONSTRAINT [" + constraintName + "];"); + AppendGo(script); + } + } + + /// + /// Writes drop statements for every table in . + /// + private static void AppendDropTables(StringBuilder script, StoreItemCollection existingStore, bool isSqlCe) + { + foreach (var entitySet in existingStore.GetAllEntitySets()) + { + var schemaName = EscapeIdentifier(entitySet.GetSchemaName()); + var tableName = EscapeIdentifier(entitySet.GetTableName()); + + if (false == isSqlCe) + { + script.AppendLine( + "IF OBJECT_ID(N'[" + EscapeLiteral(schemaName) + "].[" + EscapeLiteral(tableName) + "]', 'U') IS NOT NULL"); + } + + script.AppendLine(" DROP TABLE " + Qualify(schemaName, tableName, isSqlCe) + ";"); + AppendGo(script); + } + } + + /// + /// Writes a GO batch separator followed by a blank line. + /// + private static void AppendGo(StringBuilder script) + { + script.AppendLine("GO"); + script.AppendLine(); + } + + /// + /// Writes the script banner, including the optional originating .edmx path. + /// + private void AppendHeader(StringBuilder script, EdmParameterBag edmParameterBag, bool isSqlCe) + { + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine( + isSqlCe + ? "-- Entity Designer DDL Script for SQL Server Compact Edition" + : "-- Entity Designer DDL Script for SQL Server 2005, 2008, 2012 and Azure"); + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine("-- Date Created: " + _generatedAt); + var edmxPath = edmParameterBag.GetParameter(EdmParameterBag.ParameterName.EdmxPath); + if (false == String.IsNullOrEmpty(edmxPath)) + { + script.AppendLine("-- Generated from EDMX file: " + EscapeIdentifier(edmxPath)); + } + + script.AppendLine("-- --------------------------------------------------"); + script.AppendLine(); + } + + /// + /// Doubles closing brackets so an identifier can sit inside [...]. + /// + private static string EscapeIdentifier(string userIdentifier) + { + return userIdentifier.Replace("]", "]]"); + } + + /// + /// Doubles single quotes so a value can sit inside an N'...' literal. + /// + private static string EscapeLiteral(string userLiteral) + { + return userLiteral.Replace("'", "''"); + } + + /// + /// Returns CASCADE when the principal end deletes dependents, otherwise NO ACTION. + /// + private static string GetDeleteAction(ReferentialConstraint refConstraint) + { + return refConstraint.FromRole.DeleteBehavior == OperationAction.Cascade + ? "CASCADE" + : "NO ACTION"; + } + + /// + /// Returns whether is a SQL integer or decimal type that can take IDENTITY. + /// + private static bool IsIntegerOrDecimalType(EdmProperty property) + { + HashSet sqlIntegerOrDecimalTypes = + [ + "int", + "bigint", + "smallint", + "double", + "decimal", + "float", + "real", + "tinyint" + ]; + + var edmType = property.TypeUsage?.EdmType; + return edmType is not null && sqlIntegerOrDecimalTypes.Contains(edmType.Name); + } + + /// + /// Returns whether is a SQL Server Compact provider. + /// + private static bool IsSqlCe(string providerInvariantName) + { + return providerInvariantName.StartsWith(SqlCeProviderPrefix, StringComparison.Ordinal); + } + + /// + /// Qualifies a table with its schema for SQL Server, or returns the table name alone for SQL Compact. + /// + private static string Qualify(string schemaName, string tableName, bool isSqlCe) + { + return isSqlCe + ? "[" + tableName + "]" + : "[" + schemaName + "].[" + tableName + "]"; + } + + /// + /// Attempts to load the existing store model. Empty input is treated as "no existing objects". + /// + private static StoreItemCollection TryCreateExistingStore( + string existingSsdl, Version targetFrameworkVersion, out bool existingStoreIsValid) + { + if (String.IsNullOrWhiteSpace(existingSsdl)) + { + existingStoreIsValid = true; + return null; + } + + var existingStore = EdmExtension.CreateStoreItemCollection( + existingSsdl, targetFrameworkVersion, DependencyResolver.Instance, out IList existingSsdlErrors); + existingStoreIsValid = existingSsdlErrors is null || existingSsdlErrors.Count == 0; + return existingStoreIsValid ? existingStore : null; + } + + /// + /// Writes bracketed, identifier-escaped column names separated by . + /// + private static string WriteColumns(IEnumerable properties, char delimiter) + { + var serializedProperties = new StringBuilder(); + foreach (var property in properties) + { + serializedProperties.Append("[" + EscapeIdentifier(property.Name) + "]"); + serializedProperties.Append(delimiter + " "); + } + + return serializedProperties.ToString().Trim().TrimEnd(delimiter); + } + + /// + /// Builds the foreign-key constraint name from the principal role, prefixing FK_ when needed. + /// + private static string WriteFkConstraintName(ReferentialConstraint constraint) + { + var name = constraint.FromRole.DeclaringType.Name; + if (false == name.StartsWith("FK_", StringComparison.InvariantCultureIgnoreCase)) + { + return "FK_" + name; + } + + return name; + } + + /// + /// Returns IDENTITY(1,1) for integer or decimal identity columns, otherwise an empty string. + /// + private static string WriteIdentity(EdmProperty property, Version targetVersion) + { + if (property.GetStoreGeneratedPatternValue(targetVersion, DataSpace.SSpace) == StoreGeneratedPattern.Identity + && IsIntegerOrDecimalType(property)) + { + return "IDENTITY(1,1)"; + } + + return String.Empty; + } + + /// + /// Returns NULL or NOT NULL for a column definition. + /// + private static string WriteNullable(bool isNull) + { + return isNull ? "NULL" : "NOT NULL"; + } + + #endregion + } +} diff --git a/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Commands/Root/DdlCommand.cs b/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Commands/Root/DdlCommand.cs new file mode 100644 index 00000000..ac7f0061 --- /dev/null +++ b/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Commands/Root/DdlCommand.cs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using McMaster.Extensions.CommandLineUtils; +using Microsoft.Data.Entity.Design.DatabaseGeneration; +using System; +using System.Data.Common; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Xml.Linq; + +namespace Microsoft.Data.Entity.Design.Diagrams.Tools.Commands.Root +{ + + /// + /// Generates a T-SQL database script from the conceptual model in an EDMX file. + /// + /// + /// Infers the store model and mappings from the conceptual model, then renders DDL with + /// . Runs without Visual Studio or a T4 template. + /// + /// + /// + /// edmx ddl Model.edmx + /// edmx ddl Model.edmx --output Model.sql --database Northwind --schema dbo + /// + /// + [Command(Name = "ddl", Description = "Generate a T-SQL database script from an EDMX conceptual model.")] + internal class DdlCommand + { + + #region Fields + + private static readonly XNamespace EdmxNamespace = "http://schemas.microsoft.com/ado/2009/11/edmx"; + + #endregion + + #region Properties + + /// + /// Gets or sets the name of the generated database. Written as a USE statement when supplied. + /// + [Option("--database", Description = "Database name written as a USE statement. Omitted from the script when empty.")] + public string Database { get; set; } + + /// + /// Gets or sets the path of the EDMX file to generate a script from. + /// + [Argument(0, Description = "Path to the .edmx file.")] + public string Input { get; set; } + + /// + /// Gets or sets the file to write. Defaults to the input file name with a .sql extension. + /// + [Option("-o|--output", Description = "File to write (defaults to the input name with a .sql extension).")] + public string Output { get; set; } + + /// + /// Gets or sets the ADO.NET provider invariant name. Defaults to the SSDL Provider attribute when present. + /// + [Option("--provider", Description = "ADO.NET provider invariant name. Defaults to the SSDL Provider attribute.")] + public string Provider { get; set; } + + /// + /// Gets or sets the provider manifest token. Defaults to the SSDL ProviderManifestToken attribute when present. + /// + [Option("--provider-manifest-token", Description = "Provider manifest token. Defaults to the SSDL ProviderManifestToken attribute.")] + public string ProviderManifestToken { get; set; } + + /// + /// Gets or sets the schema of the generated tables. Defaults to dbo. + /// + [Option("--schema", Description = "Database schema for generated tables. Defaults to dbo.")] + public string Schema { get; set; } + + #endregion + + #region Public Methods + + /// + /// Validates the arguments and writes the generated script. + /// + /// The command line application. + /// The console to write messages to. + /// Zero on success, otherwise a non-zero exit code. + public int OnExecute(CommandLineApplication app, IConsole console) + { + if (string.IsNullOrWhiteSpace(Input)) + { + console.Error.WriteLine("An .edmx file is required."); + app.ShowHelp(); + + return 1; + } + + var inputPath = Path.GetFullPath(Input); + if (!File.Exists(inputPath)) + { + console.Error.WriteLine($"File not found: {inputPath}"); + + return 1; + } + + try + { + EnsureProviderFactory( + "Microsoft.Data.SqlClient", + "Microsoft.Data.SqlClient.SqlClientFactory, Microsoft.Data.SqlClient"); + EnsureProviderFactory( + "System.Data.SqlClient", + "System.Data.SqlClient.SqlClientFactory, System.Data.SqlClient"); + + var document = XDocument.Load(inputPath); + var runtime = document.Root?.Element(EdmxNamespace + "Runtime"); + var conceptual = runtime?.Element(EdmxNamespace + "ConceptualModels")?.Elements().FirstOrDefault(); + if (conceptual is null) + { + console.Error.WriteLine("The EDMX file does not contain a conceptual model."); + + return 1; + } + + var storage = runtime.Element(EdmxNamespace + "StorageModels")?.Elements().FirstOrDefault(); + var provider = FirstNonEmpty( + Provider, + storage?.Attribute("Provider")?.Value); + var providerManifestToken = FirstNonEmpty( + ProviderManifestToken, + storage?.Attribute("ProviderManifestToken")?.Value); + if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(providerManifestToken)) + { + console.Error.WriteLine( + "A provider and provider manifest token are required. Supply --provider and --provider-manifest-token, or generate from an EDMX that already has a store model."); + + return 1; + } + + var schema = FirstNonEmpty(Schema, "dbo"); + var existingSsdl = storage is null ? string.Empty : storage.ToString(); + var edm = EdmExtension.CreateAndValidateEdmItemCollection( + conceptual.ToString(), new Version(3, 0, 0, 0)); + var parameters = new EdmParameterBag( + null, + null, + new Version(3, 0, 0, 0), + provider, + providerManifestToken, + null, + schema, + Database, + null, + inputPath); + + var script = new DatabaseScriptGenerator().Generate(edm, existingSsdl, parameters); + var outputPath = Path.GetFullPath( + string.IsNullOrWhiteSpace(Output) + ? Path.ChangeExtension(inputPath, ".sql") + : Output); + + File.WriteAllText(outputPath, script.Ddl); + console.Out.WriteLine($"Wrote database script to {outputPath}"); + + return 0; + } + catch (Exception ex) + { + console.Error.WriteLine(ex.Message); + + return 1; + } + } + + #endregion + + #region Private Methods + + /// + /// Registers an ADO.NET provider factory when the runtime has no machine.config entry for it. + /// + private static void EnsureProviderFactory(string invariantName, string factoryTypeName) + { + try + { + DbProviderFactories.GetFactory(invariantName); + } + catch (ArgumentException) + { + var factoryType = Type.GetType(factoryTypeName, throwOnError: false); + var instance = factoryType?.GetField("Instance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null) + as DbProviderFactory; + if (instance is not null) + { + DbProviderFactories.RegisterFactory(invariantName, instance); + } + } + } + + /// + /// Returns the first non-empty value. + /// + private static string FirstNonEmpty(params string[] values) + { + return values.FirstOrDefault(value => false == string.IsNullOrWhiteSpace(value)); + } + + #endregion + + } + +} diff --git a/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Commands/Root/EntityDesignerRootCommand.cs b/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Commands/Root/EntityDesignerRootCommand.cs index 77ee5940..d6d98f90 100644 --- a/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Commands/Root/EntityDesignerRootCommand.cs +++ b/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Commands/Root/EntityDesignerRootCommand.cs @@ -9,6 +9,7 @@ namespace Microsoft.Data.Entity.Design.Diagrams.Tools.Commands.Root /// Root command for the Entity Designer command line tool. /// [Command(Name = "edmx", Description = "Entity Designer commands for working with EDMX files outside Visual Studio.")] + [Subcommand(typeof(DdlCommand))] [Subcommand(typeof(RenderCommand))] internal partial class EntityDesignerRootCommand { diff --git a/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Microsoft.Data.Entity.Design.Diagrams.Tools.csproj b/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Microsoft.Data.Entity.Design.Diagrams.Tools.csproj index 8da3d295..a624b03a 100644 --- a/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Microsoft.Data.Entity.Design.Diagrams.Tools.csproj +++ b/src/Microsoft.Data.Entity.Design.Diagrams.Tools/Microsoft.Data.Entity.Design.Diagrams.Tools.csproj @@ -41,6 +41,7 @@ + diff --git a/src/Microsoft.Data.Entity.Tests.Design.DatabaseGeneration/SsdlToDdlTests.cs b/src/Microsoft.Data.Entity.Tests.Design.DatabaseGeneration/SsdlToDdlTests.cs new file mode 100644 index 00000000..1840da65 --- /dev/null +++ b/src/Microsoft.Data.Entity.Tests.Design.DatabaseGeneration/SsdlToDdlTests.cs @@ -0,0 +1,305 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using FluentAssertions; +using Microsoft.Data.Entity.Design.DatabaseGeneration; +using Microsoft.Data.Entity.Design.DatabaseGeneration.OutputGenerators; +using Microsoft.Data.Entity.Design.DatabaseGeneration.Properties; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Globalization; + +namespace Microsoft.Data.Entity.Tests.Design.DatabaseGeneration +{ + [TestClass] + public class SsdlToDdlTests + { + private const string AssociationSsdl = + "" + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + ""; + + private const string IdentifyingFkSsdl = + "" + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + ""; + + private const string SimpleSsdl = + "" + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + ""; + + private static readonly DateTime GeneratedAt = new DateTime(2026, 9, 14, 12, 0, 0); + + [TestMethod] + public void Generate_creates_sql_server_script_for_simple_ssdl() + { + var ddl = new SsdlToDdl(GeneratedAt).Generate(SimpleSsdl, string.Empty, CreateBag()); + + ddl.Should().Contain("-- Entity Designer DDL Script for SQL Server 2005, 2008, 2012 and Azure"); + ddl.Should().Contain("-- Date Created: " + GeneratedAt.ToString()); + ddl.Should().Contain("SET QUOTED_IDENTIFIER OFF;"); + ddl.Should().Contain("USE [Northwind];"); + ddl.Should().Contain("IF SCHEMA_ID(N'dbo') IS NULL EXECUTE(N'CREATE SCHEMA [dbo]');"); + ddl.Should().Contain("CREATE TABLE [dbo].[Entities] ("); + ddl.Should().Contain("[Id] int IDENTITY(1,1) NOT NULL"); + ddl.Should().Contain("ADD CONSTRAINT [PK_Entities]"); + ddl.Should().Contain("PRIMARY KEY CLUSTERED ([Id] ASC);"); + ddl.Should().Contain("-- Script has ended"); + ddl.Should().NotContain("-- Warning: There were errors validating the existing SSDL"); + } + + [TestMethod] + public void Generate_creates_sqlce_script_without_schema_or_use_database() + { + var bag = CreateBag(providerInvariantName: "System.Data.SqlServerCe.4.0", databaseName: "Northwind"); + + var ddl = new SsdlToDdl(GeneratedAt).Generate(SimpleSsdl, string.Empty, bag); + + ddl.Should().Contain("-- Entity Designer DDL Script for SQL Server Compact Edition"); + ddl.Should().NotContain("SET QUOTED_IDENTIFIER"); + ddl.Should().NotContain("USE ["); + ddl.Should().NotContain("CREATE SCHEMA"); + ddl.Should().Contain("CREATE TABLE [Entities] ("); + ddl.Should().Contain("PRIMARY KEY ([Id] );"); + ddl.Should().NotContain("CLUSTERED"); + } + + [TestMethod] + public void Generate_creates_foreign_key_and_index_when_fk_is_not_part_of_pk() + { + var ddl = new SsdlToDdl(GeneratedAt).Generate(AssociationSsdl, string.Empty, CreateBag()); + + ddl.Should().Contain("ADD CONSTRAINT [FK_Children_Parents]"); + ddl.Should().Contain("FOREIGN KEY ([ParentId])"); + ddl.Should().Contain("REFERENCES [dbo].[Parents]"); + ddl.Should().Contain("([Id])"); + ddl.Should().Contain("ON DELETE NO ACTION ON UPDATE NO ACTION;"); + ddl.Should().Contain("CREATE INDEX [IX_FK_Children_Parents]"); + ddl.Should().Contain("ON [dbo].[Children]"); + } + + [TestMethod] + public void Generate_does_not_create_index_when_fk_is_the_primary_key() + { + var ddl = new SsdlToDdl(GeneratedAt).Generate(IdentifyingFkSsdl, string.Empty, CreateBag()); + + ddl.Should().Contain("ADD CONSTRAINT [FK_Children_Parents]"); + ddl.Should().NotContain("CREATE INDEX [IX_FK_Children_Parents]"); + } + + [TestMethod] + public void Generate_drops_existing_tables_and_foreign_keys() + { + var ddl = new SsdlToDdl(GeneratedAt).Generate(AssociationSsdl, AssociationSsdl, CreateBag()); + + ddl.Should().Contain("IF OBJECT_ID(N'[dbo].[FK_Children_Parents]', 'F') IS NOT NULL"); + ddl.Should().Contain("ALTER TABLE [dbo].[Children] DROP CONSTRAINT [FK_Children_Parents];"); + ddl.Should().Contain("IF OBJECT_ID(N'[dbo].[Parents]', 'U') IS NOT NULL"); + ddl.Should().Contain("DROP TABLE [dbo].[Parents];"); + ddl.Should().Contain("IF OBJECT_ID(N'[dbo].[Children]', 'U') IS NOT NULL"); + ddl.Should().Contain("DROP TABLE [dbo].[Children];"); + } + + [TestMethod] + public void Generate_includes_edmx_path_in_header_when_supplied() + { + var bag = CreateBag(edmxPath: @"C:\Project\Model.edmx"); + + var ddl = new SsdlToDdl(GeneratedAt).Generate(SimpleSsdl, string.Empty, bag); + + ddl.Should().Contain("-- Generated from EDMX file: C:\\Project\\Model.edmx"); + } + + [TestMethod] + public void Generate_skips_drops_and_warns_when_existing_ssdl_is_invalid() + { + const string invalidExisting = ""; + + var ddl = new SsdlToDdl(GeneratedAt).Generate(SimpleSsdl, invalidExisting, CreateBag()); + + ddl.Should().Contain("-- Warning: There were errors validating the existing SSDL. Drop statements"); + ddl.Should().Contain("-- will not be generated."); + ddl.Should().NotContain("DROP TABLE"); + ddl.Should().Contain("CREATE TABLE [dbo].[Entities] ("); + } + + [TestMethod] + public void Generate_throws_when_parameter_bag_is_null() + { + Action act = () => new SsdlToDdl().Generate(SimpleSsdl, string.Empty, null); + + act.Should().Throw().Which.ParamName.Should().Be("edmParameterBag"); + } + + [TestMethod] + public void Generate_throws_when_provider_is_missing() + { + var bag = CreateBag(providerInvariantName: " "); + + Action act = () => new SsdlToDdl().Generate(SimpleSsdl, string.Empty, bag); + + act.Should().Throw().Which.Message.Should().Be( + string.Format( + CultureInfo.CurrentCulture, + DatabaseGenerationResources.ErrorNoParameterDefined, + EdmParameterBag.ParameterName.ProviderInvariantName)); + } + + [TestMethod] + public void Generate_throws_when_schema_name_is_missing() + { + var bag = CreateBag(databaseSchemaName: string.Empty); + + Action act = () => new SsdlToDdl().Generate(SimpleSsdl, string.Empty, bag); + + act.Should().Throw().Which.Message.Should().Be( + string.Format( + CultureInfo.CurrentCulture, + DatabaseGenerationResources.ErrorNoParameterDefined, + EdmParameterBag.ParameterName.DatabaseSchemaName)); + } + + [TestMethod] + public void Generate_throws_when_target_version_is_invalid() + { + var bag = CreateBag(targetVersion: new Version(1, 0, 0, 0)); + + Action act = () => new SsdlToDdl().Generate(SimpleSsdl, string.Empty, bag); + + act.Should().Throw().Which.Message.Should().Be( + string.Format( + CultureInfo.CurrentCulture, + DatabaseGenerationResources.ErrorNonValidTargetVersion, + new Version(1, 0, 0, 0))); + } + + [TestMethod] + public void Generate_throws_when_target_version_is_missing() + { + var bag = new EdmParameterBag( + null, + null, + null, + "System.Data.SqlClient", + "2008", + null, + "dbo", + "Northwind", + null, + null); + + Action act = () => new SsdlToDdl().Generate(SimpleSsdl, string.Empty, bag); + + act.Should().Throw().Which.Message.Should().Be( + string.Format( + CultureInfo.CurrentCulture, + DatabaseGenerationResources.ErrorNoParameterDefined, + EdmParameterBag.ParameterName.TargetVersion)); + } + + [TestMethod] + public void DatabaseScriptGenerator_parameterless_constructor_uses_ssdl_to_ddl() + { + const string csdl = + "" + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + ""; + + var edm = EdmExtension.CreateAndValidateEdmItemCollection(csdl, new Version(3, 0, 0, 0)); + + var script = new DatabaseScriptGenerator().Generate(edm, string.Empty, CreateBag(providerManifestToken: "2008")); + + script.Ddl.Should().NotBeNullOrWhiteSpace(); + script.Ddl.Should().Contain("CREATE TABLE"); + script.Ssdl.Should().NotBeNullOrWhiteSpace(); + script.Msl.Should().NotBeNullOrWhiteSpace(); + } + + private static EdmParameterBag CreateBag( + string providerInvariantName = "System.Data.SqlClient", + string databaseSchemaName = "dbo", + string databaseName = "Northwind", + string edmxPath = null, + string providerManifestToken = "2008", + Version targetVersion = null) + { + return new EdmParameterBag( + null, + null, + targetVersion ?? new Version(3, 0, 0, 0), + providerInvariantName, + providerManifestToken, + null, + databaseSchemaName, + databaseName, + null, + edmxPath); + } + } +} diff --git a/src/Microsoft.VisualStudio.Data.Entity.EdmxDesigner/Ide/ModelWizard/Engine/DatabaseGenerationEngine.cs b/src/Microsoft.VisualStudio.Data.Entity.EdmxDesigner/Ide/ModelWizard/Engine/DatabaseGenerationEngine.cs index f6fd9041..bd414659 100644 --- a/src/Microsoft.VisualStudio.Data.Entity.EdmxDesigner/Ide/ModelWizard/Engine/DatabaseGenerationEngine.cs +++ b/src/Microsoft.VisualStudio.Data.Entity.EdmxDesigner/Ide/ModelWizard/Engine/DatabaseGenerationEngine.cs @@ -2,6 +2,7 @@ using EnvDTE; using Microsoft.Data.Entity.Design.DatabaseGeneration; +using Microsoft.Data.Entity.Design.DatabaseGeneration.OutputGenerators; using Microsoft.Data.Entity.Design.Edmx; using Microsoft.Data.Entity.Design.Edmx.Commands; using Microsoft.Data.Entity.Design.Edmx.Designer; @@ -496,7 +497,35 @@ internal static DatabaseScript GenerateDatabaseScript( syncContext, assemblyLoader, targetVersion, providerInvariantName, providerManifestToken, providerConnectionString, databaseSchemaName, databaseName, templatePath, artifactPath); - return new DatabaseScriptGenerator(new SsdlToDdlGenerator()).Generate(edmItemCollection, existingSsdl, parameters); + return new DatabaseScriptGenerator(CreateDdlGenerator(templatePath)).Generate(edmItemCollection, existingSsdl, parameters); + } + + /// + /// Uses the in-box C# generator for the shipped SSDL-to-SQL template, and the Visual Studio T4 host only when + /// the user has selected a custom .tt file. + /// + private static IDdlGenerator CreateDdlGenerator(string templatePath) + { + if (IsInboxDdlTemplate(templatePath)) + { + return new SsdlToDdl(); + } + + return new SsdlToDdlGenerator(); + } + + /// + /// Returns whether is the shipped SSDL-to-SQL template, which is now + /// implemented in C# and no longer requires a file on disk. + /// + private static bool IsInboxDdlTemplate(string templatePath) + { + if (String.IsNullOrWhiteSpace(templatePath)) + { + return true; + } + + return String.Equals(Path.GetFileName(templatePath), DefaultTemplateName, StringComparison.OrdinalIgnoreCase); } // diff --git a/src/Microsoft.VisualStudio.Data.Entity.EdmxDesigner/Ide/TextTemplating/SsdlToDdlGenerator.cs b/src/Microsoft.VisualStudio.Data.Entity.EdmxDesigner/Ide/TextTemplating/SsdlToDdlGenerator.cs index 8ebc5207..563d8bfd 100644 --- a/src/Microsoft.VisualStudio.Data.Entity.EdmxDesigner/Ide/TextTemplating/SsdlToDdlGenerator.cs +++ b/src/Microsoft.VisualStudio.Data.Entity.EdmxDesigner/Ide/TextTemplating/SsdlToDdlGenerator.cs @@ -10,13 +10,12 @@ namespace Microsoft.VisualStudio.Data.Entity.EdmxDesigner.Ide.TextTemplating { /// - /// Renders data definition language from a store model by running a T4 template through Visual Studio's text - /// templating service. + /// Renders data definition language from a store model by running a custom T4 template through Visual Studio's + /// text templating service. /// /// - /// This was SsdlToDdlActivity, a Windows Workflow activity. It lives in this assembly rather than - /// Microsoft.Data.Entity.Design.DatabaseGeneration because it depends on the Visual Studio T4 host; - /// is the seam that keeps that dependency from reaching the generation assembly. + /// The in-box SSDL-to-SQL path is , which does not need a template file. This class remains + /// for users who set the EDMX "DDL Generation Template" property to their own .tt file. /// public sealed class SsdlToDdlGenerator : IDdlGenerator {