Skip to content
Draft
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
12 changes: 12 additions & 0 deletions src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2029,6 +2029,18 @@ public void SetLimit(SqlExpression sqlExpression)
}
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
[EntityFrameworkInternal]
public void SetOffset(SqlExpression? sqlExpression)
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding SelectExpression.SetOffset(...) introduces a new (public) API surface area (even with [EntityFrameworkInternal]). The repo tracks public API in EFCore.Relational.baseline.json, so this PR likely needs the corresponding baseline update to keep API validation passing.

Suggested change
public void SetOffset(SqlExpression? sqlExpression)
internal void SetOffset(SqlExpression? sqlExpression)

Copilot uses AI. Check for mistakes.
{
Offset = sqlExpression;
}

/// <summary>
/// Applies offset to the <see cref="SelectExpression" /> to skip the number of rows in the result set.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ public static class SqlServerQueryableExtensions
/// </param>
/// <remarks>
/// <para>
/// Compose the returned query with <c>OrderBy(r => r.Distance)</c> and <c>Take(...)</c> to limit the results as required
/// for approximate vector search.
/// Results are implicitly ordered by distance ascending. Compose the returned query with <c>Take(...)</c> to limit the
/// number of results as required for approximate vector search.
/// </para>
/// </remarks>
/// <seealso href="https://learn.microsoft.com/sql/t-sql/functions/vector-search-transact-sql">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,18 @@ var metric
var valueProjectionMember = new ProjectionMember().Append(resultType.GetProperty(nameof(VectorSearchResult<>.Value))!);
var distanceProjectionMember = new ProjectionMember().Append(resultType.GetProperty(nameof(VectorSearchResult<>.Distance))!);

var distanceColumn = new ColumnExpression("Distance", vectorSearchFunction.Alias, typeof(double), _typeMappingSource.FindMapping(typeof(double)), nullable: false);

select.ReplaceProjection(new Dictionary<ProjectionMember, Expression>
{
[valueProjectionMember] = entityProjection,
[distanceProjectionMember] = new ColumnExpression("Distance", vectorSearchFunction.Alias, typeof(double), _typeMappingSource.FindMapping(typeof(double)), nullable: false)
[distanceProjectionMember] = distanceColumn
});

// VECTOR_SEARCH() results are implicitly ordered by distance ascending; add this ordering so that
// users can compose with Take() without needing an explicit OrderBy(r => r.Distance).
select.AppendOrdering(new OrderingExpression(distanceColumn, ascending: true));

var shaper = Expression.New(
resultType.GetConstructors().Single(),
arguments:
Expand Down Expand Up @@ -795,29 +801,92 @@ bool TryTranslate(
}
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
protected override ShapedQueryExpression? TranslateTake(ShapedQueryExpression source, Expression count)
{
var selectExpression = (SelectExpression)source.QueryExpression;

// When VECTOR_SEARCH() is present and an Offset has already been applied (i.e. Skip().Take()), we need to ensure that the
// generated SQL uses TOP WITH APPROXIMATE in a subquery, rather than the default OFFSET...FETCH which doesn't use the
// vector index. We push down a subquery with TOP(Skip + Take) WITH APPROXIMATE, and apply OFFSET...FETCH on the outer query.
if (selectExpression is { Offset: { } existingOffset }
&& selectExpression.Tables.Any(t => t.UnwrapJoin() is TableValuedFunctionExpression { Name: "VECTOR_SEARCH" }))
{
var translation = TranslateExpression(count);
if (translation == null)
{
return null;
}

var combinedLimit = _sqlExpressionFactory.Add(existingOffset, translation);

#pragma warning disable EF1001 // Internal EF Core API usage.
// Clear the offset so the inner subquery uses TOP(M+N) instead of OFFSET...FETCH
selectExpression.SetOffset(null);
Comment on lines +826 to +830
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TranslateTake's VECTOR_SEARCH Skip/Take rewrite doesn't preserve multiple-Take semantics: if the query already has Limit (e.g. Skip(1).Take(5).Take(10)), this branch overwrites it and can return too many rows. Consider first applying the normal Take translation (so Limit becomes min(oldLimit, newLimit)), then use that effective Limit for both the inner TOP(existingOffset + effectiveLimit) and the outer FETCH, rather than using the new translation directly.

Copilot uses AI. Check for mistakes.
selectExpression.SetLimit(combinedLimit);

// Push down: inner gets TOP(Skip+Take) WITH APPROXIMATE, outer is clean
selectExpression.PushdownIntoSubquery();

// Apply the original offset and take on the outer query as OFFSET...FETCH
selectExpression.ApplyOffset(existingOffset);
selectExpression.SetLimit(translation);
#pragma warning restore EF1001 // Internal EF Core API usage.

return source;
}

return base.TranslateTake(source, count);
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
protected override bool IsNaturallyOrdered(SelectExpression selectExpression)
=> selectExpression is
=> selectExpression switch
{
Tables: [SqlServerOpenJsonExpression openJsonExpression, ..],
Orderings:
[
{
Expression: SqlUnaryExpression
// OPENJSON rows are naturally ordered by their "key" column (array index), ascending.
// EF adds this ordering implicitly when expanding JSON arrays; treat it as natural to avoid spurious
// "Distinct after OrderBy without row limiting operator" warnings.
{
Tables: [SqlServerOpenJsonExpression openJsonExpression, ..],
Orderings:
[
{
OperatorType: ExpressionType.Convert,
Operand: ColumnExpression { Name: "key", TableAlias: var orderingTableAlias }
},
IsAscending: true
}
]
}
&& orderingTableAlias == openJsonExpression.Alias;
Expression: SqlUnaryExpression
{
OperatorType: ExpressionType.Convert,
Operand: ColumnExpression { Name: "key", TableAlias: var openJsonOrderingTableAlias }
},
IsAscending: true
}
]
} when openJsonOrderingTableAlias == openJsonExpression.Alias => true,

// VECTOR_SEARCH() results are naturally ordered by Distance ascending.
// EF adds this ordering implicitly during VectorSearch translation; treat it as natural to avoid spurious
// "Distinct after OrderBy without row limiting operator" warnings.
{
Tables: [TableValuedFunctionExpression { Name: "VECTOR_SEARCH" } vectorSearchFunction, ..],
Orderings:
[
{
Expression: ColumnExpression { Name: "Distance", TableAlias: var vectorSearchOrderingTableAlias },
IsAscending: true
}
]
} when vectorSearchOrderingTableAlias == vectorSearchFunction.Alias => true,

_ => false
};

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ public async Task VectorSearch_project_entity_and_distance()

