Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
11 changes: 10 additions & 1 deletion src/Core/Parsers/EdmModelBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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!);
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
16 changes: 16 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,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');
Expand Down
Loading