diff --git a/config-generators/postgresql-commands.txt b/config-generators/postgresql-commands.txt index 07eb8aaa74..30bb2970c9 100644 --- a/config-generators/postgresql-commands.txt +++ b/config-generators/postgresql-commands.txt @@ -171,6 +171,8 @@ update series --config "dab-config.PostgreSql.json" --permissions "TestNestedFil update series --config "dab-config.PostgreSql.json" --permissions "TestNestedFilterOneMany_ColumnForbidden:read" update series --config "dab-config.PostgreSql.json" --permissions "TestNestedFilterOneMany_EntityReadForbidden:read" update DefaultBuiltInFunction --config "dab-config.PostgreSql.json" --permissions "anonymous:create" --fields.exclude "current_date,next_date" +add ArrayType --config "dab-config.PostgreSql.json" --source "array_type_table" --permissions "anonymous:read" --rest true --graphql "arrayType:arrayTypes" +update ArrayType --config "dab-config.PostgreSql.json" --permissions "authenticated:read" add dbo_DimAccount --config "dab-config.PostgreSql.json" --source "dimaccount" --permissions "anonymous:*" --rest true --graphql true update dbo_DimAccount --config "dab-config.PostgreSql.json" --map "parentaccountkey:ParentAccountKey,accountkey:AccountKey" update dbo_DimAccount --config "dab-config.PostgreSql.json" --relationship parent_account --target.entity dbo_DimAccount --cardinality one --relationship.fields "parentaccountkey:accountkey" diff --git a/src/Config/DatabasePrimitives/DatabaseObject.cs b/src/Config/DatabasePrimitives/DatabaseObject.cs index 9548eda1ba..8bf10664d3 100644 --- a/src/Config/DatabasePrimitives/DatabaseObject.cs +++ b/src/Config/DatabasePrimitives/DatabaseObject.cs @@ -282,6 +282,17 @@ public class ColumnDefinition public object? DefaultValue { get; set; } public int? Length { get; set; } + /// + /// Indicates whether this column is a database array type (e.g., PostgreSQL int[], text[]). + /// + public bool IsArrayType { get; set; } + + /// + /// The CLR type of the array element when is true. + /// For example, typeof(int) for an int[] column. + /// + public Type? ElementSystemType { get; set; } + public ColumnDefinition() { } public ColumnDefinition(Type systemType) diff --git a/src/Core/Parsers/EdmModelBuilder.cs b/src/Core/Parsers/EdmModelBuilder.cs index 63dabb3616..24295101a8 100644 --- a/src/Core/Parsers/EdmModelBuilder.cs +++ b/src/Core/Parsers/EdmModelBuilder.cs @@ -111,7 +111,8 @@ SourceDefinition sourceDefinition // each column represents a property of the current entity we are adding foreach (string column in sourceDefinition.Columns.Keys) { - Type columnSystemType = sourceDefinition.Columns[column].SystemType; + ColumnDefinition columnDef = sourceDefinition.Columns[column]; + Type columnSystemType = columnDef.SystemType; // need to convert our column system type to an Edm type EdmPrimitiveTypeKind type = TypeHelper.GetEdmPrimitiveTypeFromSystemType(columnSystemType); @@ -125,6 +126,14 @@ SourceDefinition sourceDefinition sqlMetadataProvider.TryGetExposedColumnName(entityAndDbObject.Key, column, out exposedColumnName!); newEntity.AddKeys(newEntity.AddStructuralProperty(name: exposedColumnName, type, isNullable: false)); } + else if (columnDef.IsArrayType) + { + // Array columns are represented as EDM collection types (e.g., Collection(Edm.Int32) for int[]). + sqlMetadataProvider.TryGetExposedColumnName(entityAndDbObject.Key, column, out exposedColumnName!); + EdmPrimitiveTypeReference elementTypeRef = new(EdmCoreModel.Instance.GetPrimitiveType(type), isNullable: true); + EdmCollectionTypeReference collectionTypeRef = new(new EdmCollectionType(elementTypeRef)); + newEntity.AddStructuralProperty(name: exposedColumnName, collectionTypeRef); + } else { sqlMetadataProvider.TryGetExposedColumnName(entityAndDbObject.Key, column, out exposedColumnName!); diff --git a/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs index 0d43d0efbc..6e2a4cec3f 100644 --- a/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Data; using System.Net; +using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Resolvers.Factories; using Azure.DataApiBuilder.Service.Exceptions; @@ -75,5 +77,74 @@ public override Type SqlToCLRType(string sqlType) { throw new NotImplementedException(); } + + /// + /// Maps PostgreSQL array udt_name prefixes to their CLR element types. + /// PostgreSQL array types in information_schema use udt_name with a leading underscore + /// (e.g., _int4 for int[], _text for text[]). + /// + private static readonly Dictionary _pgArrayUdtToElementType = new(StringComparer.OrdinalIgnoreCase) + { + ["_int2"] = typeof(short), + ["_int4"] = typeof(int), + ["_int8"] = typeof(long), + ["_float4"] = typeof(float), + ["_float8"] = typeof(double), + ["_numeric"] = typeof(decimal), + ["_bool"] = typeof(bool), + ["_text"] = typeof(string), + ["_varchar"] = typeof(string), + ["_bpchar"] = typeof(string), + ["_uuid"] = typeof(Guid), + ["_timestamp"] = typeof(DateTime), + ["_timestamptz"] = typeof(DateTimeOffset), + ["_json"] = typeof(string), + ["_jsonb"] = typeof(string), + ["_money"] = typeof(decimal), + }; + + /// + /// Override to detect PostgreSQL array columns using information_schema metadata. + /// Npgsql's DataAdapter reports array columns as System.Array (the abstract base class), + /// so we use the data_type and udt_name from information_schema.columns to identify arrays + /// and resolve their element types. + /// + protected override void PopulateColumnDefinitionWithHasDefaultAndDbType( + SourceDefinition sourceDefinition, + DataTable allColumnsInTable) + { + foreach (DataRow columnInfo in allColumnsInTable.Rows) + { + string columnName = (string)columnInfo["COLUMN_NAME"]; + bool hasDefault = + Type.GetTypeCode(columnInfo["COLUMN_DEFAULT"].GetType()) != TypeCode.DBNull; + + if (sourceDefinition.Columns.TryGetValue(columnName, out ColumnDefinition? columnDefinition)) + { + columnDefinition.HasDefault = hasDefault; + + if (hasDefault) + { + columnDefinition.DefaultValue = columnInfo["COLUMN_DEFAULT"]; + } + + // Detect array columns: data_type is "ARRAY" in information_schema for PostgreSQL array types. + string dataType = columnInfo["DATA_TYPE"] is string dt ? dt : string.Empty; + if (string.Equals(dataType, "ARRAY", StringComparison.OrdinalIgnoreCase)) + { + string udtName = columnInfo["UDT_NAME"] is string udt ? udt : string.Empty; + if (_pgArrayUdtToElementType.TryGetValue(udtName, out Type? elementType)) + { + columnDefinition.IsArrayType = true; + columnDefinition.ElementSystemType = elementType; + columnDefinition.SystemType = elementType.MakeArrayType(); + columnDefinition.IsReadOnly = true; + } + } + + columnDefinition.DbType = TypeHelper.GetDbTypeFromSystemType(columnDefinition.SystemType); + } + } + } } } diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 951b5984e4..22213e04c1 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -1479,14 +1479,24 @@ private async Task PopulateSourceDefinitionAsync( subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); } + Type systemType = (Type)columnInfoFromAdapter["DataType"]; + + // Detect array types: concrete array types (e.g., int[]) have IsArray=true, + // while Npgsql reports abstract System.Array for PostgreSQL array columns. + // byte[] is excluded since it maps to the bytea/ByteArray scalar type. + bool isArrayType = (systemType.IsArray && systemType != typeof(byte[])) || systemType == typeof(Array); + ColumnDefinition column = new() { IsNullable = (bool)columnInfoFromAdapter["AllowDBNull"], IsAutoGenerated = (bool)columnInfoFromAdapter["IsAutoIncrement"], - SystemType = (Type)columnInfoFromAdapter["DataType"], + SystemType = systemType, + IsArrayType = isArrayType, + ElementSystemType = isArrayType && systemType.IsArray ? systemType.GetElementType() : null, // An auto-increment column is also considered as a read-only column. For other types of read-only columns, // the flag is populated later via PopulateColumnDefinitionsWithReadOnlyFlag() method. - IsReadOnly = (bool)columnInfoFromAdapter["IsAutoIncrement"] + // Array columns are also treated as read-only until write support for array types is implemented. + IsReadOnly = (bool)columnInfoFromAdapter["IsAutoIncrement"] || isArrayType }; // Tests may try to add the same column simultaneously diff --git a/src/Core/Services/TypeHelper.cs b/src/Core/Services/TypeHelper.cs index 40d940c807..6d3512fb3d 100644 --- a/src/Core/Services/TypeHelper.cs +++ b/src/Core/Services/TypeHelper.cs @@ -135,6 +135,12 @@ public static EdmPrimitiveTypeKind GetEdmPrimitiveTypeFromSystemType(Type column { columnSystemType = columnSystemType.GetElementType()!; } + else if (columnSystemType == typeof(Array)) + { + // Npgsql may report abstract System.Array for unresolved PostgreSQL array columns. + // Default to String if the element type hasn't been resolved yet. + return EdmPrimitiveTypeKind.String; + } EdmPrimitiveTypeKind type = columnSystemType.Name switch { diff --git a/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs b/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs index 58ff41c504..5441dcc766 100644 --- a/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs @@ -50,6 +50,14 @@ private static List GenerateOrderByInputFieldsForBuilt List inputFields = new(); foreach (FieldDefinitionNode field in node.Fields) { + // Skip scalar array fields (e.g., PostgreSQL int[], text[]) - they cannot be ordered. + // Non-scalar list types (e.g., Cosmos nested object arrays) are not skipped + // because they are handled as relationship navigations. + if (field.Type.IsListType() && IsBuiltInType(field.Type)) + { + continue; + } + if (IsBuiltInType(field.Type)) { inputFields.Add( @@ -110,6 +118,17 @@ private static List GenerateFilterInputFieldsForBuiltI List inputFields = new(); foreach (FieldDefinitionNode field in objectTypeDefinitionNode.Fields) { + // Skip auto-generated list fields (e.g., PostgreSQL int[], text[] array columns) + // which are read-only and cannot be filtered. Cosmos scalar arrays like + // tags: [String] do NOT have @autoGenerated and remain filterable + // (using ARRAY_CONTAINS). + if (field.Type.IsListType() + && IsBuiltInType(field.Type) + && field.Directives.Any(d => d.Name.Value == AutoGeneratedDirectiveType.DirectiveName)) + { + continue; + } + string fieldTypeName = field.Type.NamedType().Name.Value; if (IsBuiltInType(field.Type)) { diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs index 76057a76dc..da56287b46 100644 --- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs +++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs @@ -441,7 +441,13 @@ private static FieldDefinitionNode GenerateFieldForColumn(Entity configEntity, s } } - NamedTypeNode fieldType = new(GetGraphQLTypeFromSystemType(column.SystemType)); + NamedTypeNode namedType = new(GetGraphQLTypeFromSystemType(column.SystemType)); + + // For array columns, wrap the element type in a ListTypeNode (e.g., [Int], [String]). + INullableTypeNode fieldType = column.IsArrayType + ? new ListTypeNode(namedType) + : namedType; + FieldDefinitionNode field = new( location: null, new(exposedColumnName), @@ -541,6 +547,19 @@ private static List GenerateObjectTypeDirectivesForEntity(string /// GraphQL type." public static string GetGraphQLTypeFromSystemType(Type type) { + // For array types (e.g., int[], string[]), resolve the element type. + // byte[] is excluded as it maps to the ByteArray scalar type. + if (type.IsArray && type != typeof(byte[])) + { + type = type.GetElementType()!; + } + else if (type == typeof(Array)) + { + // Npgsql may report abstract System.Array for unresolved PostgreSQL array columns. + // Default to String if the element type hasn't been resolved yet. + return STRING_TYPE; + } + return type.Name switch { "String" => STRING_TYPE, diff --git a/src/Service.Tests/DatabaseSchema-PostgreSql.sql b/src/Service.Tests/DatabaseSchema-PostgreSql.sql index 523e96c22f..79df1f0dd4 100644 --- a/src/Service.Tests/DatabaseSchema-PostgreSql.sql +++ b/src/Service.Tests/DatabaseSchema-PostgreSql.sql @@ -22,6 +22,7 @@ DROP TABLE IF EXISTS stocks_price; DROP TABLE IF EXISTS stocks; DROP TABLE IF EXISTS comics; DROP TABLE IF EXISTS brokers; +DROP TABLE IF EXISTS array_type_table; DROP TABLE IF EXISTS type_table; DROP TABLE IF EXISTS trees; DROP TABLE IF EXISTS fungi; @@ -166,6 +167,17 @@ CREATE TABLE type_table( uuid_types uuid DEFAULT gen_random_uuid () ); +CREATE TABLE array_type_table( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + int_array_col int[], + text_array_col text[], + bool_array_col boolean[], + long_array_col bigint[], + json_array_col json[], + jsonb_array_col jsonb[], + money_array_col money[] +); + CREATE TABLE trees ( "treeId" int PRIMARY KEY, species text, @@ -412,6 +424,10 @@ INSERT INTO type_table(id, short_types, int_types, long_types, string_types, sin (4, 32767, 2147483647, 9223372036854775807, 'null', 3.4E38, 1.7E308, 2.929292E-14, true, '9999-12-31 23:59:59.997', '\xFFFFFFFF'), (5, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); INSERT INTO type_table(id, uuid_types) values(10, 'D1D021A8-47B4-4AE4-B718-98E89C41A161'); +INSERT INTO array_type_table(id, int_array_col, text_array_col, bool_array_col, long_array_col, json_array_col, jsonb_array_col, money_array_col) VALUES + (1, '{1,2,3}', '{hello,world}', '{true,false}', '{100,200,300}', ARRAY['{"key":"value"}'::json, '{"num":42}'::json], ARRAY['{"key":"value"}'::jsonb, '{"num":42}'::jsonb], '{10.50,20.75,30.25}'), + (2, '{10,20}', '{foo,bar,baz}', '{true,true}', '{999}', ARRAY['{"id":1}'::json], ARRAY['{"id":1}'::jsonb], '{5.00,15.00}'), + (3, NULL, NULL, NULL, NULL, NULL, NULL, NULL); INSERT INTO trees("treeId", species, region, height) VALUES (1, 'Tsuga terophylla', 'Pacific Northwest', '30m'), (2, 'Pseudotsuga menziesii', 'Pacific Northwest', '40m'); INSERT INTO trees("treeId", species, region, height) VALUES (4, 'test', 'Pacific Northwest', '0m'); INSERT INTO fungi(speciesid, region, habitat) VALUES (1, 'northeast', 'forest'), (2, 'southwest', 'sand'); diff --git a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs index 84806adc78..164c9d6f05 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs @@ -231,6 +231,10 @@ public void MultipleColumnsAllMapped() [DataRow(typeof(byte[]), BYTEARRAY_TYPE)] [DataRow(typeof(Guid), UUID_TYPE)] [DataRow(typeof(TimeOnly), LOCALTIME_TYPE)] + [DataRow(typeof(int[]), INT_TYPE)] + [DataRow(typeof(string[]), STRING_TYPE)] + [DataRow(typeof(bool[]), BOOLEAN_TYPE)] + [DataRow(typeof(long[]), LONG_TYPE)] public void SystemTypeMapsToCorrectGraphQLType(Type systemType, string graphQLType) { SourceDefinition table = new(); @@ -950,5 +954,155 @@ type Book @model(name:""Book"") { InputValueDefinitionNode fieldArg = aggregationType.Fields.First().Arguments[0]; Assert.AreEqual("BookNumericAggregateFields", fieldArg.Type.NamedType().Name.Value); } + + /// + /// Verify that array columns produce a ListTypeNode in the GraphQL schema. + /// For example, int[] should produce [Int] and string[] should produce [String]. + /// + [DataTestMethod] + [DataRow(typeof(int[]), typeof(int), INT_TYPE)] + [DataRow(typeof(string[]), typeof(string), STRING_TYPE)] + [DataRow(typeof(bool[]), typeof(bool), BOOLEAN_TYPE)] + [DataRow(typeof(long[]), typeof(long), LONG_TYPE)] + [DataRow(typeof(float[]), typeof(float), SINGLE_TYPE)] + [DataRow(typeof(double[]), typeof(double), FLOAT_TYPE)] + [DataRow(typeof(decimal[]), typeof(decimal), DECIMAL_TYPE)] + public void ArrayColumnProducesListTypeNode(Type arraySystemType, Type elementType, string expectedGraphQLElementType) + { + SourceDefinition table = new(); + string columnName = COLUMN_NAME; + table.Columns.Add(columnName, new ColumnDefinition + { + SystemType = arraySystemType, + IsArrayType = true, + ElementSystemType = elementType, + IsNullable = true, + IsReadOnly = true + }); + + DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; + + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( + "table", + dbObject, + GenerateEmptyEntity("table"), + new(new Dictionary()), + rolesAllowedForEntity: GetRolesAllowedForEntity(), + rolesAllowedForFields: GetFieldToRolesMap(columnName: columnName) + ); + + FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == columnName); + + // The field type should be a ListTypeNode (nullable array), not a NamedTypeNode. + Assert.IsInstanceOfType(field.Type, typeof(ListTypeNode), "Array column should produce a ListTypeNode."); + + // The inner element type should be the correct GraphQL scalar. + ListTypeNode listType = (ListTypeNode)field.Type; + Assert.AreEqual(expectedGraphQLElementType, listType.Type.NamedType().Name.Value); + } + + /// + /// Verify that a non-nullable array column produces NonNullTypeNode wrapping a ListTypeNode. + /// + [TestMethod] + public void NonNullableArrayColumnProducesNonNullListType() + { + SourceDefinition table = new(); + string columnName = COLUMN_NAME; + table.Columns.Add(columnName, new ColumnDefinition + { + SystemType = typeof(string[]), + IsArrayType = true, + ElementSystemType = typeof(string), + IsNullable = false, + IsReadOnly = true + }); + + DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; + + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( + "table", + dbObject, + GenerateEmptyEntity("table"), + new(new Dictionary()), + rolesAllowedForEntity: GetRolesAllowedForEntity(), + rolesAllowedForFields: GetFieldToRolesMap(columnName: columnName) + ); + + FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == columnName); + + // Should be NonNullType wrapping a ListType. + Assert.IsTrue(field.Type.IsNonNullType(), "Non-nullable array column should produce NonNullTypeNode."); + NonNullTypeNode nonNullType = (NonNullTypeNode)field.Type; + Assert.IsInstanceOfType(nonNullType.Type, typeof(ListTypeNode), "Inner type should be ListTypeNode."); + } + + /// + /// Verify that array columns are marked with the AutoGenerated directive (read-only) + /// and thus excluded from mutation input types. + /// + [TestMethod] + public void ArrayColumnHasAutoGeneratedDirective() + { + SourceDefinition table = new(); + string columnName = COLUMN_NAME; + table.Columns.Add(columnName, new ColumnDefinition + { + SystemType = typeof(int[]), + IsArrayType = true, + ElementSystemType = typeof(int), + IsNullable = true, + IsReadOnly = true + }); + + DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; + + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( + "entity", + dbObject, + GenerateEmptyEntity("entity"), + new(new Dictionary()), + rolesAllowedForEntity: GetRolesAllowedForEntity(), + rolesAllowedForFields: GetFieldToRolesMap(columnName: columnName) + ); + + FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == columnName); + Assert.IsTrue( + field.Directives.Any(d => d.Name.Value == AutoGeneratedDirectiveType.DirectiveName), + "Array columns should have AutoGenerated directive since they are read-only."); + } + + /// + /// Verify that byte[] is NOT treated as an array column (it maps to ByteArray scalar). + /// + [TestMethod] + public void ByteArrayIsNotTreatedAsArrayColumn() + { + SourceDefinition table = new(); + string columnName = COLUMN_NAME; + table.Columns.Add(columnName, new ColumnDefinition + { + SystemType = typeof(byte[]), + IsArrayType = false, + IsNullable = true + }); + + DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; + + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( + "table", + dbObject, + GenerateEmptyEntity("table"), + new(new Dictionary()), + rolesAllowedForEntity: GetRolesAllowedForEntity(), + rolesAllowedForFields: GetFieldToRolesMap(columnName: columnName) + ); + + FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == columnName); + + // byte[] should produce a NamedTypeNode (ByteArray scalar), NOT a ListTypeNode. + Assert.IsNotInstanceOfType(field.Type, typeof(ListTypeNode), "byte[] should not be treated as an array column."); + Assert.AreEqual(BYTEARRAY_TYPE, field.Type.NamedType().Name.Value); + } } } diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt index f7d781fe64..d55b39d969 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt @@ -2665,6 +2665,40 @@ } } }, + { + ArrayType: { + Source: { + Object: array_type_table, + Type: Table + }, + GraphQL: { + Singular: arrayType, + Plural: arrayTypes, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Read + } + ] + }, + { + Role: authenticated, + Actions: [ + { + Action: Read + } + ] + } + ] + } + }, { dbo_DimAccount: { Source: { diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs new file mode 100644 index 0000000000..b17b58a01c --- /dev/null +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLSupportedTypesTests +{ + + /// + /// Tests for PostgreSQL array column support (read-only). + /// Verifies that array columns (int[], text[], boolean[], bigint[], json[], jsonb[], money[]) are correctly + /// returned as JSON arrays via GraphQL queries. + /// + [TestClass, TestCategory(TestCategory.POSTGRESQL)] + public class PostgreSqlGQLArrayTypesTests : SqlTestBase + { + /// + /// Set the database engine for the tests + /// + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.POSTGRESQL; + await InitializeTestFixture(); + } + + /// + /// Query a row with array columns by primary key and verify arrays are returned as JSON arrays. + /// + [TestMethod] + public async Task QueryArrayColumnsByPrimaryKey() + { + string gqlQuery = @"{ + arrayType_by_pk(id: 1) { + id + int_array_col + text_array_col + bool_array_col + long_array_col + json_array_col + jsonb_array_col + money_array_col + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(gqlQuery, "arrayType_by_pk", isAuthenticated: false); + + Assert.AreEqual(1, actual.GetProperty("id").GetInt32()); + + // Note: Array elements are serialized as strings by the DAB query pipeline + // (QueryExecutor reads CLR arrays from DbDataReader and serializes via JsonSerializer). + // Using ToString() for value comparison is intentional given this serialization behavior. + + // Verify int array + JsonElement intArray = actual.GetProperty("int_array_col"); + Assert.AreEqual(JsonValueKind.Array, intArray.ValueKind, $"int_array_col actual: {intArray}"); + Assert.AreEqual(3, intArray.GetArrayLength()); + Assert.AreEqual("1", intArray[0].ToString()); + Assert.AreEqual("2", intArray[1].ToString()); + Assert.AreEqual("3", intArray[2].ToString()); + + // Verify text array + JsonElement textArray = actual.GetProperty("text_array_col"); + Assert.AreEqual(JsonValueKind.Array, textArray.ValueKind, $"text_array_col actual: {textArray}"); + Assert.AreEqual(2, textArray.GetArrayLength()); + Assert.AreEqual("hello", textArray[0].GetString()); + Assert.AreEqual("world", textArray[1].GetString()); + + // Verify boolean array + JsonElement boolArray = actual.GetProperty("bool_array_col"); + Assert.AreEqual(JsonValueKind.Array, boolArray.ValueKind, $"bool_array_col actual: {boolArray}"); + Assert.AreEqual(2, boolArray.GetArrayLength()); + Assert.AreEqual("true", boolArray[0].ToString().ToLowerInvariant()); + Assert.AreEqual("false", boolArray[1].ToString().ToLowerInvariant()); + + // Verify long array + JsonElement longArray = actual.GetProperty("long_array_col"); + Assert.AreEqual(JsonValueKind.Array, longArray.ValueKind, $"long_array_col actual: {longArray}"); + Assert.AreEqual(3, longArray.GetArrayLength()); + Assert.AreEqual("100", longArray[0].ToString()); + Assert.AreEqual("200", longArray[1].ToString()); + Assert.AreEqual("300", longArray[2].ToString()); + + // Verify json array + JsonElement jsonArray = actual.GetProperty("json_array_col"); + Assert.AreEqual(JsonValueKind.Array, jsonArray.ValueKind, $"json_array_col actual: {jsonArray}"); + Assert.AreEqual(2, jsonArray.GetArrayLength()); + Assert.IsTrue(jsonArray[0].ToString().Contains("key")); + Assert.IsTrue(jsonArray[1].ToString().Contains("42")); + + // Verify jsonb array + JsonElement jsonbArray = actual.GetProperty("jsonb_array_col"); + Assert.AreEqual(JsonValueKind.Array, jsonbArray.ValueKind, $"jsonb_array_col actual: {jsonbArray}"); + Assert.AreEqual(2, jsonbArray.GetArrayLength()); + Assert.IsTrue(jsonbArray[0].ToString().Contains("key")); + Assert.IsTrue(jsonbArray[1].ToString().Contains("42")); + + // Verify money array + JsonElement moneyArray = actual.GetProperty("money_array_col"); + Assert.AreEqual(JsonValueKind.Array, moneyArray.ValueKind, $"money_array_col actual: {moneyArray}"); + Assert.AreEqual(3, moneyArray.GetArrayLength()); + } + + /// + /// Query a row where all array columns are NULL and verify they come back as JSON null. + /// + [TestMethod] + public async Task QueryNullArrayColumns() + { + string gqlQuery = @"{ + arrayType_by_pk(id: 3) { + id + int_array_col + text_array_col + bool_array_col + long_array_col + json_array_col + jsonb_array_col + money_array_col + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(gqlQuery, "arrayType_by_pk", isAuthenticated: false); + + Assert.AreEqual(3, actual.GetProperty("id").GetInt32()); + Assert.AreEqual(JsonValueKind.Null, actual.GetProperty("int_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, actual.GetProperty("text_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, actual.GetProperty("bool_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, actual.GetProperty("long_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, actual.GetProperty("json_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, actual.GetProperty("jsonb_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, actual.GetProperty("money_array_col").ValueKind); + } + + /// + /// Query multiple rows with array columns and verify the list result. + /// + [TestMethod] + public async Task QueryMultipleRowsWithArrayColumns() + { + string gqlQuery = @"{ + arrayTypes(first: 2, orderBy: { id: ASC }) { + items { + id + int_array_col + text_array_col + json_array_col + jsonb_array_col + money_array_col + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(gqlQuery, "arrayTypes", isAuthenticated: false); + JsonElement items = actual.GetProperty("items"); + + Assert.AreEqual(2, items.GetArrayLength()); + + // First row + Assert.AreEqual(1, items[0].GetProperty("id").GetInt32()); + Assert.AreEqual(3, items[0].GetProperty("int_array_col").GetArrayLength()); + Assert.AreEqual(2, items[0].GetProperty("json_array_col").GetArrayLength()); + Assert.AreEqual(2, items[0].GetProperty("jsonb_array_col").GetArrayLength()); + Assert.AreEqual(3, items[0].GetProperty("money_array_col").GetArrayLength()); + + // Second row + Assert.AreEqual(2, items[1].GetProperty("id").GetInt32()); + Assert.AreEqual(2, items[1].GetProperty("int_array_col").GetArrayLength()); + Assert.AreEqual(3, items[1].GetProperty("text_array_col").GetArrayLength()); + Assert.AreEqual("foo", items[1].GetProperty("text_array_col")[0].ToString()); + Assert.AreEqual(1, items[1].GetProperty("json_array_col").GetArrayLength()); + Assert.AreEqual(1, items[1].GetProperty("jsonb_array_col").GetArrayLength()); + Assert.AreEqual(2, items[1].GetProperty("money_array_col").GetArrayLength()); + } + } +} diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlRestArrayTypesTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlRestArrayTypesTests.cs new file mode 100644 index 0000000000..23b5a04394 --- /dev/null +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlRestArrayTypesTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.SqlTests.RestApiTests.Find +{ + /// + /// Tests for PostgreSQL array column support via REST endpoints (read-only). + /// Verifies that array columns are correctly returned as JSON arrays via REST GET requests. + /// + [TestClass, TestCategory(TestCategory.POSTGRESQL)] + public class PostgreSqlRestArrayTypesTests : SqlTestBase + { + private const string ARRAY_TYPE_REST_PATH = "api/ArrayType"; + + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.POSTGRESQL; + await InitializeTestFixture(); + } + + /// + /// GET /api/ArrayType - Verify that listing array type entities returns array columns as JSON arrays. + /// + [TestMethod] + public async Task GetArrayTypeList() + { + HttpResponseMessage response = await HttpClient.GetAsync(ARRAY_TYPE_REST_PATH); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + string body = await response.Content.ReadAsStringAsync(); + JsonElement root = JsonDocument.Parse(body).RootElement; + JsonElement items = root.GetProperty("value"); + + Assert.IsTrue(items.GetArrayLength() >= 2, $"Expected at least 2 items, got {items.GetArrayLength()}"); + + // First row should have array values + JsonElement first = items[0]; + Assert.AreEqual(1, first.GetProperty("id").GetInt32()); + Assert.AreEqual(JsonValueKind.Array, first.GetProperty("int_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Array, first.GetProperty("text_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Array, first.GetProperty("bool_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Array, first.GetProperty("long_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Array, first.GetProperty("json_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Array, first.GetProperty("jsonb_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Array, first.GetProperty("money_array_col").ValueKind); + } + + /// + /// GET /api/ArrayType/id/1 - Verify that fetching by primary key returns array columns correctly. + /// + [TestMethod] + public async Task GetArrayTypeByPrimaryKey() + { + HttpResponseMessage response = await HttpClient.GetAsync($"{ARRAY_TYPE_REST_PATH}/id/1"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + string body = await response.Content.ReadAsStringAsync(); + JsonElement root = JsonDocument.Parse(body).RootElement; + JsonElement value = root.GetProperty("value")[0]; + + Assert.AreEqual(1, value.GetProperty("id").GetInt32()); + + // Verify int array + JsonElement intArray = value.GetProperty("int_array_col"); + Assert.AreEqual(JsonValueKind.Array, intArray.ValueKind); + Assert.AreEqual(3, intArray.GetArrayLength()); + + // Verify text array + JsonElement textArray = value.GetProperty("text_array_col"); + Assert.AreEqual(JsonValueKind.Array, textArray.ValueKind); + Assert.AreEqual(2, textArray.GetArrayLength()); + + // Verify boolean array + JsonElement boolArray = value.GetProperty("bool_array_col"); + Assert.AreEqual(JsonValueKind.Array, boolArray.ValueKind); + Assert.AreEqual(2, boolArray.GetArrayLength()); + + // Verify long array + JsonElement longArray = value.GetProperty("long_array_col"); + Assert.AreEqual(JsonValueKind.Array, longArray.ValueKind); + Assert.AreEqual(3, longArray.GetArrayLength()); + + // Verify json array + JsonElement jsonArray = value.GetProperty("json_array_col"); + Assert.AreEqual(JsonValueKind.Array, jsonArray.ValueKind); + Assert.AreEqual(2, jsonArray.GetArrayLength()); + + // Verify jsonb array + JsonElement jsonbArray = value.GetProperty("jsonb_array_col"); + Assert.AreEqual(JsonValueKind.Array, jsonbArray.ValueKind); + Assert.AreEqual(2, jsonbArray.GetArrayLength()); + + // Verify money array + JsonElement moneyArray = value.GetProperty("money_array_col"); + Assert.AreEqual(JsonValueKind.Array, moneyArray.ValueKind); + Assert.AreEqual(3, moneyArray.GetArrayLength()); + } + + /// + /// GET /api/ArrayType/id/3 - Verify that null array columns are returned as JSON null. + /// + [TestMethod] + public async Task GetArrayTypeWithNullArrays() + { + HttpResponseMessage response = await HttpClient.GetAsync($"{ARRAY_TYPE_REST_PATH}/id/3"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + string body = await response.Content.ReadAsStringAsync(); + JsonElement root = JsonDocument.Parse(body).RootElement; + JsonElement value = root.GetProperty("value")[0]; + + Assert.AreEqual(3, value.GetProperty("id").GetInt32()); + Assert.AreEqual(JsonValueKind.Null, value.GetProperty("int_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, value.GetProperty("text_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, value.GetProperty("bool_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, value.GetProperty("long_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, value.GetProperty("json_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, value.GetProperty("jsonb_array_col").ValueKind); + Assert.AreEqual(JsonValueKind.Null, value.GetProperty("money_array_col").ValueKind); + } + } +} diff --git a/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs b/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs index 2ecbac42af..36cc563f78 100644 --- a/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs +++ b/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs @@ -526,7 +526,7 @@ private static void VerifyColumnDefinitionSerializationDeserialization(ColumnDef { // test number of properties/fields defined in Column Definition int fields = typeof(ColumnDefinition).GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Length; - Assert.AreEqual(fields, 9); + Assert.AreEqual(fields, 11); // test values expectedColumnDefinition.Equals(deserializedColumnDefinition); diff --git a/src/Service.Tests/dab-config.PostgreSql.json b/src/Service.Tests/dab-config.PostgreSql.json index 48f9700754..d5772a899b 100644 --- a/src/Service.Tests/dab-config.PostgreSql.json +++ b/src/Service.Tests/dab-config.PostgreSql.json @@ -1313,6 +1313,40 @@ "id": "typeid" } }, + "ArrayType": { + "source": { + "object": "array_type_table", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "ArrayType", + "plural": "ArrayTypes" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "read" + } + ] + }, + { + "role": "authenticated", + "actions": [ + { + "action": "read" + } + ] + } + ] + }, "stocks_price": { "source": { "object": "stocks_price",