Skip to content
2 changes: 2 additions & 0 deletions config-generators/postgresql-commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions src/Config/DatabasePrimitives/DatabaseObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,17 @@ public class ColumnDefinition
public object? DefaultValue { get; set; }
public int? Length { get; set; }

/// <summary>
/// Indicates whether this column is a database array type (e.g., PostgreSQL int[], text[]).
/// </summary>
public bool IsArrayType { get; set; }

/// <summary>
/// The CLR type of the array element when <see cref="IsArrayType"/> is true.
/// For example, typeof(int) for an int[] column.
/// </summary>
public Type? ElementSystemType { get; set; }

public ColumnDefinition() { }

public ColumnDefinition(Type systemType)
Expand Down
12 changes: 11 additions & 1 deletion src/Core/Parsers/EdmModelBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,30 @@ 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);

// 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))
{
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why we want isNullable to be true?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isNullable: true is used because PostgreSQL arrays can contain NULL elements ('{1,NULL,3}'::int[] is valid). The element type reference should reflect this.

It's also consistent with how non-PK columns are handled in the same method — line 140 uses isNullable: true for regular structural properties. Only primary keys use isNullable: false.

I verified that changing to isNullable: false doesn't cause a runtime failure — DAB's OData pipeline doesn't enforce element-level nullability in collections at serialization time. So this is about correctness of the EDM model representation rather than a breaking behavior difference.

I added a test row (id=4) with NULL elements inside arrays ('{1,NULL,3}', '{hello,NULL,world}', etc.) and two new tests that query it:

  • QueryArrayColumnsWithNullElements (GraphQL)
  • GetArrayTypeWithNullElementsInsideArrays (REST)

Both pass with isNullable: true and also with false, confirming the pipeline handles both.

EdmCollectionTypeReference collectionTypeRef = new(new EdmCollectionType(elementTypeRef));
newEntity.AddStructuralProperty(name: exposedColumnName, collectionTypeRef);
}
else
{
sqlMetadataProvider.TryGetExposedColumnName(entityAndDbObject.Key, column, out exposedColumnName!);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -75,5 +77,74 @@ public override Type SqlToCLRType(string sqlType)
{
throw new NotImplementedException();
}

/// <summary>
/// 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[]).
/// </summary>
private static readonly Dictionary<string, Type> _pgArrayUdtToElementType = new(StringComparer.OrdinalIgnoreCase)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be able to add _json, _jsonb, and _money to this dictionary. Currently these types would work for requests, but will generate the wrong schema types

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense. Will verify they work with some new tests.

{
["_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),
};

/// <summary>
/// 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.
/// </summary>
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);
}
}
}
}
}
14 changes: 12 additions & 2 deletions src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/Core/Services/TypeHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
19 changes: 19 additions & 0 deletions src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ private static List<InputValueDefinitionNode> GenerateOrderByInputFieldsForBuilt
List<InputValueDefinitionNode> 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(
Expand Down Expand Up @@ -110,6 +118,17 @@ private static List<InputValueDefinitionNode> GenerateFilterInputFieldsForBuiltI
List<InputValueDefinitionNode> 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))
{
Expand Down
21 changes: 20 additions & 1 deletion src/Service.GraphQLBuilder/Sql/SchemaConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -541,6 +547,19 @@ private static List<DirectiveNode> GenerateObjectTypeDirectivesForEntity(string
/// GraphQL type.</exception>"
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,
Expand Down
17 changes: 17 additions & 0 deletions src/Service.Tests/DatabaseSchema-PostgreSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -412,6 +424,11 @@ 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),
(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');
Expand Down
Loading