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 .github/workflows/dotnet-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:
- name: Test
run: |
dotnet test FAnsi.sln -c Release \
--logger "console;verbosity=detailed" \
--logger "console;verbosity=minimal" \
--nologo \
--no-build \
--collect:"XPlat Code Coverage" \
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- **Npgsql 10.0 compatibility**
- Updated Npgsql from 9.0.4 to 10.0.0
- Added DateOnly/TimeOnly support for PostgreSQL date/time type mappings
- Refactored PostgreSqlTypeTranslater to use FrozenDictionary for O(1) type lookups

### Added
- **DateOnly/TimeOnly support across all database implementations**
- Added DateOnly/TimeOnly type support in base TypeTranslater class
- All database backends now recognize and handle .NET 6+ date/time types

### Fixed
- **Type mapping bugs in base TypeTranslater**
- Fixed duplicate type checks: `typeof(short)` and `typeof(int)` appeared twice in conditionals
- Fixed Test_Calendar_Day failure caused by Npgsql 10.0 returning DateOnly instead of DateTime
- Updated test assertions to handle DateOnly/DateTime interoperability

## [3.5.0] - 2025-11-04

### Performance
Expand Down
55 changes: 55 additions & 0 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<Project>
<!--
Dynamically determine all supported non-EOL .NET versions.

Uses SDK-provided properties:
- _MinimumNonEolSupportedNetCoreTargetFramework: Oldest supported .NET (e.g., "net8.0")
- NETCoreAppMaximumVersion: Latest .NET in current SDK (e.g., "10.0")

This automatically adapts as .NET versions go EOL:
- Nov 2024: .NET 8, 9 supported (min=8, max=9)
- Nov 2025: .NET 8, 9, 10 supported (min=8, max=10)
- Nov 2026: .NET 10, 11 supported (min=10, max=11) - .NET 8 & 9 both go EOL
- Nov 2027: .NET 10, 11, 12 supported (min=10, max=12)
- Nov 2028: .NET 11, 12, 13 supported (min=11, max=13) - .NET 10 goes EOL
-->
<PropertyGroup>
<_MinSupportedMajor>$(_MinimumNonEolSupportedNetCoreTargetFramework.Replace('net', '').Replace('.0', ''))</_MinSupportedMajor>
<_MaxSupportedMajor>$(NETCoreAppMaximumVersion.Replace('.0', ''))</_MaxSupportedMajor>
</PropertyGroup>

<!-- .NET 9 SDK (min=8, max=9) -->
<PropertyGroup Condition="'$(_MaxSupportedMajor)' == '9' and '$(_MinSupportedMajor)' == '8'">
<SupportedNonEolTargetFrameworks>net8.0;net9.0</SupportedNonEolTargetFrameworks>
</PropertyGroup>

<!-- .NET 10 SDK variations -->
<PropertyGroup Condition="'$(_MaxSupportedMajor)' == '10' and '$(_MinSupportedMajor)' == '8'">
<SupportedNonEolTargetFrameworks>net8.0;net9.0;net10.0</SupportedNonEolTargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="'$(_MaxSupportedMajor)' == '10' and '$(_MinSupportedMajor)' == '9'">
<SupportedNonEolTargetFrameworks>net9.0;net10.0</SupportedNonEolTargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="'$(_MaxSupportedMajor)' == '10' and '$(_MinSupportedMajor)' == '10'">
<SupportedNonEolTargetFrameworks>net10.0</SupportedNonEolTargetFrameworks>
</PropertyGroup>

<!-- .NET 11 SDK variations (expected Nov 2026) -->
<PropertyGroup Condition="'$(_MaxSupportedMajor)' == '11' and '$(_MinSupportedMajor)' == '10'">
<SupportedNonEolTargetFrameworks>net10.0;net11.0</SupportedNonEolTargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="'$(_MaxSupportedMajor)' == '11' and '$(_MinSupportedMajor)' == '11'">
<SupportedNonEolTargetFrameworks>net11.0</SupportedNonEolTargetFrameworks>
</PropertyGroup>

