Skip to content

Commit 56f0fc2

Browse files
bilby91claudeaaronburtleRubenCerna2079
authored
Add read-only support for PostgreSQL array columns (#3402)
## Summary - Add read-only support for PostgreSQL array columns (`int[]`, `text[]`, `boolean[]`, `bigint[]`, etc.) which previously caused initialization failures - Array columns are exposed as GraphQL list types (`[Int]`, `[String]`, etc.) and returned as JSON arrays in REST/MCP responses - Array columns are marked read-only and excluded from create/update mutation inputs until write support is implemented ## Changes **Core plumbing (database-agnostic):** - `ColumnDefinition`: new `IsArrayType` and `ElementSystemType` properties - `SqlMetadataProvider`: detect array types (`System.Array`) during schema introspection - `SchemaConverter`: unwrap array element types and generate `ListTypeNode` for GraphQL fields - `EdmModelBuilder`: represent array columns as `EdmCollectionTypeReference` in OData model - `TypeHelper`: fallback handling for unresolved `System.Array` type **PostgreSQL-specific:** - `PostgreSqlMetadataProvider`: override `PopulateColumnDefinitionWithHasDefaultAndDbType` to resolve element types from `information_schema` `udt_name` (`_int4`→`int`, `_text`→`string`, etc.) **Tests:** - 14 new unit tests (array→ListTypeNode generation, nullability, AutoGenerated directive, byte[] exclusion) - 3 new PostgreSQL e2e tests (query by PK, null arrays, multi-row queries) - Test schema and config for `array_type_table` entity ## Limitations - **Read-only**: mutations on array columns are blocked (marked as auto-generated/read-only) - **No OData `$filter`**: filtering on array columns is not supported (no OData array operators) - **PostgreSQL only**: other databases don't have native array types; generic plumbing is in place for future use - **1D arrays only**: multi-dimensional arrays are not tested/supported ## Test plan - [x] Unit tests pass (1325/1325, 0 failures) - [x] PostgreSQL e2e tests pass (3/3 array tests, 711/711 non-preexisting PG tests) - [x] CI pipeline validation (PostgreSQL Docker) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Co-authored-by: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com>
1 parent 5b538c6 commit 56f0fc2

15 files changed

Lines changed: 831 additions & 5 deletions

File tree

config-generators/postgresql-commands.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ update series --config "dab-config.PostgreSql.json" --permissions "TestNestedFil
171171
update series --config "dab-config.PostgreSql.json" --permissions "TestNestedFilterOneMany_ColumnForbidden:read"
172172
update series --config "dab-config.PostgreSql.json" --permissions "TestNestedFilterOneMany_EntityReadForbidden:read"
173173
update DefaultBuiltInFunction --config "dab-config.PostgreSql.json" --permissions "anonymous:create" --fields.exclude "current_date,next_date"
174+
add ArrayType --config "dab-config.PostgreSql.json" --source "array_type_table" --permissions "anonymous:read" --rest true --graphql "arrayType:arrayTypes"
175+
update ArrayType --config "dab-config.PostgreSql.json" --permissions "authenticated:read"
174176
add dbo_DimAccount --config "dab-config.PostgreSql.json" --source "dimaccount" --permissions "anonymous:*" --rest true --graphql true
175177
update dbo_DimAccount --config "dab-config.PostgreSql.json" --map "parentaccountkey:ParentAccountKey,accountkey:AccountKey"
176178
update dbo_DimAccount --config "dab-config.PostgreSql.json" --relationship parent_account --target.entity dbo_DimAccount --cardinality one --relationship.fields "parentaccountkey:accountkey"

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: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,20 +111,30 @@ 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

118119
// The mapped (aliased) field name defined in the runtime config is used to create a representative
119120
// OData StructuralProperty. The created property is then added to the EdmEntityType.
120121
// StructuralProperty objects representing database primary keys are added as a 'keyProperties' to the EdmEntityType.
122+
// Array columns are represented as collection-typed StructuralProperties (e.g., Collection(Edm.Int32) for int[]).
121123
// Otherwise, the StructuralProperty object is added as a generic StructuralProperty of the EdmEntityType.
122124
string exposedColumnName;
123125
if (sourceDefinition.PrimaryKey.Contains(column))
124126
{
125127
sqlMetadataProvider.TryGetExposedColumnName(entityAndDbObject.Key, column, out exposedColumnName!);
126128
newEntity.AddKeys(newEntity.AddStructuralProperty(name: exposedColumnName, type, isNullable: false));
127129
}
130+
else if (columnDef.IsArrayType)
131+
{
132+
// Array columns are represented as EDM collection types (e.g., Collection(Edm.Int32) for int[]).
133+
sqlMetadataProvider.TryGetExposedColumnName(entityAndDbObject.Key, column, out exposedColumnName!);
134+
EdmPrimitiveTypeReference elementTypeRef = new(EdmCoreModel.Instance.GetPrimitiveType(type), isNullable: true);
135+
EdmCollectionTypeReference collectionTypeRef = new(new EdmCollectionType(elementTypeRef));
136+
newEntity.AddStructuralProperty(name: exposedColumnName, collectionTypeRef);
137+
}
128138
else
129139
{
130140
sqlMetadataProvider.TryGetExposedColumnName(entityAndDbObject.Key, column, out exposedColumnName!);

src/Core/Services/MetadataProviders/PostgreSqlMetadataProvider.cs

Lines changed: 71 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,74 @@ 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+
["_json"] = typeof(string),
102+
["_jsonb"] = typeof(string),
103+
["_money"] = typeof(decimal),
104+
};
105+
106+
/// <summary>
107+
/// Override to detect PostgreSQL array columns using information_schema metadata.
108+
/// Npgsql's DataAdapter reports array columns as System.Array (the abstract base class),
109+
/// so we use the data_type and udt_name from information_schema.columns to identify arrays
110+
/// and resolve their element types.
111+
/// </summary>
112+
protected override void PopulateColumnDefinitionWithHasDefaultAndDbType(
113+
SourceDefinition sourceDefinition,
114+
DataTable allColumnsInTable)
115+
{
116+
foreach (DataRow columnInfo in allColumnsInTable.Rows)
117+
{
118+
string columnName = (string)columnInfo["COLUMN_NAME"];
119+
bool hasDefault =
120+
Type.GetTypeCode(columnInfo["COLUMN_DEFAULT"].GetType()) != TypeCode.DBNull;
121+
122+
if (sourceDefinition.Columns.TryGetValue(columnName, out ColumnDefinition? columnDefinition))
123+
{
124+
columnDefinition.HasDefault = hasDefault;
125+
126+
if (hasDefault)
127+
{
128+
columnDefinition.DefaultValue = columnInfo["COLUMN_DEFAULT"];
129+
}
130+
131+
// Detect array columns: data_type is "ARRAY" in information_schema for PostgreSQL array types.
132+
string dataType = columnInfo["DATA_TYPE"] is string dt ? dt : string.Empty;
133+
if (string.Equals(dataType, "ARRAY", StringComparison.OrdinalIgnoreCase))
134+
{
135+
string udtName = columnInfo["UDT_NAME"] is string udt ? udt : string.Empty;
136+
if (_pgArrayUdtToElementType.TryGetValue(udtName, out Type? elementType))
137+
{
138+
columnDefinition.IsArrayType = true;
139+
columnDefinition.ElementSystemType = elementType;
140+
columnDefinition.SystemType = elementType.MakeArrayType();
141+
columnDefinition.IsReadOnly = true;
142+
}
143+
}
144+
145+
columnDefinition.DbType = TypeHelper.GetDbTypeFromSystemType(columnDefinition.SystemType);
146+
}
147+
}
148+
}
78149
}
79150
}

