Skip to content
Merged
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: 1 addition & 1 deletion dotnet/src/VectorData/PgVector/PostgresCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ public override async Task DeleteAsync(IEnumerable<TKey> keys, CancellationToken

using var connection = await this._dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
using var command = connection.CreateCommand();
PostgresSqlBuilder.BuildDeleteBatchCommand(command, this._schema, this.Name, this._model.KeyProperty.StorageName, listOfKeys);
PostgresSqlBuilder.BuildDeleteBatchCommand(command, this._schema, this.Name, this._model.KeyProperty, listOfKeys);

await this.RunOperationAsync("DeleteBatch", () => command.ExecuteNonQueryAsync(cancellationToken)).ConfigureAwait(false);
}
Expand Down
24 changes: 18 additions & 6 deletions dotnet/src/VectorData/PgVector/PostgresFilterTranslator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq.Expressions;
using System.Text;
Expand All @@ -29,18 +30,29 @@ protected override void TranslateConstant(object? value, bool isSearchCondition)
{
switch (value)
{
// TODO: This aligns with our mapping of DateTime to PG's timestamp (as opposed to timestamptz) - we probably want to
// change that to timestamptz (aligning with Npgsql and EF). See #10641.
case DateTime dateTime:
this._sql.Append('\'').Append(dateTime.ToString("yyyy-MM-ddTHH:mm:ss.FFFFFF", CultureInfo.InvariantCulture)).Append('\'');
return;
switch (dateTime.Kind)
{
case DateTimeKind.Utc:
this._sql.Append('\'').Append(dateTime.ToString("yyyy-MM-ddTHH:mm:ss.FFFFFFZ", CultureInfo.InvariantCulture)).Append('\'');
return;

case DateTimeKind.Unspecified:
case DateTimeKind.Local:
this._sql.Append('\'').Append(dateTime.ToString("yyyy-MM-ddTHH:mm:ss.FFFFFF", CultureInfo.InvariantCulture)).Append('\'');
return;

default:
throw new UnreachableException();
}

case DateTimeOffset dateTimeOffset:
if (dateTimeOffset.Offset != TimeSpan.Zero)
{
throw new NotSupportedException("DateTimeOffset with non-zero offset is not supported with PostgreSQL");
throw new NotSupportedException("DateTimeOffset with non-zero offset is not supported with PostgreSQL. Use DateTimeOffset.UtcNow or convert to UTC.");
}

this._sql.Append('\'').Append(dateTimeOffset.ToString("yyyy-MM-ddTHH:mm:ss.FFFFFF", CultureInfo.InvariantCulture)).Append("Z'");
this._sql.Append('\'').Append(dateTimeOffset.ToString("yyyy-MM-ddTHH:mm:ss.FFFFFFZ", CultureInfo.InvariantCulture)).Append('\'');
return;

// Array constants (ARRAY[1, 2, 3])
Expand Down
17 changes: 17 additions & 0 deletions dotnet/src/VectorData/PgVector/PostgresModelBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.Extensions.VectorData.ProviderServices;
using Pgvector;

Expand Down Expand Up @@ -92,6 +93,22 @@ internal static bool IsVectorPropertyTypeValidCore(Type type, [NotNullWhen(false
type == typeof(SparseVector);
}

/// <inheritdoc />
protected override void ValidateProperty(PropertyModel propertyModel, VectorStoreCollectionDefinition? definition)
{
base.ValidateProperty(propertyModel, definition);

if (propertyModel.IsTimestampWithoutTimezone())
{
var type = Nullable.GetUnderlyingType(propertyModel.Type) ?? propertyModel.Type;
if (type != typeof(DateTime))
{
throw new NotSupportedException(
$"Property '{propertyModel.ModelName}' has store type 'timestamp' configured, but this is only supported for DateTime properties. The property type is '{propertyModel.Type.Name}'.");
}
}
}

/// <inheritdoc />
protected override Type? ResolveEmbeddingType(
VectorPropertyModel vectorProperty,
Expand Down
69 changes: 69 additions & 0 deletions dotnet/src/VectorData/PgVector/PostgresPropertyExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using Microsoft.Extensions.VectorData;
using Microsoft.Extensions.VectorData.ProviderServices;

Expand All @@ -11,6 +12,9 @@ namespace Microsoft.SemanticKernel.Connectors.PgVector;
public static class PostgresPropertyExtensions
{
private const string FullTextSearchLanguageKey = "Postgres:FullTextSearchLanguage";
private const string StoreTypeKey = "Postgres:StoreType";

#region Full-text search language

/// <summary>
/// Sets the PostgreSQL full-text search language for a data property.
Expand Down Expand Up @@ -50,4 +54,69 @@ internal static string GetFullTextSearchLanguageOrDefault(this DataPropertyModel
=> property.ProviderAnnotations?.TryGetValue(FullTextSearchLanguageKey, out var value) == true && value is string language
? language
: PostgresConstants.DefaultFullTextSearchLanguage;

#endregion Full-text search language

#region Store type

/// <summary>
/// Sets the PostgreSQL store type for a property, overriding the default type mapping.
/// </summary>
/// <param name="property">The property to configure.</param>
/// <param name="storeType">
/// The PostgreSQL type name. Currently, only <c>"timestamp"</c> and <c>"timestamp without time zone"</c>
/// are supported, and only on <see cref="DateTime"/> properties. This causes the property to be stored as
/// PostgreSQL <c>timestamp</c> (without time zone) instead of the default <c>timestamptz</c>.
/// </param>
/// <returns>The same property instance for method chaining.</returns>
/// <remarks>
/// <para>
/// By default, .NET <see cref="DateTime"/> properties are mapped to PostgreSQL <c>timestamptz</c> (timestamp with time zone),
/// which requires UTC values. Use this method to map to <c>timestamp</c> (without time zone) instead, which stores
/// local/unspecified date-time values without time zone information.
/// </para>
/// <para>
/// When using <c>timestamp</c>, <see cref="DateTime"/> values with <see cref="DateTimeKind.Unspecified"/> or
/// <see cref="DateTimeKind.Local"/> kind should be used. Values read back from the database will have
/// <see cref="DateTimeKind.Unspecified"/>.
/// </para>
/// </remarks>
/// <exception cref="NotSupportedException">Thrown if the <paramref name="storeType"/> is not a supported value.</exception>
public static TProperty WithStoreType<TProperty>(this TProperty property, string storeType)
Comment thread
adamsitnik marked this conversation as resolved.
where TProperty : VectorStoreProperty
{
if (!IsTimestampStoreType(storeType))
{
throw new NotSupportedException(
$"Store type '{storeType}' is not supported. Only 'timestamp' and 'timestamp without time zone' are supported.");
}

property.ProviderAnnotations ??= [];
property.ProviderAnnotations[StoreTypeKey] = storeType;
return property;
}

/// <summary>
/// Gets the PostgreSQL store type configured for a property.
/// </summary>
/// <param name="property">The property to read from.</param>
/// <returns>The configured store type, or <see langword="null"/> if not set.</returns>
public static string? GetStoreType(this VectorStoreProperty property)
=> property.ProviderAnnotations?.TryGetValue(StoreTypeKey, out var value) == true
? value as string
: null;

/// <summary>
/// Gets whether the property model has been configured with a <c>timestamp</c> (without time zone) store type.
/// </summary>
internal static bool IsTimestampWithoutTimezone(this PropertyModel property)
=> property.ProviderAnnotations?.TryGetValue(StoreTypeKey, out var value) == true
&& value is string storeType
&& IsTimestampStoreType(storeType);

private static bool IsTimestampStoreType(string storeType)
=> string.Equals(storeType, "timestamp", StringComparison.OrdinalIgnoreCase)
|| string.Equals(storeType, "timestamp without time zone", StringComparison.OrdinalIgnoreCase);

#endregion Store type
}
52 changes: 35 additions & 17 deletions dotnet/src/VectorData/PgVector/PostgresPropertyMapping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ internal static class PostgresPropertyMapping
var value => throw new NotSupportedException($"Mapping for type '{value.GetType().Name}' to a vector is not supported.")
};

public static NpgsqlDbType? GetNpgsqlDbType(Type propertyType) =>
(Nullable.GetUnderlyingType(propertyType) ?? propertyType) switch
/// <summary>
/// Gets the NpgsqlDbType for a property, taking into account any store type annotation.
/// </summary>
internal static NpgsqlDbType? GetNpgsqlDbType(PropertyModel property)
=> (Nullable.GetUnderlyingType(property.Type) ?? property.Type) switch
{
Type t when t == typeof(bool) => NpgsqlDbType.Boolean,
Type t when t == typeof(short) => NpgsqlDbType.Smallint,
Expand All @@ -50,19 +53,21 @@ internal static class PostgresPropertyMapping
Type t when t == typeof(decimal) => NpgsqlDbType.Numeric,
Type t when t == typeof(string) => NpgsqlDbType.Text,
Type t when t == typeof(byte[]) => NpgsqlDbType.Bytea,
Type t when t == typeof(DateTime) => NpgsqlDbType.Timestamp,
Type t when t == typeof(DateTimeOffset) => NpgsqlDbType.TimestampTz,
Type t when t == typeof(Guid) => NpgsqlDbType.Uuid,
Type t when t == typeof(DateTimeOffset) => NpgsqlDbType.TimestampTz,

// DateTime properties map to PG's "timestamp with time zone" (UTC timestamps) by default, aligning with Npgsql/EF/etc.
// Users can explicitly opt into "timestamp without time zone".
Type t when t == typeof(DateTime) && property.IsTimestampWithoutTimezone() => NpgsqlDbType.Timestamp,
Type t when t == typeof(DateTime) => NpgsqlDbType.TimestampTz,

_ => null
};

/// <summary>
/// Maps a .NET type to a PostgreSQL type name.
/// Maps a .NET type to a PostgreSQL type name, taking into account any store type annotation on the property.
/// </summary>
/// <param name="propertyType">The .NET type.</param>
/// <returns>Tuple of the the PostgreSQL type name and whether it can be NULL</returns>
public static (string PgType, bool IsNullable) GetPostgresTypeName(Type propertyType)
internal static (string PgType, bool IsNullable) GetPostgresTypeName(PropertyModel property)
{
static bool TryGetBaseType(Type type, [NotNullWhen(true)] out string? typeName)
{
Expand All @@ -77,7 +82,7 @@ static bool TryGetBaseType(Type type, [NotNullWhen(true)] out string? typeName)
Type t when t == typeof(decimal) => "NUMERIC",
Type t when t == typeof(string) => "TEXT",
Type t when t == typeof(byte[]) => "BYTEA",
Type t when t == typeof(DateTime) => "TIMESTAMP",
Type t when t == typeof(DateTime) => "TIMESTAMPTZ",
Type t when t == typeof(DateTimeOffset) => "TIMESTAMPTZ",
Type t when t == typeof(Guid) => "UUID",
_ => null
Expand All @@ -86,30 +91,43 @@ static bool TryGetBaseType(Type type, [NotNullWhen(true)] out string? typeName)
return typeName is not null;
}

var propertyType = property.Type;

// TODO: Handle NRTs properly via NullabilityInfoContext

(string PgType, bool IsNullable) result;

if (TryGetBaseType(propertyType, out string? pgType))
{
return (pgType, !propertyType.IsValueType);
result = (pgType, !propertyType.IsValueType);
}

// Handle nullable types (e.g. Nullable<int>)
if (Nullable.GetUnderlyingType(propertyType) is Type unwrappedType
else if (Nullable.GetUnderlyingType(propertyType) is Type unwrappedType
&& TryGetBaseType(unwrappedType, out string? underlyingPgType))
{
return (underlyingPgType, true);
result = (underlyingPgType, true);
}

// Handle collections
if ((propertyType.IsArray && TryGetBaseType(propertyType.GetElementType()!, out string? elementPgType))
else if ((propertyType.IsArray && TryGetBaseType(propertyType.GetElementType()!, out string? elementPgType))
|| (propertyType.IsGenericType
&& propertyType.GetGenericTypeDefinition() == typeof(List<>)
&& TryGetBaseType(propertyType.GetGenericArguments()[0], out elementPgType)))
{
return (elementPgType + "[]", true);
result = (elementPgType + "[]", true);
}
else
{
throw new NotSupportedException($"Type {propertyType.Name} is not supported by this store.");
}

if (property.IsTimestampWithoutTimezone())
{
// Replace TIMESTAMPTZ with TIMESTAMP in the PG type name.
// This handles both "TIMESTAMPTZ" and "TIMESTAMPTZ[]" cases.
result = ("TIMESTAMP", result.IsNullable);
}

throw new NotSupportedException($"Type {propertyType.Name} is not supported by this store.");
return result;
}

/// <summary>
Expand Down
29 changes: 22 additions & 7 deletions dotnet/src/VectorData/PgVector/PostgresSqlBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ internal static string BuildCreateTableSql(string schema, string tableName, Coll
createTableCommand.AppendIdentifier(schema).Append('.').AppendIdentifier(tableName).AppendLine(" (");

// Add the key column
var keyStoreType = PostgresPropertyMapping.GetPostgresTypeName(model.KeyProperty.Type).PgType;
var keyStoreType = PostgresPropertyMapping.GetPostgresTypeName(model.KeyProperty).PgType;
createTableCommand.Append(" ").AppendIdentifier(keyName).Append(' ').Append(keyStoreType);
if (model.KeyProperty.IsAutoGenerated)
{
Expand All @@ -90,7 +90,7 @@ internal static string BuildCreateTableSql(string schema, string tableName, Coll
// Add the data columns
foreach (var dataProperty in model.DataProperties)
{
var dataPgTypeInfo = PostgresPropertyMapping.GetPostgresTypeName(dataProperty.Type);
var dataPgTypeInfo = PostgresPropertyMapping.GetPostgresTypeName(dataProperty);
createTableCommand.Append(" ").AppendIdentifier(dataProperty.StorageName).Append(' ').Append(dataPgTypeInfo.PgType);
if (!dataPgTypeInfo.IsNullable)
{
Expand Down Expand Up @@ -224,7 +224,22 @@ internal static bool BuildUpsertCommand<TKey>(
value = PostgresPropertyMapping.MapVectorForStorageModel(value);
}

batchCommand.Parameters.Add(new() { Value = value ?? DBNull.Value });
NpgsqlDbType? npgsqlDbType = null;

if (property.Type == typeof(DateTime))
{
npgsqlDbType = property.IsTimestampWithoutTimezone()
? NpgsqlDbType.Timestamp
// DateTime properties map to PG's "timestamp with time zone" (timestamptz) by default.
// Npgsql would silently send non-UTC DateTimes as 'timestamp' (without timezone), and PG would
// implicitly cast to timestamptz using the session timezone, producing incorrect results.
// Explicitly set NpgsqlDbType.TimestampTz to ensure Npgsql rejects non-UTC DateTimes.
: NpgsqlDbType.TimestampTz;
}

batchCommand.Parameters.Add(npgsqlDbType.HasValue
? new() { Value = value ?? DBNull.Value, NpgsqlDbType = npgsqlDbType.Value }
: new() { Value = value ?? DBNull.Value });
}

batch.BatchCommands.Add(batchCommand);
Expand Down Expand Up @@ -357,7 +372,7 @@ internal static void BuildGetCommand<TKey>(NpgsqlCommand command, string schema,
internal static void BuildGetBatchCommand<TKey>(NpgsqlCommand command, string schema, string tableName, CollectionModel model, List<TKey> keys, bool includeVectors = false)
where TKey : notnull
{
NpgsqlDbType? keyType = PostgresPropertyMapping.GetNpgsqlDbType(model.KeyProperty.Type) ?? throw new UnreachableException($"Unsupported key type {model.KeyProperty.Type.Name}");
NpgsqlDbType? keyType = PostgresPropertyMapping.GetNpgsqlDbType(model.KeyProperty) ?? throw new UnreachableException($"Unsupported key type {model.KeyProperty.Type.Name}");

StringBuilder sql = new();
sql.Append("SELECT ");
Expand Down Expand Up @@ -403,9 +418,9 @@ internal static void BuildDeleteCommand<TKey>(NpgsqlCommand command, string sche
}

/// <inheritdoc />
internal static void BuildDeleteBatchCommand<TKey>(NpgsqlCommand command, string schema, string tableName, string keyColumn, List<TKey> keys)
internal static void BuildDeleteBatchCommand<TKey>(NpgsqlCommand command, string schema, string tableName, KeyPropertyModel keyProperty, List<TKey> keys)
{
NpgsqlDbType? keyType = PostgresPropertyMapping.GetNpgsqlDbType(typeof(TKey)) ?? throw new ArgumentException($"Unsupported key type {typeof(TKey).Name}");
NpgsqlDbType? keyType = PostgresPropertyMapping.GetNpgsqlDbType(keyProperty) ?? throw new ArgumentException($"Unsupported key type {typeof(TKey).Name}");

for (int i = 0; i < keys.Count; i++)
{
Expand All @@ -417,7 +432,7 @@ internal static void BuildDeleteBatchCommand<TKey>(NpgsqlCommand command, string

StringBuilder sql = new();
sql.Append("DELETE FROM ").AppendIdentifier(schema).Append('.').AppendIdentifier(tableName).AppendLine()
.Append("WHERE ").AppendIdentifier(keyColumn).Append(" = ANY($1);");
.Append("WHERE ").AppendIdentifier(keyProperty.StorageName).Append(" = ANY($1);");

command.CommandText = sql.ToString();
Debug.Assert(command.Parameters.Count == 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,23 @@ namespace PgVector.ConformanceTests.TypeTests;
public class PostgresDataTypeTests(PostgresDataTypeTests.Fixture fixture)
: DataTypeTests<Guid, DataTypeTests<Guid>.DefaultRecord>(fixture), IClassFixture<PostgresDataTypeTests.Fixture>
{
// Npgsql maps DateTime to timestamptz by default, and so requires UTC DateTimes (the base test uses Unspecified).
public override Task DateTime()
=> Assert.ThrowsAsync<ArgumentException>(base.DateTime);

public virtual Task DateTime_utc()
=> this.Test<DateTime>(
"DateTime",
System.DateTime.SpecifyKind(new DateTime(2020, 1, 1, 12, 30, 45), DateTimeKind.Utc),
System.DateTime.SpecifyKind(new DateTime(2021, 2, 3, 13, 40, 55), DateTimeKind.Utc),
instantiationExpression: () => System.DateTime.SpecifyKind(new DateTime(2020, 1, 1, 12, 30, 45), DateTimeKind.Utc));

// PostgreSQL does not support representing an offset, so only DateTimeOffsets with offset=0 are supported.
public override Task DateTimeOffset()
=> Assert.ThrowsAsync<ArgumentException>(base.DateTimeOffset);

// PostgreSQL does not support representing an offset, so only DateTimeOffsets with offset=0 are supported.
public virtual Task DateTimeOffset_with_offset_zero()
=> this.Test<DateTimeOffset>(
"DateTimeOffset",
new DateTimeOffset(2020, 1, 1, 12, 30, 45, TimeSpan.FromHours(0)),
Expand Down
Loading
Loading