// ------------------------------------------------------------------------------------------------- // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. // ------------------------------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Data; using System.Linq; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.SqlServer.TransactSql.ScriptDom; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; namespace Microsoft.Health.Extensions.BuildTimeCodeGenerator.Sql; /// /// Visits a SQL AST, creating a class for each CREATE PROCEDURE statement. Classes have a PopulateCommand method, with a signature derived from the procedure's signature. /// internal class CreateProcedureVisitor : SqlVisitor { private const string TvpGeneratorGenericTypeName = "TInput"; private const string PopulateCommandMethodName = "PopulateCommand"; private const string CommandParameterName = "command"; public override int ArtifactSortOder => 1; public override void Visit(CreateProcedureStatement node) { var rawProcedureName = node.ProcedureReference.Name.BaseIdentifier.Value; string procedureName = GetMemberNameWithoutVersionSuffix(node.ProcedureReference.Name); string schemaQualifiedProcedureName = $"{node.ProcedureReference.Name.SchemaIdentifier.Value}.{rawProcedureName}"; string className = $"{procedureName}Procedure"; ClassDeclarationSyntax classDeclarationSyntax = ClassDeclaration(className) .WithModifiers(TokenList(Token(SyntaxKind.InternalKeyword))) // derive from StoredProcedure .WithBaseList( BaseList( SingletonSeparatedList( SimpleBaseType( IdentifierName("StoredProcedure"))))) // call base("dbo.StoredProcedure") .AddMembers( ConstructorDeclaration( Identifier(className)) .WithModifiers( TokenList( Token(SyntaxKind.InternalKeyword))) .WithInitializer( ConstructorInitializer( SyntaxKind.BaseConstructorInitializer, ArgumentList( SingletonSeparatedList( Argument( LiteralExpression( SyntaxKind.StringLiteralExpression, Literal(schemaQualifiedProcedureName))))))) .WithBody(Block())) // add fields for each parameter .AddMembers(node.Parameters.Select(CreateFieldForParameter).ToArray()) // add the PopulateCommand method .AddMembers(AddPopulateCommandMethod(node, schemaQualifiedProcedureName), AddPopulateCommandMethodForTableValuedParameters(node, procedureName)) // add the get output value method .AddMembers(AddGetOutputMethod(node)); FieldDeclarationSyntax fieldDeclarationSyntax = CreateStaticFieldForClass(className, procedureName); var (tvpGeneratorClass, tvpHolderStruct) = CreateTvpGeneratorTypes(node, procedureName); MembersToAdd.Add(classDeclarationSyntax.AddSortingKey(this, procedureName)); MembersToAdd.Add(fieldDeclarationSyntax.AddSortingKey(this, procedureName)); MembersToAdd.Add(tvpGeneratorClass.AddSortingKey(this, procedureName)); MembersToAdd.Add(tvpHolderStruct.AddSortingKey(this, procedureName)); base.Visit(node); } /// /// Creates a Column-derived field for a stored procedure parameter. /// /// The stored procedure parameter /// The field declaration private MemberDeclarationSyntax CreateFieldForParameter(ProcedureParameter parameter) { TypeSyntax typeName; List arguments = new List { Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(parameter.VariableName.Value))), }; if (TryGetSqlDbTypeForParameter(parameter, out SqlDbType sqlDbType)) { // new ParameterDefinition("@paramName", SqlDbType.Int, nullable, maxlength,...) string parameterDefinitionType = parameter.Modifier != ParameterModifier.Output ? "ParameterDefinition" : "OutputParameterDefinition"; typeName = GenericName(parameterDefinitionType) .AddTypeArgumentListArguments(SqlDbTypeToClrType(sqlDbType, nullable: parameter.Value != null).ToTypeSyntax(true)); arguments.Add( Argument( MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, typeof(SqlDbType).ToTypeSyntax(true), IdentifierName(sqlDbType.ToString())))); arguments.Add( Argument( LiteralExpression(parameter.Value == null ? SyntaxKind.FalseLiteralExpression : SyntaxKind.TrueLiteralExpression))); arguments.AddRange(GetDataTypeSpecificConstructorArguments(parameter.DataType, null)); } else { // new MyTableValuedParameterDefinition("@paramName"); typeName = IdentifierName(GetClassNameForTableValuedParameterDefinition(parameter.DataType.Name)); } return FieldDeclaration( VariableDeclaration(typeName) // call it "_myParam" when the parameter is named "@myParam" .AddVariables(VariableDeclarator(FieldNameForParameter(parameter)) .WithInitializer( EqualsValueClause( ObjectCreationExpression(typeName) .AddArgumentListArguments(arguments.ToArray()))))) .AddModifiers(Token(SyntaxKind.PrivateKeyword), Token(SyntaxKind.ReadOnlyKeyword)); } /// /// Creates a PopulateCommand method taking a SqlCommand and parameters for each sproc parameter. /// /// The CREATE STORED PROCEDURE statement /// The full name of the stored procedure /// The method declaration private static MethodDeclarationSyntax AddPopulateCommandMethod(CreateProcedureStatement node, string schemaQualifiedProcedureName) { return MethodDeclaration( typeof(void).ToTypeSyntax(), Identifier(PopulateCommandMethodName)) .AddModifiers(Token(SyntaxKind.PublicKeyword)) // first parameter is the SqlCommand .AddParameterListParameters(Parameter(Identifier(CommandParameterName)).WithType(ParseTypeName("SqlCommandWrapper"))) // Add a parameter for each stored procedure parameter .AddParameterListParameters(node.Parameters.Select(selector: p => Parameter(Identifier(ParameterNameForParameter(p))) .WithType(DataTypeReferenceToClrType(p.DataType, p.Value != null))).ToArray()) // start the body with: // command.CommandType = CommandType.StoredProcedure // command.CommandText = "dbo.MySproc" .AddBodyStatements( ExpressionStatement( AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, IdentifierName(CommandParameterName), IdentifierName("CommandType")), MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, typeof(CommandType).ToTypeSyntax(useGlobalAlias: true), IdentifierName("StoredProcedure")))), ExpressionStatement( AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, IdentifierName(CommandParameterName), IdentifierName("CommandText")), LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(schemaQualifiedProcedureName))))) // now for each parameter generate: // _fieldForParameter.AddParameter(command, parameterValue) .AddBodyStatements(node.Parameters.Select(p => (StatementSyntax)ExpressionStatement( InvocationExpression( MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, IdentifierName(FieldNameForParameter(p)), IdentifierName("AddParameter"))) .AddArgumentListArguments( Argument(MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, IdentifierName(CommandParameterName), IdentifierName("Parameters"))), Argument(IdentifierName(ParameterNameForParameter(p)))))).ToArray()); } private static MemberDeclarationSyntax AddPopulateCommandMethodForTableValuedParameters(CreateProcedureStatement node, string procedureName) { var nonTableParameters = new List(); var tableParameters = new List(); foreach (var procedureParameter in node.Parameters) { if (TryGetSqlDbTypeForParameter(procedureParameter, out _)) { nonTableParameters.Add(procedureParameter); } else { tableParameters.Add(procedureParameter); } } if (tableParameters.Count == 0) { return IncompleteMember(); } string tableValuedParametersParameterName = "tableValuedParameters"; return MethodDeclaration( typeof(void).ToTypeSyntax(), Identifier(PopulateCommandMethodName)) .AddModifiers(Token(SyntaxKind.PublicKeyword)) // first parameter is the SqlCommand .AddParameterListParameters(Parameter(Identifier(CommandParameterName)).WithType(ParseTypeName("SqlCommandWrapper"))) // Add a parameter for each non-TVP .AddParameterListParameters(nonTableParameters.Select(selector: p => Parameter(Identifier(ParameterNameForParameter(p))) .WithType(DataTypeReferenceToClrType(p.DataType, p.Value != null))).ToArray()) // Add a parameter for the TVP set .AddParameterListParameters( Parameter(Identifier(tableValuedParametersParameterName)).WithType(IdentifierName(TableValuedParametersStructName(procedureName)))) // Call the overload .AddBodyStatements( ExpressionStatement( InvocationExpression( IdentifierName(PopulateCommandMethodName)) .AddArgumentListArguments(Argument(IdentifierName(CommandParameterName))) .AddArgumentListArguments( nonTableParameters.Select(p => Argument(IdentifierName(ParameterNameForParameter(p))) .WithNameColon(NameColon(ParameterNameForParameter(p)))).ToArray()) .AddArgumentListArguments( tableParameters.Select(p => Argument(MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, IdentifierName(tableValuedParametersParameterName), IdentifierName(PropertyNameForParameter(p)))) .WithNameColon(NameColon(ParameterNameForParameter(p)))).ToArray()))); } private static MethodDeclarationSyntax[] AddGetOutputMethod(CreateProcedureStatement node) { List outputTypes = new List(); List outputExpressions = new List(); List procedureParameters = node.Parameters.Where(p => p.Modifier == ParameterModifier.Output).ToList(); if (procedureParameters.Count == 0) { return new MethodDeclarationSyntax[0]; } foreach (ProcedureParameter parameter in procedureParameters) { outputTypes.Add(DataTypeReferenceToClrType(parameter.DataType, parameter.Value != null)); outputExpressions.Add( InvocationExpression( MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, IdentifierName(FieldNameForParameter(parameter)), IdentifierName("GetOutputValue"))) .AddArgumentListArguments( Argument(IdentifierName(CommandParameterName)))); } // For single output, T GetOutputs(SqlCommandWrapper) if (procedureParameters.Count == 1) { return new MethodDeclarationSyntax[] { MethodDeclaration( outputTypes.First(), Identifier($"GetOutputs")) .AddModifiers(Token(SyntaxKind.PublicKeyword)) // Parameter is the SqlCommand .AddParameterListParameters(Parameter(Identifier(CommandParameterName)).WithType(ParseTypeName("SqlCommandWrapper"))) .AddBodyStatements(ReturnStatement(outputExpressions.First())) }; } // For multi outputs, (T1, T2, ..) GetOutputs(SqlCommandWrapper) else { return new MethodDeclarationSyntax[] { MethodDeclaration( TypeExtensions.CreateGenericTypeFromGenericTypeDefinition(typeof(ValueTuple<>).ToTypeSyntax(), outputTypes.ToArray()), Identifier($"GetOutputs")) .AddModifiers(Token(SyntaxKind.PublicKeyword)) // Parameter is the SqlCommand .AddParameterListParameters(Parameter(Identifier(CommandParameterName)).WithType(ParseTypeName("SqlCommandWrapper"))) .AddBodyStatements( ReturnStatement( InvocationExpression( MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, IdentifierName("global::System.ValueTuple"), IdentifierName("Create"))) .AddArgumentListArguments( outputExpressions.Select(o => Argument(o)).ToArray()))) }; } } private static (MemberDeclarationSyntax tvpGeneratorClass, MemberDeclarationSyntax tvpHolderStruct) CreateTvpGeneratorTypes(CreateProcedureStatement node, string procedureName) { List<(string parameterName, string rowStructName)> rowTypes = node.Parameters .Where(p => !TryGetSqlDbTypeForParameter(p, out _)) .Select(p => (parameterName: PropertyNameForParameter(p), rowStructName: GetRowStructNameForTableType(p.DataType.Name))) .ToList(); if (rowTypes.Count == 0) { // no table-valued parameters on this procedure return (IncompleteMember(), IncompleteMember()); } var holderStructName = TableValuedParametersStructName(procedureName); // create a struct with properties for each table-valued parameter var structDeclaration = StructDeclaration(holderStructName) .AddModifiers(Token(SyntaxKind.InternalKeyword)) // Add a constructor with parameters for each column, setting the associated property for each column. .AddMembers( ConstructorDeclaration( Identifier(holderStructName)) .WithModifiers( TokenList( Token(SyntaxKind.InternalKeyword))) .AddParameterListParameters( rowTypes.Select(p => Parameter(Identifier(p.parameterName)) .WithType(TypeExtensions.CreateGenericTypeFromGenericTypeDefinition( typeof(IEnumerable<>).ToTypeSyntax(true), IdentifierName(p.rowStructName)))).ToArray()) .WithBody( Block(rowTypes.Select(p => ExpressionStatement( AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, left: MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, ThisExpression(), IdentifierName(p.parameterName)), right: IdentifierName(p.parameterName))))))) // Add a property for each column .AddMembers(rowTypes.Select(p => (MemberDeclarationSyntax)PropertyDeclaration( TypeExtensions.CreateGenericTypeFromGenericTypeDefinition( typeof(IEnumerable<>).ToTypeSyntax(true), IdentifierName(p.rowStructName)), Identifier(p.parameterName)) .AddModifiers(Token(SyntaxKind.InternalKeyword)) .AddAccessorListAccessors(AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)))).ToArray()); string className = $"{procedureName}TvpGenerator"; List distinctTvpTypeNames = rowTypes.Select(r => r.rowStructName).Distinct().ToList(); var classDeclaration = ClassDeclaration(className) .AddTypeParameterListParameters(TypeParameter(TvpGeneratorGenericTypeName)) .AddBaseListTypes( SimpleBaseType( GenericName("IStoredProcedureTableValuedParametersGenerator") .AddTypeArgumentListArguments( IdentifierName(TvpGeneratorGenericTypeName), IdentifierName(holderStructName)))) .AddModifiers(Token(SyntaxKind.InternalKeyword)) .AddMembers( ConstructorDeclaration(Identifier(className)) .WithModifiers(TokenList(Token(SyntaxKind.PublicKeyword))) .AddParameterListParameters(distinctTvpTypeNames .Select(t => Parameter(Identifier(GeneratorFieldName(t))) .WithType(GeneratorType(t))).ToArray()) .WithBody( Block(distinctTvpTypeNames .Select(t => ExpressionStatement( AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, left: MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, ThisExpression(), IdentifierName(GeneratorFieldName(t))), right: IdentifierName(GeneratorFieldName(t)))))))) .AddMembers( distinctTvpTypeNames .Select(t => (MemberDeclarationSyntax)FieldDeclaration( VariableDeclaration(GeneratorType(t)) .AddVariables(VariableDeclarator(GeneratorFieldName(t)))) .AddModifiers(Token(SyntaxKind.PrivateKeyword), Token(SyntaxKind.ReadOnlyKeyword))).ToArray()) .AddMembers( MethodDeclaration(IdentifierName(holderStructName), Identifier("Generate")) .AddModifiers(Token(SyntaxKind.PublicKeyword)) .AddParameterListParameters(Parameter(Identifier("input")).WithType(IdentifierName(TvpGeneratorGenericTypeName))) .AddBodyStatements( ReturnStatement( ObjectCreationExpression(IdentifierName(holderStructName)) .AddArgumentListArguments( rowTypes.Select(p => Argument( InvocationExpression( MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, IdentifierName(GeneratorFieldName(p.rowStructName)), IdentifierName("GenerateRows"))) .AddArgumentListArguments(Argument(IdentifierName("input"))))).ToArray())))); return (classDeclaration, structDeclaration); } private static string TableValuedParametersStructName(string procedureName) { return $"{procedureName}TableValuedParameters"; } private static string GeneratorFieldName(string tableTypeName) { return $"{tableTypeName}Generator"; } private static TypeSyntax GeneratorType(string rowStructName) { return GenericName("ITableValuedParameterRowGenerator") .AddTypeArgumentListArguments( IdentifierName(TvpGeneratorGenericTypeName), IdentifierName(rowStructName)); } private static bool TryGetSqlDbTypeForParameter(ProcedureParameter parameter, out SqlDbType sqlDbType) { return Enum.TryParse(parameter.DataType.Name.BaseIdentifier.Value, ignoreCase: true, out sqlDbType); } private static string ParameterNameForParameter(ProcedureParameter parameter) { return parameter.VariableName.Value.Substring(1); } private static string FieldNameForParameter(ProcedureParameter parameter) { return $"_{parameter.VariableName.Value.Substring(1)}"; } private static string PropertyNameForParameter(ProcedureParameter parameter) { string parameterName = parameter.VariableName.Value; return $"{char.ToUpperInvariant(parameterName[1])}{(parameterName.Length > 2 ? parameterName.Substring(2) : string.Empty)}"; } }