Skip to content

Commit 5bb9456

Browse files
bilby91claude
andcommitted
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) <noreply@anthropic.com>
1 parent 29a1b7d commit 5bb9456

11 files changed

Lines changed: 466 additions & 5 deletions

File tree

src/Config/DatabasePrimitives/DatabaseObject.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,17 @@ public class ColumnDefinition
282282
public object? DefaultValue { get; set; }
283283
public int? Length { get; set; }
284284

285+
/// <summary>
286+
/// Indicates whether this column is a database array type (e.g., PostgreSQL int[], text[]).
287+
/// </summary>
288+
public bool IsArrayType { get; set; }
289+
290+
/// <summary>
291+
/// The CLR type of the array element when <see cref="IsArrayType"/> is true.
292+
/// For example, typeof(int) for an int[] column.
293+
/// </summary>
294+
public Type? ElementSystemType { get; set; }
295+
285296
public ColumnDefinition() { }
286297

287298
public ColumnDefinition(Type systemType)

src/Core/Parsers/EdmModelBuilder.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ SourceDefinition sourceDefinition
111111
// each column represents a property of the current entity we are adding
112112
foreach (string column in sourceDefinition.Columns.Keys)
113113
{
114-
Type columnSystemType = sourceDefinition.Columns[column].SystemType;
114+
ColumnDefinition columnDef = sourceDefinition.Columns[column];
115+
Type columnSystemType = columnDef.SystemType;
115116
// need to convert our column system type to an Edm type
116117
EdmPrimitiveTypeKind type = TypeHelper.GetEdmPrimitiveTypeFromSystemType(columnSystemType);
117118

@@ -125,6 +126,14 @@ SourceDefinition sourceDefinition
125126
sqlMetadataProvider.TryGetExposedColumnName(entityAndDbObject.Key, column, out exposedColumnName!);
126127
newEntity.AddKeys(newEntity.AddStructuralProperty(name: exposedColumnName, type, isNullable: false));
127128
}
129+
else if (columnDef.IsArrayType)
130+
{
131+
// Array columns are represented as EDM collection types (e.g., Collection(Edm.Int32) for int[]).
132+
sqlMetadataProvider.TryGetExposedColumnName(entityAndDbObject.Key, column, out exposedColumnName!);
133+
EdmPrimitiveTypeReference elementTypeRef = new(EdmCoreModel.Instance.GetPrimitiveType(type), isNullable: true);
134+
EdmCollectionTypeReference collectionTypeRef = new(new EdmCollectionType(elementTypeRef));
135+
newEntity.AddStructuralProperty(name: exposedColumnName, collectionTypeRef);
136+
}
128137
else
129138
{
130139
sqlMetadataProvider.TryGetExposedColumnName(entityAndDbObject.Key, column, out exposedColumnName!);

src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Data;
45
using System.Net;
6+
using Azure.DataApiBuilder.Config.DatabasePrimitives;
57
using Azure.DataApiBuilder.Core.Configurations;
68
using Azure.DataApiBuilder.Core.Resolvers.Factories;
79
using Azure.DataApiBuilder.Service.Exceptions;
@@ -75,5 +77,71 @@ public override Type SqlToCLRType(string sqlType)
7577
{
7678
throw new NotImplementedException();
7779
}
80+
81+
/// <summary>
82+
/// Maps PostgreSQL array udt_name prefixes to their CLR element types.
83+
/// PostgreSQL array types in information_schema use udt_name with a leading underscore
84+
/// (e.g., _int4 for int[], _text for text[]).
85+
/// </summary>
86+
private static readonly Dictionary<string, Type> _pgArrayUdtToElementType = new(StringComparer.OrdinalIgnoreCase)
87+
{
88+
["_int2"] = typeof(short),
89+
["_int4"] = typeof(int),
90+
["_int8"] = typeof(long),
91+
["_float4"] = typeof(float),
92+
["_float8"] = typeof(double),
93+
["_numeric"] = typeof(decimal),
94+
["_bool"] = typeof(bool),
95+
["_text"] = typeof(string),
96+
["_varchar"] = typeof(string),
97+
["_bpchar"] = typeof(string),
98+
["_uuid"] = typeof(Guid),
99+
["_timestamp"] = typeof(DateTime),
100+
["_timestamptz"] = typeof(DateTimeOffset),
101+
};
102+
103+
/// <summary>
104+
/// Override to detect PostgreSQL array columns using information_schema metadata.
105+
/// Npgsql's DataAdapter reports array columns as System.Array (the abstract base class),
106+
/// so we use the data_type and udt_name from information_schema.columns to identify arrays
107+
/// and resolve their element types.
108+
/// </summary>
109+
protected override void PopulateColumnDefinitionWithHasDefaultAndDbType(
110+
SourceDefinition sourceDefinition,
111+
DataTable allColumnsInTable)
112+
{
113+
foreach (DataRow columnInfo in allColumnsInTable.Rows)
114+
{
115+
string columnName = (string)columnInfo["COLUMN_NAME"];
116+
bool hasDefault =
117+
Type.GetTypeCode(columnInfo["COLUMN_DEFAULT"].GetType()) != TypeCode.DBNull;
118+
119+
if (sourceDefinition.Columns.TryGetValue(columnName, out ColumnDefinition? columnDefinition))
120+
{
121+
columnDefinition.HasDefault = hasDefault;
122+
123+
if (hasDefault)
124+
{
125+
columnDefinition.DefaultValue = columnInfo["COLUMN_DEFAULT"];
126+
}
127+
128+
// Detect array columns: data_type is "ARRAY" in information_schema for PostgreSQL array types.
129+
string dataType = columnInfo["DATA_TYPE"] is string dt ? dt : string.Empty;
130+
if (string.Equals(dataType, "ARRAY", StringComparison.OrdinalIgnoreCase))
131+
{
132+
string udtName = columnInfo["UDT_NAME"] is string udt ? udt : string.Empty;
133+
if (_pgArrayUdtToElementType.TryGetValue(udtName, out Type? elementType))
134+
{
135+
columnDefinition.IsArrayType = true;
136+
columnDefinition.ElementSystemType = elementType;
137+
columnDefinition.SystemType = elementType.MakeArrayType();
138+
columnDefinition.IsReadOnly = true;
139+
}
140+
}
141+
142+
columnDefinition.DbType = TypeHelper.GetDbTypeFromSystemType(columnDefinition.SystemType);
143+
}
144+
}
145+
}
78146
}
79147
}