<!-- .NET 12 SDK variations (expected Nov 2027) -->
<PropertyGroup Condition="'$(_MaxSupportedMajor)' == '12' and '$(_MinSupportedMajor)' == '10'">
<SupportedNonEolTargetFrameworks>net10.0;net11.0;net12.0</SupportedNonEolTargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="'$(_MaxSupportedMajor)' == '12' and '$(_MinSupportedMajor)' == '11'">
<SupportedNonEolTargetFrameworks>net11.0;net12.0</SupportedNonEolTargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="'$(_MaxSupportedMajor)' == '12' and '$(_MinSupportedMajor)' == '12'">
<SupportedNonEolTargetFrameworks>net12.0</SupportedNonEolTargetFrameworks>
</PropertyGroup>
</Project>
1 change: 0 additions & 1 deletion FAnsi.Core/Discovery/DiscoveredDatabaseHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ public DiscoveredTable CreateTable(CreateTableArgs args)
// Guesser defaults to bool when no data, causing PostgreSQL type mismatches
if ((column.Table?.Rows.Count ?? 0) == 0)
{
Console.WriteLine($"DEBUG EmptyTable: Column {column.ColumnName} has {column.Table?.Rows.Count ?? -1} rows, overriding Guesser type {guess.CSharpType} with DataColumn type {column.DataType}");
guess.CSharpType = column.DataType;
}

Expand Down
14 changes: 9 additions & 5 deletions FAnsi.Core/Discovery/TypeTranslation/TypeTranslater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ public string GetSQLDBTypeForCSharpType(DatabaseTypeRequest request)
if (t == typeof(byte))
return GetByteDataType();

if (t == typeof(short) || t == typeof(short) || t == typeof(ushort) || t == typeof(short?) || t == typeof(ushort?))
if (t == typeof(short) || t == typeof(ushort) || t == typeof(short?) || t == typeof(ushort?))
return GetSmallIntDataType();

if (t == typeof(int) || t == typeof(int) || t == typeof(uint) || t == typeof(int?) || t == typeof(uint?))
if (t == typeof(int) || t == typeof(uint) || t == typeof(int?) || t == typeof(uint?))
return GetIntDataType();

if (t == typeof(long) || t == typeof(ulong) || t == typeof(long?) || t == typeof(ulong?))
Expand All @@ -83,9 +83,15 @@ public string GetSQLDBTypeForCSharpType(DatabaseTypeRequest request)
if (t == typeof(DateTime) || t == typeof(DateTime?))
return GetDateDateTimeDataType();

if (t == typeof(DateOnly) || t == typeof(DateOnly?))
return GetDateDateTimeDataType();

if (t == typeof(TimeSpan) || t == typeof(TimeSpan?))
return GetTimeDataType();

if (t == typeof(TimeOnly) || t == typeof(TimeOnly?))
return GetTimeDataType();

if (t == typeof(byte[]))
return GetByteArrayDataType();

Expand Down Expand Up @@ -419,9 +425,7 @@ private static int ParseSizeFromType(ReadOnlySpan<char> typeSpan)
// So: numbersBeforeDecimalPlace = precision - scale, numbersAfterDecimalPlace = scale
var numbersBeforeDecimalPlace = precision - scale;
var numbersAfterDecimalPlace = scale;
var result = new DecimalSize(numbersBeforeDecimalPlace, numbersAfterDecimalPlace);
Console.WriteLine($"DEBUG ParseDecimalSize: decimal({precision},{scale}) → DecimalSize({numbersBeforeDecimalPlace},{numbersAfterDecimalPlace})");
return result;
return new DecimalSize(numbersBeforeDecimalPlace, numbersAfterDecimalPlace);
}

public string TranslateSQLDBType(string sqlType, ITypeTranslater destinationTypeTranslater)
Expand Down
2 changes: 1 addition & 1 deletion FAnsi.Core/FAnsi.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Linq.Async" Version="7.0.0" />
<PackageReference Include="TypeGuesser" Version="2.0.3" />
<PackageReference Include="TypeGuesser" Version="2.0.4" />
</ItemGroup>

