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
136 changes: 136 additions & 0 deletions src/DocumentDbTests/Bugs/advanced_sql_query_scalar_reference_types.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Marten.Testing.Harness;
using Npgsql;
using Shouldly;
using Weasel.Postgresql;
using Weasel.Postgresql.Tables;
using Xunit;

namespace DocumentDbTests.Bugs;

public class advanced_sql_query_scalar_reference_types: BugIntegrationContext
{
private readonly byte[] theData = Encoding.UTF8.GetBytes("hello blob");
private readonly IPAddress theUploadedFrom = IPAddress.Parse("203.0.113.42");
private readonly Guid theId = Guid.NewGuid();

private async Task seedBlobTableAsync()
{
StoreOptions(opts =>
{
// Plain table, deliberately not a Marten document, so its bytes live in a
// real `bytea` column instead of base64-in-JSONB. Schema-qualified with the
// test's own schema so BugIntegrationContext's per-test schema drop cleans it up.
var blobTable = new Table(new PostgresqlObjectName(SchemaName, "blob", SchemaUtils.IdentifierUsage.General));
blobTable.AddColumn<Guid>("id").AsPrimaryKey();

// Explicit "bytea" pg type: Weasel's generic AddColumn<byte[]>() maps byte[] to
// a Postgres smallint[] array (element-wise), not bytea.
blobTable.AddColumn("data", "bytea").NotNull();
blobTable.AddColumn<string>("media_type").NotNull();
blobTable.AddColumn<int>("size").NotNull();

// Second reference-type scalar (a real Postgres "inet" column, not bytea) to prove the
// fix isn't byte[]-specific: any reference type with a direct Npgsql scalar mapping
// (byte[], BitArray, JsonDocument, IPAddress, PhysicalAddress, ...) hit the same
// ScalarSelectClause<T> where T : struct crash. Note this does *not* extend to CLR
// arrays like int[]/Guid[] — Npgsql composes those from their element type rather than
// registering a scalar type mapping, so HasTypeMapping(typeof(int[])) is false and they
// never reach this code path at all.
blobTable.AddColumn<IPAddress>("uploaded_from").NotNull();

opts.Storage.ExtendedSchemaObjects.Add(blobTable);
});

await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync();

await using var conn = new NpgsqlConnection(ConnectionSource.ConnectionString);
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"insert into {SchemaName}.blob (id, data, media_type, size, uploaded_from) values (@id, @data, @media_type, @size, @uploaded_from)";
cmd.Parameters.AddWithValue("id", theId);
cmd.Parameters.AddWithValue("data", theData);
cmd.Parameters.AddWithValue("media_type", "text/plain");
cmd.Parameters.AddWithValue("size", theData.Length);
cmd.Parameters.AddWithValue("uploaded_from", theUploadedFrom);
await cmd.ExecuteNonQueryAsync();
}

[Fact]
public async Task can_query_single_bytea_scalar()
{
await seedBlobTableAsync();

await using var session = theStore.QuerySession();
var data = (await session.AdvancedSql.QueryAsync<byte[]>(
$"select data from {SchemaName}.blob where id = ?", CancellationToken.None, theId)).Single();

data.ShouldBe(theData);
}

[Fact]
public async Task can_query_single_inet_scalar()
{
await seedBlobTableAsync();

await using var session = theStore.QuerySession();
var uploadedFrom = (await session.AdvancedSql.QueryAsync<IPAddress>(
$"select uploaded_from from {SchemaName}.blob where id = ?", CancellationToken.None, theId)).Single();

uploadedFrom.ShouldBe(theUploadedFrom);
}

[Fact]
public async Task can_query_bytea_column_in_row_tuple()
{
await seedBlobTableAsync();

await using var session = theStore.QuerySession();
var (id, data, mediaType) = (await session.AdvancedSql.QueryAsync<Guid, byte[], string>(
$"select row(id), row(data), row(media_type) from {SchemaName}.blob where id = ?",
CancellationToken.None, theId)).Single();

id.ShouldBe(theId);
data.ShouldBe(theData);
mediaType.ShouldBe("text/plain");
}

[Fact]
public async Task can_stream_bytea_scalar()
{
await seedBlobTableAsync();

await using var session = theStore.QuerySession();
var asyncEnumerable = session.AdvancedSql.StreamAsync<byte[]>(
$"select data from {SchemaName}.blob where id = ?", CancellationToken.None, theId);

var results = new List<byte[]>();
await foreach (var data in asyncEnumerable)
{
results.Add(data);
}

results.Single().ShouldBe(theData);
}

// UserSuppliedQueryHandler.GetSelectClause (session.QueryAsync<T>("select ...")) had the
// identical struct-constraint bug as AdvancedSqlQueryHandlerBase.GetSelectClause; this
// exercises that separate code path rather than AdvancedSql.
[Fact]
public async Task can_query_single_bytea_scalar_via_raw_sql()
{
await seedBlobTableAsync();

await using var session = theStore.QuerySession();
var data = (await session.QueryAsync<byte[]>(
$"select data from {SchemaName}.blob where id = ?", CancellationToken.None, theId)).Single();

data.ShouldBe(theData);
}
}
7 changes: 6 additions & 1 deletion src/Marten/Linq/QueryHandlers/AdvancedSqlQueryHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,12 @@ protected ISelectClause GetSelectClause<T>(IStorageSession session) where T : no