src/Core/Services/MetadataProviders/SqlMetadataProvider.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1479,14 +1479,24 @@ private async Task PopulateSourceDefinitionAsync(
14791479
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
14801480
}
14811481

1482+
Type systemType = (Type)columnInfoFromAdapter["DataType"];
1483+
1484+
// Detect array types: concrete array types (e.g., int[]) have IsArray=true,
1485+
// while Npgsql reports abstract System.Array for PostgreSQL array columns.
1486+
// byte[] is excluded since it maps to the bytea/ByteArray scalar type.
1487+
bool isArrayType = (systemType.IsArray && systemType != typeof(byte[])) || systemType == typeof(Array);
1488+
14821489
ColumnDefinition column = new()
14831490
{
14841491
IsNullable = (bool)columnInfoFromAdapter["AllowDBNull"],
14851492
IsAutoGenerated = (bool)columnInfoFromAdapter["IsAutoIncrement"],
1486-
SystemType = (Type)columnInfoFromAdapter["DataType"],
1493+
SystemType = systemType,
1494+
IsArrayType = isArrayType,
1495+
ElementSystemType = isArrayType && systemType.IsArray ? systemType.GetElementType() : null,
14871496
// An auto-increment column is also considered as a read-only column. For other types of read-only columns,
14881497
// the flag is populated later via PopulateColumnDefinitionsWithReadOnlyFlag() method.
1489-
IsReadOnly = (bool)columnInfoFromAdapter["IsAutoIncrement"]
1498+
// Array columns are also treated as read-only until write support for array types is implemented.
1499+
IsReadOnly = (bool)columnInfoFromAdapter["IsAutoIncrement"] || isArrayType
14901500
};
14911501

14921502
// Tests may try to add the same column simultaneously