src/Core/Services/MetadataProviders/SqlMetadataProvider.cs

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

1486+
Type systemType = (Type)columnInfoFromAdapter["DataType"];
1487+
1488+
// Detect array types: concrete array types (e.g., int[]) have IsArray=true,
1489+
// while Npgsql reports abstract System.Array for PostgreSQL array columns.
1490+
// byte[] is excluded since it maps to the bytea/ByteArray scalar type.
1491+
bool isArrayType = (systemType.IsArray && systemType != typeof(byte[])) || systemType == typeof(Array);
1492+
14861493
ColumnDefinition column = new()
14871494
{
14881495
IsNullable = (bool)columnInfoFromAdapter["AllowDBNull"],
14891496
IsAutoGenerated = (bool)columnInfoFromAdapter["IsAutoIncrement"],
1490-
SystemType = (Type)columnInfoFromAdapter["DataType"],
1497+
SystemType = systemType,
1498+
IsArrayType = isArrayType,
1499+
ElementSystemType = isArrayType && systemType.IsArray ? systemType.GetElementType() : null,
14911500
// An auto-increment column is also considered as a read-only column. For other types of read-only columns,
14921501
// the flag is populated later via PopulateColumnDefinitionsWithReadOnlyFlag() method.
1493-
IsReadOnly = (bool)columnInfoFromAdapter["IsAutoIncrement"]
1502+
// Array columns are also treated as read-only until write support for array types is implemented.
1503+
IsReadOnly = (bool)columnInfoFromAdapter["IsAutoIncrement"] || isArrayType
14941504
};
14951505