if (PostgresqlProvider.Instance.HasTypeMapping(typeof(T)))
{
return typeof(ScalarSelectClause<>).CloseAndBuildAs<ISelectClause>(string.Empty, string.Empty, typeof(T));
// Value types can be null via Nullable<T> (ScalarSelectClause<T>); reference types like
// byte[] are already naturally nullable and can't close over Nullable<T>, so they need
// the ScalarClassSelectClause<T> counterpart instead.
return typeof(T).IsValueType
? typeof(ScalarSelectClause<>).CloseAndBuildAs<ISelectClause>(string.Empty, string.Empty, typeof(T))
: typeof(ScalarClassSelectClause<>).CloseAndBuildAs<ISelectClause>(string.Empty, string.Empty, typeof(T));
}

if (typeof(T).GetProperty("Id") == null && typeof(T).GetProperty("id") == null)
Expand Down
7 changes: 6 additions & 1 deletion src/Marten/Linq/QueryHandlers/UserSuppliedQueryHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,12 @@ private ISelectClause GetSelectClause(IStorageSession session)

if (PostgresqlProvider.Instance.HasTypeMapping(typeof(T)))
{
return typeof(ScalarSelectClause<>).CloseAndBuildAs<ISelectClause>(string.Empty, string.Empty, typeof(T));
// Value types can be null via Nullable<T> (ScalarSelectClause<T>); reference types like
// byte[] are already naturally nullable and can't close over Nullable<T>, so they need
// the ScalarClassSelectClause<T> counterpart instead.
return typeof(T).IsValueType
? typeof(ScalarSelectClause<>).CloseAndBuildAs<ISelectClause>(string.Empty, string.Empty, typeof(T))
: typeof(ScalarClassSelectClause<>).CloseAndBuildAs<ISelectClause>(string.Empty, string.Empty, typeof(T));
}


Expand Down
115 changes: 115 additions & 0 deletions src/Marten/Linq/SqlGeneration/ScalarClassSelectClause.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#nullable enable
using System;
using System.Data.Common;
using System.Threading;
using System.Threading.Tasks;
using JasperFx.Core.Reflection;
using Marten.Internal;
using Marten.Linq.Members;
using Marten.Linq.Parsing;
using Marten.Linq.QueryHandlers;
using Marten.Linq.Selectors;
using Weasel.Postgresql;
using Weasel.Postgresql.SqlGeneration;

namespace Marten.Linq.SqlGeneration;

// Reference-type counterpart to ScalarSelectClause<T>. That type is `where T : struct` because it
// also implements ISelector<T?> via System.Nullable<T>, which can't be closed over a reference type
// like byte[]. Reference types are already naturally nullable, so this skips the Nullable<T> wrapper
// entirely rather than trying to unify the two behind one generic (T? on an unconstrained T is only a
// compile-time nullable-reference annotation, not an actual Nullable<T> at runtime).
internal class ScalarClassSelectClause<T>: ISelectClause, IScalarSelectClause, ISelector<T> where T : class
{
public ScalarClassSelectClause(string locator, string from)
{
FromObject = from;
MemberName = locator;
}

public ScalarClassSelectClause(IQueryableMember field, string from)
{
FromObject = from;

MemberName = field.TypedLocator;
}

public string MemberName { get; private set; }

public ISelectClause CloneToOtherTable(string tableName)
{
return new ScalarClassSelectClause<T>(MemberName, tableName);
}

public void ApplyOperator(string op)
{
MemberName = $"{op}({MemberName})";
}

public ISelectClause CloneToDouble()
{
throw new NotSupportedException();
}

public Type SelectedType => typeof(T);

public string FromObject { get; }

public void Apply(ICommandBuilder sql)
{
sql.Append("select ");
sql.Append(MemberName);
sql.Append(" as data from ");
sql.Append(FromObject);
sql.Append(" as d");
}

public string[] SelectFields()
{
return new[] { MemberName };
}

public ISelector BuildSelector(IStorageSession session)
{
return this;
}

public IQueryHandler<TResult> BuildHandler<TResult>(IStorageSession session, ISqlFragment statement,
ISqlFragment currentStatement) where TResult : notnull
{
return LinqQueryParser.BuildHandler<T, TResult>(this, statement);
}

public ISelectClause UseStatistics(QueryStatistics statistics)
{
return new StatsSelectClause<T>(this, statistics);
}

public T Resolve(DbDataReader reader)
{
if (reader.IsDBNull(0))
{
// ISelector<T> declares a non-nullable T, but a null database value is a legitimate
// result for a reference-type scalar (e.g. a nullable bytea column) and callers are
// expected to tolerate it, matching ScalarStringSelectClause's contract for string.
return null!;
}

return reader.GetFieldValue<T>(0);
}

public async Task<T> ResolveAsync(DbDataReader reader, CancellationToken token)
{
if (await reader.IsDBNullAsync(0, token).ConfigureAwait(false))
{
return null!;
}

return await reader.GetFieldValueAsync<T>(0, token).ConfigureAwait(false);
}

public override string ToString()
{
return $"Select {typeof(T).ShortNameInCode()} from {FromObject}";
}
}
Loading