var results = await ctx.VectorEntities
.VectorSearch(e => e.Vector, similarTo: vector, "cosine")
.OrderBy(e => e.Distance)
.Take(1)
.ToListAsync();

Expand All @@ -103,7 +102,7 @@ ORDER BY [v0].[Distance]
[ConditionalFact]
[SqlServerCondition(SqlServerCondition.IsAzureSql)]
[Experimental("EF9105")]
public async Task VectorSearch_project_entity_only_with_distance_filter_and_ordering()
public async Task VectorSearch_project_entity_only_with_distance_filter()
{
using var ctx = CreateContext();

Expand All @@ -112,7 +111,6 @@ public async Task VectorSearch_project_entity_only_with_distance_filter_and_orde
var results = await ctx.VectorEntities
.VectorSearch(e => e.Vector, similarTo: vector, "cosine")
.Where(e => e.Distance < 0.01)
.OrderBy(e => e.Distance)
.Select(e => e.Value)
.Take(3)
.ToListAsync();
Expand Down Expand Up @@ -151,7 +149,6 @@ public async Task VectorSearch_in_subquery()

var results = await ctx.VectorEntities
.VectorSearch(e => e.Vector, similarTo: vector, "cosine")
.OrderBy(e => e.Distance)
.Take(3)
.Select(e => new { e.Value.Id, e.Distance })
.Where(e => e.Distance < 0.01)
Expand Down Expand Up @@ -183,6 +180,86 @@ ORDER BY [v1].[Distance]
""");
}

// The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384).
[ConditionalFact]
[SqlServerCondition(SqlServerCondition.IsAzureSql)]
[Experimental("EF9105")]
public async Task VectorSearch_with_Skip_and_Take()
{
using var ctx = CreateContext();

var vector = new SqlVector<float>(new float[] { 1, 2, 100 });

var results = await ctx.VectorEntities
.VectorSearch(e => e.Vector, similarTo: vector, "cosine")
.Skip(1)
.Take(2)
.ToListAsync();

Assert.Equal(2, results.Count);

AssertSql(
"""
@p1='1'
@p2='2'
@p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary)

SELECT [v1].[Id], [v1].[Distance]
FROM (
SELECT TOP(@p1 + @p2) WITH APPROXIMATE [v].[Id], [v0].[Distance]
FROM VECTOR_SEARCH(
TABLE = [VectorEntities] AS [v],
COLUMN = [Vector],
SIMILAR_TO = @p,
METRIC = 'cosine'
) AS [v0]
ORDER BY [v0].[Distance]
) AS [v1]
ORDER BY [v1].[Distance]
OFFSET @p1 ROWS FETCH NEXT @p2 ROWS ONLY
""");
}

// The latest vector index version (required for VECTOR_SEARCH) is only available on Azure SQL (#36384).
[ConditionalFact]
[SqlServerCondition(SqlServerCondition.IsAzureSql)]
[Experimental("EF9105")]
public async Task VectorSearch_with_Take_and_Skip()
{
using var ctx = CreateContext();

var vector = new SqlVector<float>(new float[] { 1, 2, 100 });

var results = await ctx.VectorEntities
.VectorSearch(e => e.Vector, similarTo: vector, "cosine")
.Take(3)
.Skip(1)
.ToListAsync();

Assert.Equal(2, results.Count);

AssertSql(
"""
@p1='3'
@p='Microsoft.Data.SqlTypes.SqlVector`1[System.Single]' (Size = 20) (DbType = Binary)
@p2='1'

SELECT [v1].[Id], [v1].[Distance]
FROM (
SELECT TOP(@p1) WITH APPROXIMATE [v].[Id], [v0].[Distance]
FROM VECTOR_SEARCH(
TABLE = [VectorEntities] AS [v],
COLUMN = [Vector],
SIMILAR_TO = @p,
METRIC = 'cosine'
) AS [v0]
ORDER BY [v0].[Distance]
) AS [v1]
ORDER BY [v1].[Distance]
OFFSET @p2 ROWS
""");
}

[ConditionalFact]
public async Task Length()
{
Expand Down
Loading