<!-- Source generation removed - analyzer DLL references cleaned up -->
Expand Down
2 changes: 1 addition & 1 deletion FAnsi.PostgreSql/FAnsi.PostgreSql.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<ProjectReference Include="..\FAnsi.Core\FAnsi.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="9.0.4" />
<PackageReference Include="Npgsql" Version="10.0.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
Expand Down
86 changes: 43 additions & 43 deletions FAnsi.PostgreSql/PostgreSqlTypeTranslater.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using FAnsi.Discovery.TypeTranslation;
Expand All @@ -10,6 +12,43 @@ public sealed partial class PostgreSqlTypeTranslater : TypeTranslater
{
public static readonly PostgreSqlTypeTranslater Instance = new();

private static readonly FrozenDictionary<Type, NpgsqlDbType> TypeMappings =
new Dictionary<Type, NpgsqlDbType>
{
[typeof(bool)] = NpgsqlDbType.Boolean,
[typeof(bool?)] = NpgsqlDbType.Boolean,
[typeof(byte)] = NpgsqlDbType.Smallint,
[typeof(byte[])] = NpgsqlDbType.Bytea,
[typeof(short)] = NpgsqlDbType.Smallint,
[typeof(short?)] = NpgsqlDbType.Smallint,
[typeof(ushort)] = NpgsqlDbType.Smallint,
[typeof(ushort?)] = NpgsqlDbType.Smallint,
[typeof(int)] = NpgsqlDbType.Integer,
[typeof(int?)] = NpgsqlDbType.Integer,
[typeof(uint)] = NpgsqlDbType.Integer,
[typeof(uint?)] = NpgsqlDbType.Integer,
[typeof(long)] = NpgsqlDbType.Bigint,
[typeof(long?)] = NpgsqlDbType.Bigint,
[typeof(ulong)] = NpgsqlDbType.Bigint,
[typeof(ulong?)] = NpgsqlDbType.Bigint,
[typeof(float)] = NpgsqlDbType.Double,
[typeof(float?)] = NpgsqlDbType.Double,
[typeof(double)] = NpgsqlDbType.Double,
[typeof(double?)] = NpgsqlDbType.Double,
[typeof(decimal)] = NpgsqlDbType.Numeric,
[typeof(decimal?)] = NpgsqlDbType.Numeric,
[typeof(string)] = NpgsqlDbType.Text,
[typeof(DateTime)] = NpgsqlDbType.Timestamp,
[typeof(DateTime?)] = NpgsqlDbType.Timestamp,
[typeof(DateOnly)] = NpgsqlDbType.Date,
[typeof(DateOnly?)] = NpgsqlDbType.Date,
[typeof(TimeSpan)] = NpgsqlDbType.Time,
[typeof(TimeSpan?)] = NpgsqlDbType.Time,
[typeof(TimeOnly)] = NpgsqlDbType.Time,
[typeof(TimeOnly?)] = NpgsqlDbType.Time,
[typeof(Guid)] = NpgsqlDbType.Uuid
}.ToFrozenDictionary();

private PostgreSqlTypeTranslater() : base(DateRegexImpl(), 8000, 4000)
{
TimeRegex = TimeRegexImpl(); //space is important
Expand All @@ -27,49 +66,10 @@ private PostgreSqlTypeTranslater() : base(DateRegexImpl(), 8000, 4000)

protected override string GetByteArrayDataType() => "bytea";

public NpgsqlDbType GetNpgsqlDbTypeForCSharpType(Type t)
{

if (t == typeof(bool) || t == typeof(bool?))
return NpgsqlDbType.Boolean;

if (t == typeof(byte))
return NpgsqlDbType.Smallint;

if (t == typeof(byte[]))
return NpgsqlDbType.Bytea;

if (t == typeof(short) || t == typeof(short) || t == typeof(ushort) || t == typeof(short?) || t == typeof(ushort?))
return NpgsqlDbType.Smallint;

if (t == typeof(int) || t == typeof(int) || t == typeof(uint) || t == typeof(int?) || t == typeof(uint?))
return NpgsqlDbType.Integer;

if (t == typeof(long) || t == typeof(ulong) || t == typeof(long?) || t == typeof(ulong?))
return NpgsqlDbType.Bigint;

if (t == typeof(float) || t == typeof(float?) || t == typeof(double) ||
t == typeof(double?))
return NpgsqlDbType.Double;

if (t == typeof(decimal) || t == typeof(decimal?))
return NpgsqlDbType.Numeric;

if (t == typeof(string))
return NpgsqlDbType.Text;

if (t == typeof(DateTime) || t == typeof(DateTime?))
return NpgsqlDbType.Timestamp;

if (t == typeof(TimeSpan) || t == typeof(TimeSpan?))
return NpgsqlDbType.Time;

if (t == typeof(Guid))
return NpgsqlDbType.Uuid;

throw new TypeNotMappedException(string.Format(CultureInfo.InvariantCulture, FAnsiStrings.TypeTranslater_GetSQLDBTypeForCSharpType_Unsure_what_SQL_type_to_use_for_CSharp_Type___0_____TypeTranslater_was___1__, t.Name, GetType().Name));

}
public NpgsqlDbType GetNpgsqlDbTypeForCSharpType(Type t) =>
TypeMappings.TryGetValue(t, out var npgsqlType)
? npgsqlType
: throw new TypeNotMappedException(string.Format(CultureInfo.InvariantCulture, FAnsiStrings.TypeTranslater_GetSQLDBTypeForCSharpType_Unsure_what_SQL_type_to_use_for_CSharp_Type___0_____TypeTranslater_was___1__, t.Name, GetType().Name));

protected override bool IsByteArray(string sqlType) =>
sqlType?.StartsWith("bytea", StringComparison.OrdinalIgnoreCase) ?? false;
Expand Down
11 changes: 7 additions & 4 deletions FAnsi.Sqlite/SqliteServerHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,23 @@ protected override DbConnectionStringBuilder GetConnectionStringBuilderImpl(stri
/// <summary>
/// Creates a connection string builder with the specified parameters.
/// </summary>
/// <param name="server">The server/file path (used if database is null)</param>
/// <param name="database">The database file path (preferred over server)</param>
/// <param name="server">The server/file path (primary parameter for file path)</param>
/// <param name="database">The database file path (fallback if server is not provided)</param>
/// <param name="username">Username (ignored for SQLite as it has no authentication)</param>
/// <param name="password">Password (ignored for SQLite as it has no authentication)</param>
/// <returns>A configured connection string builder</returns>
/// <remarks>
/// SQLite doesn't use username/password authentication. These parameters are accepted for API compatibility but ignored.
/// <para>SQLite doesn't use username/password authentication. These parameters are accepted for API compatibility but ignored.</para>
/// <para>For SQLite, server and database are the same concept (file path). We prefer the server parameter to maintain
/// consistency with the Name property, which uses ServerKeyName ("Data Source").</para>
/// </remarks>
protected override DbConnectionStringBuilder GetConnectionStringBuilderImpl(string server, string? database, string username, string password)
{
var builder = new SqliteConnectionStringBuilder();

// For SQLite, server and database are the same (file path)
var dataSource = database ?? server;
// Use server as primary, fall back to database for backwards compatibility
var dataSource = !string.IsNullOrWhiteSpace(server) ? server : database;
builder.DataSource = dataSource;

// SQLite doesn't use username/password authentication, but we accept them
Expand Down
29 changes: 29 additions & 0 deletions Tests/FAnsiTests/Aggregation/AggregationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,34 @@ private static bool IsMatch(DataRow r, object?[] cells)

//could be dealing with int / long mismatch etc
if (aType != bType)
{
// Handle DateOnly/DateTime interoperability (Npgsql 10.0 maps PostgreSQL date to DateOnly)
if (a is DateOnly dateOnly && b is DateTime dateTime)
{
if (DateOnly.FromDateTime(dateTime) != dateOnly)
return false;
continue;
}
if (a is DateTime dt && b is DateOnly dOnly)
{
if (DateOnly.FromDateTime(dt) != dOnly)
return false;
continue;
}

// Handle SQLite string dates (SQLite stores dates as TEXT)
// Expected: DateTime, Actual: String like "2001-01-01 00:00:00"
if (a is string str && b is DateTime expectedDt)
{
if (DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDt))
{
if (parsedDt != expectedDt)
return false;
continue;
}
return false; // String couldn't be parsed as DateTime
}

try
{
b = Convert.ChangeType(b, aType, CultureInfo.InvariantCulture);
Expand All @@ -103,6 +131,7 @@ private static bool IsMatch(DataRow r, object?[] cells)
//they are not a match because they are not the same type and cannot be converted
return false;
}
}

if (!a.Equals(b))
return false;
Expand Down
5 changes: 1 addition & 4 deletions Tests/FAnsiTests/Aggregation/CalendarAggregationTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,6 @@ protected void Test_Calendar_Month(DatabaseType type)
}
protected void Test_Calendar_Day(DatabaseType type)
{
if (type == DatabaseType.Sqlite)
Assert.Ignore("SQLite stores DateTime as TEXT; calendar functions work differently");

var tbl = GetTestTable(type);
var svr = tbl.Database.Server;
var col = tbl.DiscoverColumn("EventDate");
Expand Down Expand Up @@ -193,7 +190,7 @@ protected void Test_Calendar_Day(DatabaseType type)
using var dt = new DataTable();
da.Fill(dt);

Assert.That(dt.Rows, Has.Count.EqualTo(3288)); // 109 months between 2001 and 2010 (inclusive)
Assert.That(dt.Rows, Has.Count.EqualTo(3288)); // 3288 days between 2001-01-01 and 2010-01-01 (inclusive)

AssertHasRow(dt, new DateTime(2001, 1, 1), 4);
AssertHasRow(dt, new DateTime(2001, 1, 2), 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ internal sealed class CalendarAggregationTests_Sqlite : CalendarAggregationTests
public void Test_Calendar_Month() => Test_Calendar_Month(DbType);

[Test]
[Ignore("SQLite stores DateTime as TEXT; calendar functions work differently")]
public void Test_Calendar_Day() => Test_Calendar_Day(DbType);

[Test]
Expand Down
Loading
Loading