14961506
// 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/Queries/InputTypeBuilder.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ private static List<InputValueDefinitionNode> GenerateOrderByInputFieldsForBuilt
5050
List<InputValueDefinitionNode> inputFields = new();
5151
foreach (FieldDefinitionNode field in node.Fields)
5252
{
53+
// Skip scalar array fields (e.g., PostgreSQL int[], text[]) - they cannot be ordered.
54+
// Non-scalar list types (e.g., Cosmos nested object arrays) are not skipped
55+
// because they are handled as relationship navigations.
56+
if (field.Type.IsListType() && IsBuiltInType(field.Type))
57+
{
58+
continue;
59+
}
60+
5361
if (IsBuiltInType(field.Type))
5462
{
5563
inputFields.Add(
@@ -110,6 +118,17 @@ private static List<InputValueDefinitionNode> GenerateFilterInputFieldsForBuiltI
110118
List<InputValueDefinitionNode> inputFields = new();
111119
foreach (FieldDefinitionNode field in objectTypeDefinitionNode.Fields)
112120
{
121+
// Skip auto-generated list fields (e.g., PostgreSQL int[], text[] array columns)
122+
// which are read-only and cannot be filtered. Cosmos scalar arrays like
123+
// tags: [String] do NOT have @autoGenerated and remain filterable
124+
// (using ARRAY_CONTAINS).
125+
if (field.Type.IsListType()
126+
&& IsBuiltInType(field.Type)
127+
&& field.Directives.Any(d => d.Name.Value == AutoGeneratedDirectiveType.DirectiveName))
128+
{
129+
continue;
130+
}
131+
113132
string fieldTypeName = field.Type.NamedType().Name.Value;
114133
if (IsBuiltInType(field.Type))
115134
{

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: 17 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,17 @@ 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+
json_array_col json[],
177+
jsonb_array_col jsonb[],
178+
money_array_col money[]
179+
);
180+
169181
CREATE TABLE trees (
170182
"treeId" int PRIMARY KEY,
171183
species text,
@@ -412,6 +424,11 @@ INSERT INTO type_table(id, short_types, int_types, long_types, string_types, sin
412424
(4, 32767, 2147483647, 9223372036854775807, 'null', 3.4E38, 1.7E308, 2.929292E-14, true, '9999-12-31 23:59:59.997', '\xFFFFFFFF'),
413425
(5, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
414426
INSERT INTO type_table(id, uuid_types) values(10, 'D1D021A8-47B4-4AE4-B718-98E89C41A161');
427+
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
428+
(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}'),
429+
(2, '{10,20}', '{foo,bar,baz}', '{true,true}', '{999}', ARRAY['{"id":1}'::json], ARRAY['{"id":1}'::jsonb], '{5.00,15.00}'),
430+
(3, NULL, NULL, NULL, NULL, NULL, NULL, NULL),
431+
(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}');
415432
INSERT INTO trees("treeId", species, region, height) VALUES (1, 'Tsuga terophylla', 'Pacific Northwest', '30m'), (2, 'Pseudotsuga menziesii', 'Pacific Northwest', '40m');
416433
INSERT INTO trees("treeId", species, region, height) VALUES (4, 'test', 'Pacific Northwest', '0m');
417434
INSERT INTO fungi(speciesid, region, habitat) VALUES (1, 'northeast', 'forest'), (2, 'southwest', 'sand');

0 commit comments

Comments
 (0)