src/Core/Services/TypeHelper.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ public static EdmPrimitiveTypeKind GetEdmPrimitiveTypeFromSystemType(Type column
135135
{
136136
columnSystemType = columnSystemType.GetElementType()!;
137137
}
138+
else if (columnSystemType == typeof(Array))
139+
{
140+
// Npgsql may report abstract System.Array for unresolved PostgreSQL array columns.
141+
// Default to String if the element type hasn't been resolved yet.
142+
return EdmPrimitiveTypeKind.String;
143+
}
138144

139145
EdmPrimitiveTypeKind type = columnSystemType.Name switch
140146
{

src/Service.GraphQLBuilder/Sql/SchemaConverter.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,13 @@ private static FieldDefinitionNode GenerateFieldForColumn(Entity configEntity, s
441441
}
442442
}
443443

444-
NamedTypeNode fieldType = new(GetGraphQLTypeFromSystemType(column.SystemType));
444+
NamedTypeNode namedType = new(GetGraphQLTypeFromSystemType(column.SystemType));
445+
446+
// For array columns, wrap the element type in a ListTypeNode (e.g., [Int], [String]).
447+
INullableTypeNode fieldType = column.IsArrayType
448+
? new ListTypeNode(namedType)
449+
: namedType;
450+
445451
FieldDefinitionNode field = new(
446452
location: null,
447453
new(exposedColumnName),
@@ -541,6 +547,19 @@ private static List<DirectiveNode> GenerateObjectTypeDirectivesForEntity(string
541547
/// GraphQL type.</exception>"
542548
public static string GetGraphQLTypeFromSystemType(Type type)
543549
{
550+
// For array types (e.g., int[], string[]), resolve the element type.
551+
// byte[] is excluded as it maps to the ByteArray scalar type.
552+
if (type.IsArray && type != typeof(byte[]))
553+
{
554+
type = type.GetElementType()!;
555+
}
556+
else if (type == typeof(Array))
557+
{
558+
// Npgsql may report abstract System.Array for unresolved PostgreSQL array columns.
559+
// Default to String if the element type hasn't been resolved yet.
560+
return STRING_TYPE;
561+
}
562+
544563
return type.Name switch
545564
{
546565
"String" => STRING_TYPE,

src/Service.Tests/DatabaseSchema-PostgreSql.sql

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ DROP TABLE IF EXISTS stocks_price;
2222
DROP TABLE IF EXISTS stocks;
2323
DROP TABLE IF EXISTS comics;
2424
DROP TABLE IF EXISTS brokers;
25+
DROP TABLE IF EXISTS array_type_table;
2526
DROP TABLE IF EXISTS type_table;
2627
DROP TABLE IF EXISTS trees;
2728
DROP TABLE IF EXISTS fungi;
@@ -166,6 +167,14 @@ CREATE TABLE type_table(
166167
uuid_types uuid DEFAULT gen_random_uuid ()
167168
);
168169

170+
CREATE TABLE array_type_table(
171+
id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
172+
int_array_col int[],
173+
text_array_col text[],
174+
bool_array_col boolean[],
175+
long_array_col bigint[]
176+
);
177+
169178
CREATE TABLE trees (
170179
"treeId" int PRIMARY KEY,
171180
species text,
@@ -412,6 +421,10 @@ INSERT INTO type_table(id, short_types, int_types, long_types, string_types, sin
412421
(4, 32767, 2147483647, 9223372036854775807, 'null', 3.4E38, 1.7E308, 2.929292E-14, true, '9999-12-31 23:59:59.997', '\xFFFFFFFF'),
413422
(5, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
414423
INSERT INTO type_table(id, uuid_types) values(10, 'D1D021A8-47B4-4AE4-B718-98E89C41A161');
424+
INSERT INTO array_type_table(id, int_array_col, text_array_col, bool_array_col, long_array_col) VALUES
425+
(1, '{1,2,3}', '{hello,world}', '{true,false}', '{100,200,300}'),
426+
(2, '{10,20}', '{foo,bar,baz}', '{true,true}', '{999}'),
427+
(3, NULL, NULL, NULL, NULL);
415428
INSERT INTO trees("treeId", species, region, height) VALUES (1, 'Tsuga terophylla', 'Pacific Northwest', '30m'), (2, 'Pseudotsuga menziesii', 'Pacific Northwest', '40m');
416429
INSERT INTO trees("treeId", species, region, height) VALUES (4, 'test', 'Pacific Northwest', '0m');
417430
INSERT INTO fungi(speciesid, region, habitat) VALUES (1, 'northeast', 'forest'), (2, 'southwest', 'sand');

0 commit comments

Comments
 (0)