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
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,36 @@ public NpgsqlByteArrayMethodTranslator(ISqlExpressionFactory sqlExpressionFactor
Check.NotNull(method, nameof(method));
Check.NotNull(arguments, nameof(arguments));

// bytea || bytea. Note that we return a byte[] (rather than the method's IEnumerable<byte>) so that further bytea
// operations (ToArray, Length...) can be composed on top.
if (method is { IsGenericMethod: true, Name: nameof(Enumerable.Concat) }
&& method.DeclaringType == typeof(Enumerable)
&& arguments is [var first, var second]
&& (first.TypeMapping ?? second.TypeMapping) is NpgsqlByteArrayTypeMapping concatTypeMapping)
{
if (first.Type != typeof(byte[])
|| second.Type != typeof(byte[])
|| first.TypeMapping is not (null or NpgsqlByteArrayTypeMapping)
|| second.TypeMapping is not (null or NpgsqlByteArrayTypeMapping))
{
throw new InvalidOperationException(
"Concatenating bytea values is only supported for byte[] operands mapped to bytea.");
}

return _sqlExpressionFactory.Add(
_sqlExpressionFactory.ApplyTypeMapping(first, concatTypeMapping),
_sqlExpressionFactory.ApplyTypeMapping(second, concatTypeMapping),
concatTypeMapping);
}

if (method.IsGenericMethod && arguments[0].TypeMapping is NpgsqlByteArrayTypeMapping typeMapping)
{
// ToArray over bytea (e.g. after Concat) is a no-op
if (method is { Name: nameof(Enumerable.ToArray) } && method.DeclaringType == typeof(Enumerable))
{
return arguments[0];
}

// Note: we only translate if the array argument is a column mapped to bytea. There are various other
// cases (e.g. Where(b => new byte[] { 1, 2, 3 }.Contains(b.SomeByte))) where we prefer to translate via
// regular PostgreSQL array logic.
Expand Down
3 changes: 2 additions & 1 deletion src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ protected override string GetOperator(SqlBinaryExpression e)
{
// PostgreSQL has a special string concatenation operator: ||
// We switch to it if the expression itself has type string, or if one of the sides has a string type mapping.
// Same for full-text search's TsVector, arrays.
// Same for full-text search's TsVector, arrays and bytea.
ExpressionType.Add when
e.Type == typeof(string)
|| e.Left.TypeMapping?.ClrType == typeof(string)
Expand All @@ -127,6 +127,7 @@ ExpressionType.Add when
|| e.Left.TypeMapping?.ClrType == typeof(NpgsqlTsVector)
|| e.Right.TypeMapping?.ClrType == typeof(NpgsqlTsVector)
|| e.Left.TypeMapping is NpgsqlArrayTypeMapping && e.Right.TypeMapping is NpgsqlArrayTypeMapping
|| e.Left.TypeMapping is NpgsqlByteArrayTypeMapping && e.Right.TypeMapping is NpgsqlByteArrayTypeMapping
=> " || ",

ExpressionType.And when e.Type == typeof(bool) => " AND ",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,18 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
{
var method = methodCallExpression.Method;

// EF turns inline Concat operands into query roots, but bytea concatenation needs a scalar byte[] constant.
if (method is { IsGenericMethod: true, Name: nameof(Enumerable.Concat) }
&& method.DeclaringType == typeof(Enumerable)
&& methodCallExpression.Arguments is [var source, InlineQueryRootExpression inlineArray]
&& inlineArray.ElementType == typeof(byte)
&& inlineArray.Values.All(v => v is ConstantExpression)
&& Visit(source) is SqlExpression { TypeMapping: NpgsqlByteArrayTypeMapping })
{
return base.VisitMethodCall(
methodCallExpression.Update(null, [source, Expression.NewArrayInit(typeof(byte), inlineArray.Values)]));
}

// Pattern-match: cube.LowerLeft[index] or cube.UpperRight[index]
// This appears as: get_Item method call on a MemberExpression of LowerLeft/UpperRight.
// We match this before calling base.VisitMethodCall, because we throw an informative InvalidOperationException when
Expand Down
10 changes: 10 additions & 0 deletions test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,16 @@ public virtual async Task All_Contains()

#region Other translations

[ConditionalFact]
public virtual async Task Concat_bytea_with_smallint_array()
{
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => AssertQuery(
ss => ss.Set<ArrayEntity>().Where(e => e.Bytea.Concat(e.ByteArray).ToArray().Length == 6)));

Assert.Equal("Concatenating bytea values is only supported for byte[] operands mapped to bytea.", exception.Message);
AssertSql();
}

[ConditionalFact]
public virtual async Task Append()
// TODO: https://github.com/dotnet/efcore/issues/30669
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,142 @@ WHERE length(b."ByteArray") > 0
""");
}

[ConditionalFact]
public virtual async Task Concat_with_parameter()
{
var suffix = new byte[] { 1, 2 };

await AssertQuery(ss => ss.Set<BasicTypesEntity>().Where(e => e.ByteArray.Concat(suffix).ToArray().Length == 6));

AssertSql(
"""
@suffix='0x0102'

SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan"
FROM "BasicTypesEntities" AS b
WHERE length(b."ByteArray" || @suffix) = 6
""");
}

[ConditionalFact]
public virtual async Task Concat_with_constant()
{
await AssertQuery(ss => ss.Set<BasicTypesEntity>()
.Where(e => e.ByteArray.Concat(new byte[] { 1, 2 }).ToArray().Length == 6));

AssertSql(
"""
SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan"
FROM "BasicTypesEntities" AS b
WHERE length(b."ByteArray" || BYTEA E'\\x0102') = 6
""");
}

[ConditionalFact]
public virtual async Task Concat_with_constant_in_projection()
{
await AssertQuery(
ss => ss.Set<BasicTypesEntity>().OrderBy(e => e.Id).Select(e => e.ByteArray.Concat(new byte[] { 1, 2 }).ToArray()),
assertOrder: true);

AssertSql(
"""
SELECT b."ByteArray" || BYTEA E'\\x0102'
FROM "BasicTypesEntities" AS b
ORDER BY b."Id" NULLS FIRST
""");
}

[ConditionalFact]
public virtual async Task Concat_with_empty_array()
{
await AssertQuery(
ss => ss.Set<BasicTypesEntity>().OrderBy(e => e.Id).Select(e => e.ByteArray.Concat(Array.Empty<byte>()).ToArray()),
assertOrder: true);

AssertSql(
"""
SELECT b."ByteArray" || BYTEA E'\\x'
FROM "BasicTypesEntities" AS b
ORDER BY b."Id" NULLS FIRST
""");
}

[ConditionalFact]
public virtual async Task Concat_with_list_parameter()
{
List<byte> suffix = [1, 2];

var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => AssertQuery(
ss => ss.Set<BasicTypesEntity>().Where(e => e.ByteArray.Concat(suffix).ToArray().Length == 6)));

Assert.Equal("Concatenating bytea values is only supported for byte[] operands mapped to bytea.", exception.Message);
AssertSql();
}

[ConditionalFact]
public virtual async Task Concat_with_list_parameter_in_projection()
{
List<byte> suffix = [1, 2];

var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => AssertQuery(
ss => ss.Set<BasicTypesEntity>().Select(e => e.ByteArray.Concat(suffix).ToArray())));

Assert.Equal("Concatenating bytea values is only supported for byte[] operands mapped to bytea.", exception.Message);
AssertSql();
}

[ConditionalFact]
public virtual async Task Concat_with_column()
{
await AssertQuery(ss => ss.Set<BasicTypesEntity>().Where(e => e.ByteArray.Concat(e.ByteArray).ToArray().Length == 8));

AssertSql(
"""
SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan"
FROM "BasicTypesEntities" AS b
WHERE length(b."ByteArray" || b."ByteArray") = 8
""");
}

[ConditionalFact]
public virtual async Task Concat_in_projection()
{
var suffix = new byte[] { 1, 2 };

await AssertQuery(
ss => ss.Set<BasicTypesEntity>().OrderBy(e => e.Id).Select(e => e.ByteArray.Concat(suffix).ToArray()),
assertOrder: true);

AssertSql(
"""
@suffix='0x0102'

SELECT b."ByteArray" || @suffix
FROM "BasicTypesEntities" AS b
ORDER BY b."Id" NULLS FIRST
""");
}

[ConditionalFact]
public virtual async Task Concat_SequenceEqual()
{
var suffix = new byte[] { 1, 2 };
var expected = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 1, 2 };

await AssertQuery(ss => ss.Set<BasicTypesEntity>().Where(e => e.ByteArray.Concat(suffix).SequenceEqual(expected)));

AssertSql(
"""
@suffix='0x0102'
@expected='0xDEADBEEF0102'

SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan"
FROM "BasicTypesEntities" AS b
WHERE b."ByteArray" || @suffix = @expected
""");
}

private void AssertSql(params string[] expected)
=> Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
}