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",