From b2e7ad361bdf84c3aef6c02e9a0eea67466bc447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Mon, 30 Mar 2026 00:57:32 -0300 Subject: [PATCH 1/6] Add read-only support for PostgreSQL array columns PostgreSQL array columns (int[], text[], boolean[], bigint[], etc.) were previously unsupported and caused initialization failures. This change adds read-only support so array columns are exposed as list types in GraphQL ([Int], [String], etc.) and as JSON arrays in REST responses. Array columns are marked read-only and excluded from mutation input types until write support is implemented. The implementation adds generic array plumbing in the shared SQL layers and PostgreSQL-specific element type resolution via information_schema udt_name mapping. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DatabasePrimitives/DatabaseObject.cs | 11 ++ src/Core/Parsers/EdmModelBuilder.cs | 11 +- .../PostgreSqlMetadataProvider.cs | 68 ++++++++ .../MetadataProviders/SqlMetadataProvider.cs | 14 +- src/Core/Services/TypeHelper.cs | 6 + .../Sql/SchemaConverter.cs | 21 ++- .../DatabaseSchema-PostgreSql.sql | 13 ++ .../Sql/SchemaConverterTests.cs | 154 ++++++++++++++++++ .../PostgreSqlGQLArrayTypesTests.cs | 141 ++++++++++++++++ .../SerializationDeserializationTests.cs | 2 +- src/Service.Tests/dab-config.PostgreSql.json | 34 ++++ 11 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs 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..5c71dd817b 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,71 @@ 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), + }; + + /// + /// 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/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..c6c75c1a5d 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,14 @@ 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[] +); + CREATE TABLE trees ( "treeId" int PRIMARY KEY, species text, @@ -412,6 +421,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) VALUES + (1, '{1,2,3}', '{hello,world}', '{true,false}', '{100,200,300}'), + (2, '{10,20}', '{foo,bar,baz}', '{true,true}', '{999}'), + (3, 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/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs new file mode 100644 index 0000000000..9adeb07aa3 --- /dev/null +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs @@ -0,0 +1,141 @@ +// 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[]) 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 + } + }"; + + 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()); + } + + /// + /// 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 + } + }"; + + 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); + } + + /// + /// 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 + } + } + }"; + + 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()); + + // 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()); + } + } +} 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", From a18fe7768127affcd1e8efaf8f8fa6625ef75de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Tue, 31 Mar 2026 13:43:31 -0300 Subject: [PATCH 2/6] Fix CI failures for PostgreSQL array column integration tests Add ArrayType entity to postgresql-commands.txt so CI's config generator includes it, and skip list-type fields in InputTypeBuilder filter/orderby generation to prevent array columns from being incorrectly processed. Co-Authored-By: Claude Opus 4.6 (1M context) --- config-generators/postgresql-commands.txt | 2 ++ .../Queries/InputTypeBuilder.cs | 12 ++++++++++++ 2 files changed, 14 insertions(+) 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/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs b/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs index 58ff41c504..c4e601efe3 100644 --- a/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs @@ -50,6 +50,12 @@ private static List GenerateOrderByInputFieldsForBuilt List inputFields = new(); foreach (FieldDefinitionNode field in node.Fields) { + // Skip array/list type fields (e.g., PostgreSQL array columns) - they cannot be ordered. + if (field.Type.IsListType()) + { + continue; + } + if (IsBuiltInType(field.Type)) { inputFields.Add( @@ -110,6 +116,12 @@ private static List GenerateFilterInputFieldsForBuiltI List inputFields = new(); foreach (FieldDefinitionNode field in objectTypeDefinitionNode.Fields) { + // Skip array/list type fields (e.g., PostgreSQL array columns) - they cannot be filtered. + if (field.Type.IsListType()) + { + continue; + } + string fieldTypeName = field.Type.NamedType().Name.Value; if (IsBuiltInType(field.Type)) { From 684ced53256522087c9fa3b1e1d976b3812c28c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Tue, 31 Mar 2026 19:12:58 -0300 Subject: [PATCH 3/6] Add json/jsonb/money array support, REST tests, and snapshot fix - Add _json, _jsonb, _money to PostgreSQL array type mapping dictionary - Add json[], jsonb[], money[] columns to array_type_table test schema - Add REST integration tests for array type endpoints (list, by PK, nulls) - Update GraphQL array tests to cover new array column types - Update PostgreSQL snapshot to include ArrayType entity Co-Authored-By: Claude Opus 4.6 (1M context) --- .../PostgreSqlMetadataProvider.cs | 3 + .../DatabaseSchema-PostgreSql.sql | 13 +- ...ingRuntimeConfigForPostgreSql.verified.txt | 34 +++++ .../PostgreSqlGQLArrayTypesTests.cs | 39 +++++- .../Find/PostgreSqlRestArrayTypesTests.cs | 129 ++++++++++++++++++ 5 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlRestArrayTypesTests.cs diff --git a/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs index 5c71dd817b..6e2a4cec3f 100644 --- a/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs @@ -98,6 +98,9 @@ public override Type SqlToCLRType(string sqlType) ["_uuid"] = typeof(Guid), ["_timestamp"] = typeof(DateTime), ["_timestamptz"] = typeof(DateTimeOffset), + ["_json"] = typeof(string), + ["_jsonb"] = typeof(string), + ["_money"] = typeof(decimal), }; /// diff --git a/src/Service.Tests/DatabaseSchema-PostgreSql.sql b/src/Service.Tests/DatabaseSchema-PostgreSql.sql index c6c75c1a5d..79df1f0dd4 100644 --- a/src/Service.Tests/DatabaseSchema-PostgreSql.sql +++ b/src/Service.Tests/DatabaseSchema-PostgreSql.sql @@ -172,7 +172,10 @@ CREATE TABLE array_type_table( int_array_col int[], text_array_col text[], bool_array_col boolean[], - long_array_col bigint[] + long_array_col bigint[], + json_array_col json[], + jsonb_array_col jsonb[], + money_array_col money[] ); CREATE TABLE trees ( @@ -421,10 +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) VALUES - (1, '{1,2,3}', '{hello,world}', '{true,false}', '{100,200,300}'), - (2, '{10,20}', '{foo,bar,baz}', '{true,true}', '{999}'), - (3, NULL, NULL, NULL, NULL); +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/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 index 9adeb07aa3..b17b58a01c 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs @@ -10,7 +10,7 @@ namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLSupportedTypesTests /// /// Tests for PostgreSQL array column support (read-only). - /// Verifies that array columns (int[], text[], boolean[], bigint[]) are correctly + /// Verifies that array columns (int[], text[], boolean[], bigint[], json[], jsonb[], money[]) are correctly /// returned as JSON arrays via GraphQL queries. /// [TestClass, TestCategory(TestCategory.POSTGRESQL)] @@ -39,6 +39,9 @@ public async Task QueryArrayColumnsByPrimaryKey() text_array_col bool_array_col long_array_col + json_array_col + jsonb_array_col + money_array_col } }"; @@ -79,6 +82,25 @@ public async Task QueryArrayColumnsByPrimaryKey() 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()); } /// @@ -94,6 +116,9 @@ public async Task QueryNullArrayColumns() text_array_col bool_array_col long_array_col + json_array_col + jsonb_array_col + money_array_col } }"; @@ -104,6 +129,9 @@ public async Task QueryNullArrayColumns() 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); } /// @@ -118,6 +146,9 @@ public async Task QueryMultipleRowsWithArrayColumns() id int_array_col text_array_col + json_array_col + jsonb_array_col + money_array_col } } }"; @@ -130,12 +161,18 @@ public async Task QueryMultipleRowsWithArrayColumns() // 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); + } + } +} From b0090a489431e32f6da8f1c62d4a1de8f094c95b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Wed, 1 Apr 2026 12:26:25 +0000 Subject: [PATCH 4/6] Fix InputTypeBuilder skipping Cosmos nested array fields from filter/orderby MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous change to skip list-type fields was too broad — it excluded all list types, which broke Cosmos DB nested object array filtering (e.g., additionalAttributes, moons). Narrowed the condition to only skip scalar list types (IsListType && IsBuiltInType) so PostgreSQL array columns (int[], text[]) are still excluded while Cosmos nested arrays remain filterable. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Queries/InputTypeBuilder.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs b/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs index c4e601efe3..a19bb2e56f 100644 --- a/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs @@ -50,8 +50,10 @@ private static List GenerateOrderByInputFieldsForBuilt List inputFields = new(); foreach (FieldDefinitionNode field in node.Fields) { - // Skip array/list type fields (e.g., PostgreSQL array columns) - they cannot be ordered. - if (field.Type.IsListType()) + // 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; } @@ -116,8 +118,10 @@ private static List GenerateFilterInputFieldsForBuiltI List inputFields = new(); foreach (FieldDefinitionNode field in objectTypeDefinitionNode.Fields) { - // Skip array/list type fields (e.g., PostgreSQL array columns) - they cannot be filtered. - if (field.Type.IsListType()) + // Skip scalar array fields (e.g., PostgreSQL int[], text[]) - they cannot be filtered. + // Non-scalar list types (e.g., Cosmos nested object arrays) are not skipped + // because they support filtering via JOIN-based queries. + if (field.Type.IsListType() && IsBuiltInType(field.Type)) { continue; } From c9fd8ff78c8ebf0f9301ef72d8e6c7de5049e455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 3 Apr 2026 03:20:09 +0000 Subject: [PATCH 5/6] Fix filter input skipping Cosmos scalar array fields like tags: [String] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used IsListType() && IsBuiltInType() to skip PostgreSQL array columns from filter generation, but this also excluded Cosmos scalar arrays (e.g., tags: [String]) which support ARRAY_CONTAINS filtering. Added @autoGenerated directive check so only SQL read-only array columns are skipped — Cosmos fields lack this directive. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Queries/InputTypeBuilder.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs b/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs index a19bb2e56f..5441dcc766 100644 --- a/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs @@ -118,10 +118,13 @@ private static List GenerateFilterInputFieldsForBuiltI List inputFields = new(); foreach (FieldDefinitionNode field in objectTypeDefinitionNode.Fields) { - // Skip scalar array fields (e.g., PostgreSQL int[], text[]) - they cannot be filtered. - // Non-scalar list types (e.g., Cosmos nested object arrays) are not skipped - // because they support filtering via JOIN-based queries. - if (field.Type.IsListType() && IsBuiltInType(field.Type)) + // 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; } From b165f6029341494062a682519a4072754af25eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Tue, 7 Apr 2026 02:54:11 +0000 Subject: [PATCH 6/6] Add tests for arrays with NULL elements and update EdmModelBuilder comment Add test row (id=4) with NULL elements inside arrays (e.g., '{1,NULL,3}') and two new tests (GraphQL and REST) that verify null elements within arrays are handled correctly. Update the EdmModelBuilder block comment to document the array column branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Core/Parsers/EdmModelBuilder.cs | 1 + .../DatabaseSchema-PostgreSql.sql | 3 +- .../PostgreSqlGQLArrayTypesTests.cs | 84 +++++++++++++++++++ .../Find/PostgreSqlRestArrayTypesTests.cs | 48 +++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/Core/Parsers/EdmModelBuilder.cs b/src/Core/Parsers/EdmModelBuilder.cs index 24295101a8..3c6c247dfd 100644 --- a/src/Core/Parsers/EdmModelBuilder.cs +++ b/src/Core/Parsers/EdmModelBuilder.cs @@ -119,6 +119,7 @@ SourceDefinition sourceDefinition // The mapped (aliased) field name defined in the runtime config is used to create a representative // OData StructuralProperty. The created property is then added to the EdmEntityType. // StructuralProperty objects representing database primary keys are added as a 'keyProperties' to the EdmEntityType. + // Array columns are represented as collection-typed StructuralProperties (e.g., Collection(Edm.Int32) for int[]). // Otherwise, the StructuralProperty object is added as a generic StructuralProperty of the EdmEntityType. string exposedColumnName; if (sourceDefinition.PrimaryKey.Contains(column)) diff --git a/src/Service.Tests/DatabaseSchema-PostgreSql.sql b/src/Service.Tests/DatabaseSchema-PostgreSql.sql index 79df1f0dd4..bd36c551b0 100644 --- a/src/Service.Tests/DatabaseSchema-PostgreSql.sql +++ b/src/Service.Tests/DatabaseSchema-PostgreSql.sql @@ -427,7 +427,8 @@ INSERT INTO type_table(id, uuid_types) values(10, 'D1D021A8-47B4-4AE4-B718-98E89 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); + (3, NULL, NULL, NULL, NULL, NULL, NULL, NULL), + (4, '{1,NULL,3}', '{hello,NULL,world}', '{true,NULL,false}', '{100,NULL,300}', ARRAY['{"key":"value"}'::json, null], ARRAY['{"key":"value"}'::jsonb, null], '{10.50,NULL,30.25}'); 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/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs index b17b58a01c..cb5aeff420 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLArrayTypesTests.cs @@ -134,6 +134,90 @@ public async Task QueryNullArrayColumns() Assert.AreEqual(JsonValueKind.Null, actual.GetProperty("money_array_col").ValueKind); } + /// + /// Query a row where array columns contain NULL elements (e.g., '{1,NULL,3}') + /// and verify that null values within arrays are correctly returned. + /// This validates that the EDM element type is marked as nullable (isNullable: true), + /// which is required because PostgreSQL arrays can contain NULL elements. + /// + [TestMethod] + public async Task QueryArrayColumnsWithNullElements() + { + string gqlQuery = @"{ + arrayType_by_pk(id: 4) { + 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(4, actual.GetProperty("id").GetInt32()); + + // Note: The GraphQL pipeline serializes array elements as strings via JsonSerializer. + // Null elements within arrays are serialized as JSON null values. + // This test verifies that arrays with null elements are returned successfully, + // which requires the EDM element type to be nullable (isNullable: true). + + // Verify int array contains null element + JsonElement intArray = actual.GetProperty("int_array_col"); + Assert.AreEqual(JsonValueKind.Array, intArray.ValueKind); + Assert.AreEqual(3, intArray.GetArrayLength()); + Assert.AreEqual("1", intArray[0].ToString()); + Assert.IsTrue(intArray[1].ValueKind == JsonValueKind.Null || intArray[1].ToString() == "", "Expected null element inside int array"); + Assert.AreEqual("3", intArray[2].ToString()); + + // Verify text array contains null element + JsonElement textArray = actual.GetProperty("text_array_col"); + Assert.AreEqual(JsonValueKind.Array, textArray.ValueKind); + Assert.AreEqual(3, textArray.GetArrayLength()); + Assert.AreEqual("hello", textArray[0].ToString()); + Assert.IsTrue(textArray[1].ValueKind == JsonValueKind.Null || textArray[1].ToString() == "", "Expected null element inside text array"); + Assert.AreEqual("world", textArray[2].ToString()); + + // Verify boolean array contains null element + JsonElement boolArray = actual.GetProperty("bool_array_col"); + Assert.AreEqual(JsonValueKind.Array, boolArray.ValueKind); + Assert.AreEqual(3, boolArray.GetArrayLength()); + Assert.AreEqual("true", boolArray[0].ToString().ToLowerInvariant()); + Assert.IsTrue(boolArray[1].ValueKind == JsonValueKind.Null || boolArray[1].ToString() == "", "Expected null element inside bool array"); + Assert.AreEqual("false", boolArray[2].ToString().ToLowerInvariant()); + + // Verify long array contains null element + JsonElement longArray = actual.GetProperty("long_array_col"); + Assert.AreEqual(JsonValueKind.Array, longArray.ValueKind); + Assert.AreEqual(3, longArray.GetArrayLength()); + Assert.AreEqual("100", longArray[0].ToString()); + Assert.IsTrue(longArray[1].ValueKind == JsonValueKind.Null || longArray[1].ToString() == "", "Expected null element inside long array"); + Assert.AreEqual("300", longArray[2].ToString()); + + // Verify json array contains null element + JsonElement jsonArray = actual.GetProperty("json_array_col"); + Assert.AreEqual(JsonValueKind.Array, jsonArray.ValueKind); + Assert.AreEqual(2, jsonArray.GetArrayLength()); + Assert.IsTrue(jsonArray[0].ToString().Contains("key")); + Assert.IsTrue(jsonArray[1].ValueKind == JsonValueKind.Null || jsonArray[1].ToString() == "", "Expected null element inside json array"); + + // Verify jsonb array contains null element + JsonElement jsonbArray = actual.GetProperty("jsonb_array_col"); + Assert.AreEqual(JsonValueKind.Array, jsonbArray.ValueKind); + Assert.AreEqual(2, jsonbArray.GetArrayLength()); + Assert.IsTrue(jsonbArray[0].ToString().Contains("key")); + Assert.IsTrue(jsonbArray[1].ValueKind == JsonValueKind.Null || jsonbArray[1].ToString() == "", "Expected null element inside jsonb array"); + + // Verify money array contains null element + JsonElement moneyArray = actual.GetProperty("money_array_col"); + Assert.AreEqual(JsonValueKind.Array, moneyArray.ValueKind); + Assert.AreEqual(3, moneyArray.GetArrayLength()); + Assert.IsTrue(moneyArray[1].ValueKind == JsonValueKind.Null || moneyArray[1].ToString() == "", "Expected null element inside money array"); + } + /// /// Query multiple rows with array columns and verify the list result. /// diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlRestArrayTypesTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlRestArrayTypesTests.cs index 23b5a04394..e23d330cf7 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlRestArrayTypesTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlRestArrayTypesTests.cs @@ -103,6 +103,54 @@ public async Task GetArrayTypeByPrimaryKey() Assert.AreEqual(3, moneyArray.GetArrayLength()); } + /// + /// GET /api/ArrayType/id/4 - Verify that arrays containing NULL elements are returned correctly. + /// PostgreSQL arrays can contain NULL elements (e.g., '{1,NULL,3}'). + /// This validates that the EDM element type is marked as nullable (isNullable: true). + /// + [TestMethod] + public async Task GetArrayTypeWithNullElementsInsideArrays() + { + HttpResponseMessage response = await HttpClient.GetAsync($"{ARRAY_TYPE_REST_PATH}/id/4"); + 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(4, value.GetProperty("id").GetInt32()); + + // Verify int array has null element at index 1 + JsonElement intArray = value.GetProperty("int_array_col"); + Assert.AreEqual(JsonValueKind.Array, intArray.ValueKind); + Assert.AreEqual(3, intArray.GetArrayLength()); + Assert.AreEqual(JsonValueKind.Null, intArray[1].ValueKind, "Expected null element inside int array"); + + // Verify text array has null element at index 1 + JsonElement textArray = value.GetProperty("text_array_col"); + Assert.AreEqual(JsonValueKind.Array, textArray.ValueKind); + Assert.AreEqual(3, textArray.GetArrayLength()); + Assert.AreEqual(JsonValueKind.Null, textArray[1].ValueKind, "Expected null element inside text array"); + + // Verify boolean array has null element at index 1 + JsonElement boolArray = value.GetProperty("bool_array_col"); + Assert.AreEqual(JsonValueKind.Array, boolArray.ValueKind); + Assert.AreEqual(3, boolArray.GetArrayLength()); + Assert.AreEqual(JsonValueKind.Null, boolArray[1].ValueKind, "Expected null element inside bool array"); + + // Verify long array has null element at index 1 + JsonElement longArray = value.GetProperty("long_array_col"); + Assert.AreEqual(JsonValueKind.Array, longArray.ValueKind); + Assert.AreEqual(3, longArray.GetArrayLength()); + Assert.AreEqual(JsonValueKind.Null, longArray[1].ValueKind, "Expected null element inside long array"); + + // Verify money array has null element at index 1 + JsonElement moneyArray = value.GetProperty("money_array_col"); + Assert.AreEqual(JsonValueKind.Array, moneyArray.ValueKind); + Assert.AreEqual(3, moneyArray.GetArrayLength()); + Assert.AreEqual(JsonValueKind.Null, moneyArray[1].ValueKind, "Expected null element inside money array"); + } + /// /// GET /api/ArrayType/id/3 - Verify that null array columns are returned as JSON null. ///