diff --git a/src/Config/DatabasePrimitives/DatabaseObject.cs b/src/Config/DatabasePrimitives/DatabaseObject.cs index 6e0db9d8b8..b33b314fc8 100644 --- a/src/Config/DatabasePrimitives/DatabaseObject.cs +++ b/src/Config/DatabasePrimitives/DatabaseObject.cs @@ -64,6 +64,7 @@ public SourceDefinition SourceDefinition EntitySourceType.Table => ((DatabaseTable)this).TableDefinition, EntitySourceType.View => ((DatabaseView)this).ViewDefinition, EntitySourceType.StoredProcedure => ((DatabaseStoredProcedure)this).StoredProcedureDefinition, + EntitySourceType.Function => ((DatabaseStoredProcedure)this).StoredProcedureDefinition, _ => throw new Exception( message: $"Unsupported EntitySourceType. It can either be Table,View, or Stored Procedure.") }; diff --git a/src/Config/ObjectModel/EntitySourceType.cs b/src/Config/ObjectModel/EntitySourceType.cs index 863751da5c..2554068d2b 100644 --- a/src/Config/ObjectModel/EntitySourceType.cs +++ b/src/Config/ObjectModel/EntitySourceType.cs @@ -12,5 +12,6 @@ public enum EntitySourceType { Table, View, - [EnumMember(Value = "stored-procedure")] StoredProcedure + [EnumMember(Value = "stored-procedure")] StoredProcedure, + Function } diff --git a/src/Core/Resolvers/BaseQueryStructure.cs b/src/Core/Resolvers/BaseQueryStructure.cs index cfe4a050ec..798713e1d1 100644 --- a/src/Core/Resolvers/BaseQueryStructure.cs +++ b/src/Core/Resolvers/BaseQueryStructure.cs @@ -120,7 +120,7 @@ public virtual string MakeDbConnectionParam(object? value, string? paramName = n string encodedParamName = GetEncodedParamName(Counter.Next()); if (!string.IsNullOrEmpty(paramName)) { - Parameters.Add(encodedParamName, + Parameters.Add(paramName, new(value, dbType: GetUnderlyingSourceDefinition().GetDbTypeForParam(paramName), sqlDbType: GetUnderlyingSourceDefinition().GetSqlDbTypeForParam(paramName))); @@ -130,7 +130,9 @@ public virtual string MakeDbConnectionParam(object? value, string? paramName = n Parameters.Add(encodedParamName, new(value)); } - return encodedParamName; + return paramName == null ? encodedParamName : $"{PARAM_NAME_PREFIX}{paramName}"; + + } /// diff --git a/src/Core/Resolvers/PostgresQueryBuilder.cs b/src/Core/Resolvers/PostgresQueryBuilder.cs index c779252cfe..6734381b28 100644 --- a/src/Core/Resolvers/PostgresQueryBuilder.cs +++ b/src/Core/Resolvers/PostgresQueryBuilder.cs @@ -101,7 +101,59 @@ public string Build(SqlDeleteStructure structure) /// public string Build(SqlExecuteStructure structure) { - throw new NotImplementedException(); + return $"SELECT * FROM {QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)}({BuildProcedureParameterList(structure.ProcedureParameters)})"; + } + private string BuildProcedureParameterList(Dictionary parameters) + { + if (parameters == null || parameters.Count == 0) + { + return ""; + } + + List parameterList = new(); + foreach (KeyValuePair param in parameters) + { + parameterList.Add($"{QuoteIdentifier(param.Key)} := {FormatParameterValue(param.Value)}"); + } + + return string.Join(", ", parameterList); + } + +#pragma warning disable CA1822 // Mark members as static + private string? FormatParameterValue(object value) +#pragma warning restore CA1822 // Mark members as static + { + if (value == null) + { + return "NULL"; + } + + if (value is string || value is char) + { + if (value == null) + { + value = string.Empty; + } + // Handle string values, escaping single quotes +#pragma warning disable CS8602 // Dereference of a possibly null reference. + return $"{value.ToString().Replace("'", "''")}"; +#pragma warning restore CS8602 // Dereference of a possibly null reference. + } + + if (value is bool) + { + // Handle boolean values + return (bool)value ? "TRUE" : "FALSE"; + } + + if (value is DateTime) + { + // Handle DateTime values + return $"'{((DateTime)value).ToString("yyyy-MM-dd HH:mm:ss")}'"; + } + + // Handle numeric and other types + return value == null ? string.Empty : value.ToString(); } public string Build(SqlUpsertQueryStructure structure) @@ -218,13 +270,36 @@ private string MakeSelectColumns(SqlQueryStructure structure) return string.Join(", ", builtColumns); } - - /// - public string BuildStoredProcedureResultDetailsQuery(string databaseObjectName) + public string BuildStoredProcedureResultDetailsQuery(string procedureName) { - throw new NotImplementedException(); + // This query retrieves the details of the result set for a given stored procedure + + string query = $@" + SELECT + p.parameter_name AS name, + p.data_type AS system_type_name, + CASE + WHEN p.parameter_mode = 'IN' THEN FALSE + ELSE TRUE + END AS is_nullable + FROM + information_schema.parameters p + JOIN + information_schema.routines r + ON p.specific_name = r.specific_name + WHERE + r.routine_schema = 'public' + and p.parameter_mode = 'OUT' + AND r.routine_name = '{procedureName}' + ORDER BY + p.ordinal_position"; + + + + return query; } + /// public string BuildQueryToGetReadOnlyColumns(string schemaParamName, string tableParamName) { diff --git a/src/Core/Services/MetadataProviders/MySqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MySqlMetadataProvider.cs index 99336180d5..0c3bb032d3 100644 --- a/src/Core/Services/MetadataProviders/MySqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MySqlMetadataProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections; using System.Data; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Core.Configurations; @@ -128,12 +129,124 @@ protected override DatabaseTable GenerateDbTable(string schemaName, string table } /// - /// Takes a string version of a MySql data type and returns its .NET common language runtime (CLR) counterpart - /// TODO: For MySql stored procedure support, this needs to be implemented. + /// Takes a string version of a PostgreSql data type and returns its .NET common language runtime (CLR) counterpart + /// TODO: For PostgreSql stored procedure support, this needs to be implemented. /// public override Type SqlToCLRType(string sqlType) { - throw new NotImplementedException(); + switch (sqlType.ToLower()) + { + case "boolean": + case "bool": + return typeof(bool); + + case "smallint": + return typeof(short); + + case "integer": + case "int": + return typeof(int); + + case "bigint": + return typeof(long); + + case "real": + return typeof(float); + + case "double precision": + return typeof(double); + + case "numeric": + case "decimal": + return typeof(decimal); + + case "money": + return typeof(decimal); + + case "character varying": + case "varchar": + case "character": + case "char": + case "text": + return typeof(string); + + case "bytea": + return typeof(byte[]); + + case "date": + return typeof(DateTime); + + case "timestamp": + case "timestamp without time zone": + return typeof(DateTime); + + case "timestamp with time zone": + return typeof(DateTimeOffset); + + case "time": + case "time without time zone": + return typeof(TimeSpan); + + case "time with time zone": + return typeof(DateTimeOffset); + + case "interval": + return typeof(TimeSpan); + + case "uuid": + return typeof(Guid); + + case "json": + case "jsonb": + return typeof(string); + + case "xml": + return typeof(string); + + case "inet": + return typeof(System.Net.IPAddress); + + case "cidr": + return typeof(ValueTuple); + + case "macaddr": + return typeof(System.Net.NetworkInformation.PhysicalAddress); + + case "bit": + case "bit varying": + return typeof(BitArray); + + case "point": + return typeof((double, double)); + + case "line": + return typeof(string); // Implement a custom type if needed + + case "lseg": + return typeof((double, double)[]); + + case "box": + return typeof((double, double)[]); + + case "path": + return typeof((double, double)[]); + + case "polygon": + return typeof((double, double)[]); + + case "circle": + return typeof((double, double, double)); + + case "tsvector": + return typeof(string); // Implement a custom type if needed + + case "tsquery": + return typeof(string); // Implement a custom type if needed + + default: + throw new NotSupportedException($"The SQL type '{sqlType}' is not supported."); + } } + } } diff --git a/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs index ecd65b3d95..35cf0f7a84 100644 --- a/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections; using System.Net; +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Resolvers.Factories; using Azure.DataApiBuilder.Service.Exceptions; @@ -65,6 +68,16 @@ public override string GetDefaultSchemaName() { return "public"; } + /// + protected override Task FillSchemaForStoredProcedureAsync( + Entity procedureEntity, + string entityName, + string schemaName, + string storedProcedureSourceName, + StoredProcedureDefinition storedProcedureDefinition) + { + return Task.CompletedTask; + } /// /// Takes a string version of a PostgreSql data type and returns its .NET common language runtime (CLR) counterpart @@ -72,7 +85,119 @@ public override string GetDefaultSchemaName() /// public override Type SqlToCLRType(string sqlType) { - throw new NotImplementedException(); + switch (sqlType.ToLower()) + { + case "boolean": + case "bool": + return typeof(bool); + + case "smallint": + return typeof(short); + + case "integer": + case "int": + return typeof(int); + + case "bigint": + return typeof(long); + + case "real": + return typeof(float); + + case "double precision": + return typeof(double); + + case "numeric": + case "decimal": + return typeof(decimal); + + case "money": + return typeof(decimal); + + case "character varying": + case "varchar": + case "character": + case "char": + case "text": + return typeof(string); + + case "bytea": + return typeof(byte[]); + + case "date": + return typeof(DateTime); + + case "timestamp": + case "timestamp without time zone": + return typeof(DateTime); + + case "timestamp with time zone": + return typeof(DateTimeOffset); + + case "time": + case "time without time zone": + return typeof(TimeSpan); + + case "time with time zone": + return typeof(DateTimeOffset); + + case "interval": + return typeof(TimeSpan); + + case "uuid": + return typeof(Guid); + + case "json": + case "jsonb": + return typeof(string); + + case "xml": + return typeof(string); + + case "inet": + return typeof(System.Net.IPAddress); + + case "cidr": + return typeof(ValueTuple); + + case "macaddr": + return typeof(System.Net.NetworkInformation.PhysicalAddress); + + case "bit": + case "bit varying": + return typeof(BitArray); + + case "point": + return typeof((double, double)); + + case "line": + return typeof(string); // Implement a custom type if needed + + case "lseg": + return typeof((double, double)[]); + + case "box": + return typeof((double, double)[]); + + case "path": + return typeof((double, double)[]); + + case "polygon": + return typeof((double, double)[]); + + case "circle": + return typeof((double, double, double)); + + case "tsvector": + return typeof(string); // Implement a custom type if needed + + case "tsquery": + return typeof(string); // Implement a custom type if needed + + default: + throw new NotSupportedException($"The SQL type '{sqlType}' is not supported."); + } } + } } diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 839ac05785..73fd04582c 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -19,6 +19,7 @@ using Azure.DataApiBuilder.Service.Exceptions; using HotChocolate.Language; using Microsoft.Extensions.Logging; +using Npgsql; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming; [assembly: InternalsVisibleTo("Azure.DataApiBuilder.Service.Tests")] @@ -478,6 +479,102 @@ protected virtual async Task FillSchemaForStoredProcedureAsync( GraphQLStoredProcedureExposedNameToEntityNameMap.TryAdd(GenerateStoredProcedureGraphQLFieldName(entityName, procedureEntity), entityName); } + protected virtual async Task FillSchemaForFunctionAsync( + Entity procedureEntity, + string entityName, + string schemaName, + string storedProcedureSourceName, + StoredProcedureDefinition storedProcedureDefinition) + { + using NpgsqlConnection conn = new(ConnectionString); + DataTable procedureMetadata; + DataTable parameterMetadata; + + try + { + await QueryExecutor.SetManagedIdentityAccessTokenIfAnyAsync(conn, _dataSourceName); + await conn.OpenAsync(); + + // Retrieve procedure metadata from PostgreSQL information schema + string procedureMetadataQuery = @" + SELECT routine_name + FROM information_schema.routines + WHERE routine_schema = @schemaName AND routine_name = @procedureName + "; + + using (NpgsqlCommand procedureCommand = new(procedureMetadataQuery, conn)) + { + procedureCommand.Parameters.AddWithValue("@schemaName", schemaName); + procedureCommand.Parameters.AddWithValue("@procedureName", storedProcedureSourceName); + + using (NpgsqlDataReader reader = await procedureCommand.ExecuteReaderAsync()) + { + procedureMetadata = new DataTable(); + procedureMetadata.Load(reader); + } + } + + // Throw exception if procedure does not exist + if (procedureMetadata.Rows.Count == 0) + { + throw new DataApiBuilderException( + message: $"No stored procedure definition found for the given database object {storedProcedureSourceName}", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + + // Retrieve parameter metadata from PostgreSQL information schema + string parameterMetadataQuery = @" + SELECT + p.parameter_name, + p.data_type + FROM + information_schema.parameters p + JOIN + information_schema.routines r ON p.specific_name = r.specific_name + WHERE + p.parameter_mode ='IN' AND + r.routine_schema = @schemaName AND r.routine_name = @functionName"; + + using (NpgsqlCommand parameterCommand = new(parameterMetadataQuery, conn)) + { + parameterCommand.Parameters.AddWithValue("@schemaName", schemaName); + parameterCommand.Parameters.AddWithValue("@functionName", storedProcedureSourceName); + + using (NpgsqlDataReader reader = await parameterCommand.ExecuteReaderAsync()) + { + parameterMetadata = new DataTable(); + parameterMetadata.Load(reader); + } + } + + // For each row/parameter, add an entry to StoredProcedureDefinition.Parameters dictionary + foreach (DataRow row in parameterMetadata.Rows) + { + string sqlType = (string)row["data_type"]; + Type systemType = SqlToCLRType(sqlType); + ParameterDefinition paramDefinition = new() + { + SystemType = systemType, + DbType = TypeHelper.GetDbTypeFromSystemType(systemType) + }; + + storedProcedureDefinition.Parameters.TryAdd((string)row["parameter_name"], paramDefinition); + } + } + catch (Exception ex) + { + string message = $"Cannot obtain Schema for entity {entityName} " + + $"with underlying database object source: {schemaName}.{storedProcedureSourceName} " + + $"due to: {ex.Message}"; + + throw new DataApiBuilderException( + message: message, + innerException: ex, + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + } /// /// Takes a string version of a sql data type and returns its .NET common language runtime (CLR) counterpart /// @@ -679,6 +776,14 @@ protected void PopulateDatabaseObjectForEntity( StoredProcedureDefinition = new() }; } + else if (sourceType is EntitySourceType.Function) + { + sourceObject = new DatabaseStoredProcedure(schemaName, dbObjectName) + { + SourceType = sourceType, + StoredProcedureDefinition = new() + }; + } else if (sourceType is EntitySourceType.Table) { sourceObject = new DatabaseTable() @@ -1076,7 +1181,24 @@ private async Task PopulateObjectDefinitionForEntity(string entityName, Entity e EntitySourceType entitySourceType = GetEntitySourceType(entityName, entity); if (entitySourceType is EntitySourceType.StoredProcedure) { - await FillSchemaForStoredProcedureAsync( + await FillSchemaForFunctionAsync( + entity, + entityName, + GetSchemaName(entityName), + GetDatabaseObjectName(entityName), + GetStoredProcedureDefinition(entityName)); + + if (GetDatabaseType() == DatabaseType.MSSQL || GetDatabaseType() == DatabaseType.DWSQL || GetDatabaseType() == DatabaseType.PostgreSQL) + { + await PopulateResultSetDefinitionsForStoredProcedureAsync( + GetSchemaName(entityName), + GetDatabaseObjectName(entityName), + GetStoredProcedureDefinition(entityName)); + } + } + else if (entitySourceType is EntitySourceType.Function) + { + await FillSchemaForFunctionAsync( entity, entityName, GetSchemaName(entityName), @@ -1131,7 +1253,7 @@ private async Task PopulateResultSetDefinitionsForStoredProcedureAsync( // Generate query to get result set details // of the stored procedure. string queryForResultSetDetails = SqlQueryBuilder.BuildStoredProcedureResultDetailsQuery( - dbStoredProcedureName); + storedProcedureName); // Execute the query to get columns' details. JsonArray? resultArray = await QueryExecutor.ExecuteQueryAsync(