PowerPortalsPro.Dataverse.Linq
1.0.12
dotnet add package PowerPortalsPro.Dataverse.Linq --version 1.0.12
NuGet\Install-Package PowerPortalsPro.Dataverse.Linq -Version 1.0.12
<PackageReference Include="PowerPortalsPro.Dataverse.Linq" Version="1.0.12" />
<PackageVersion Include="PowerPortalsPro.Dataverse.Linq" Version="1.0.12" />
<PackageReference Include="PowerPortalsPro.Dataverse.Linq" />
paket add PowerPortalsPro.Dataverse.Linq --version 1.0.12
#r "nuget: PowerPortalsPro.Dataverse.Linq, 1.0.12"
#:package PowerPortalsPro.Dataverse.Linq@1.0.12
#addin nuget:?package=PowerPortalsPro.Dataverse.Linq&version=1.0.12
#tool nuget:?package=PowerPortalsPro.Dataverse.Linq&version=1.0.12
PowerPortalsPro.Dataverse.Linq
A strongly-typed LINQ query provider for Microsoft Dataverse (Dynamics 365 / Power Platform) that translates standard LINQ expressions into FetchXml and executes them against the Dataverse API. Write queries using familiar C# syntax instead of hand-crafting FetchXml strings.
Installation
dotnet add package PowerPortalsPro.Dataverse.Linq
Supports .NET 8, .NET 10+ (with full async support) and .NET Framework 4.6.2+ (sync only).
Quick Start
using PowerPortalsPro.Dataverse.Linq;
var accounts = await service.Queryable<Account>()
.Where(a => a.Name.StartsWith("Contoso"))
.OrderBy(a => a.Name)
.ToListAsync();
Entry Points
The Queryable() extension method creates a LINQ queryable from your service connection. On .NET 10+ it extends IOrganizationServiceAsync; on .NET Framework 4.6.2+ it extends IOrganizationService.
// Typed query — entity type must have [EntityLogicalName] attribute
var accounts = await service.Queryable<Account>().ToListAsync();
// Typed query with explicit column set
var accounts = await service.Queryable<Account>("name", "revenue").ToListAsync();
// Unbound query — no proxy class required
var results = await service.Queryable("account", "name", "revenue").ToListAsync();
Filtering
Standard C# comparison operators, null checks, and string methods translate directly to FetchXml conditions. All supported condition operators are listed below.
Comparison and Equality
Translates to FetchXml operators eq, ne, lt, le, gt, ge, null, not-null.
.Where(a => a.Name == "Contoso")
.Where(a => a.Revenue > 1000000 && a.Revenue <= 5000000)
.Where(a => a.Description != null)
String Methods
Contains, StartsWith, and EndsWith translate to like / not-like with appropriate wildcard patterns. string.IsNullOrEmpty() translates to a null OR eq "" filter.
.Where(a => a.Name.Contains("Corp"))
.Where(a => a.Name.StartsWith("A"))
.Where(a => a.Name.EndsWith("Inc"))
.Where(a => !string.IsNullOrEmpty(a.Email))
String Length
string.Length comparisons translate to like / not-like patterns using underscore wildcards.
.Where(a => a.Name.Length == 10)
.Where(a => a.Name.Length > 5)
In / Not In
Contains() on a collection translates to in / not-in.
var names = new[] { "Contoso", "Fabrikam", "Northwind" };
.Where(a => names.Contains(a.Name))
.Where(a => !names.Contains(a.Name))
// Also works with Guid collections
var ids = new[] { id1, id2, id3 };
.Where(a => ids.Contains(a.AccountId))
Column-to-Column Comparison
Compare two columns in the same row using the FetchXml valueof attribute. Also works across joined entities.
.Where(c => c.FirstName == c.LastName)
.Where(o => o.ActualRevenue > o.EstimatedRevenue)
Negation
Prefix any boolean filter with ! to negate it:
.Where(a => !a.Name.Contains("Test"))
.Where(a => !a.CreatedOn.LastXDays(30))
Subquery Filtering (Any, All, Exists)
These operators filter rows based on the existence or properties of related records, translating to FetchXml link-entity elements with special link-type values. Each predicate must include a join condition relating the inner entity to the outer entity.
Any — at least one match
Any() translates to link-type="any" (nested inside a <filter>). Returns parent rows where at least one related child matches.
// Contacts that are the primary contact of an account named "Contoso"
await service.Queryable<Contact>()
.Where(c => service.Queryable<Account>().Any(
a => a.PrimaryContactId.Id == c.ContactId && a.Name == "Contoso"))
.ToListAsync();
// Negate with ! → link-type="not any" (no matching children)
await service.Queryable<Contact>()
.Where(c => !service.Queryable<Account>().Any(
a => a.PrimaryContactId.Id == c.ContactId))
.ToListAsync();
All — every child matches
All() translates to link-type="all". Returns parent rows where every related child satisfies the condition. Parents with no children are excluded (no vacuous truth).
The LINQ predicate is automatically negated in the generated FetchXml (including DeMorgan's law for nested &&/|| conditions), because FetchXml link-type="all" uses inverted filter semantics — the filter describes what would cause a child to fail.
// Contacts where ALL linked accounts have a non-null rating
await service.Queryable<Contact>()
.Where(c => service.Queryable<Account>().All(
a => a.PrimaryContactId.Id == c.ContactId
&& a.AccountRating != null))
.ToListAsync();
// Negate with ! → link-type="not all" (at least one child fails)
await service.Queryable<Contact>()
.Where(c => !service.Queryable<Account>().All(
a => a.PrimaryContactId.Id == c.ContactId
&& a.Name == "Contoso"))
.ToListAsync();
// Complex predicate with OR — DeMorgan applied automatically
// All(Name == "Contoso" || Rating != null) generates:
// link-type="all" with filter: Name != "Contoso" AND Rating == null
await service.Queryable<Contact>()
.Where(c => service.Queryable<Account>().All(
a => a.PrimaryContactId.Id == c.ContactId
&& (a.Name == "Contoso" || a.AccountRating != null)))
.ToListAsync();
All() and !All() are complementary within the set of parents that have at least one related child.
Exists and In — semi-join operators
Exists() and In() translate to link-type="exists" and link-type="in" respectively, placed as direct children of the <entity> element (not inside a <filter>). Both are semi-joins — they filter parent rows based on the existence of matching child rows without returning columns from the related entity.
Exists() and In() are semantically equivalent but may have different performance characteristics depending on the Dataverse query optimizer. Exists uses a correlated subquery while In uses an IN subquery. Try both if you encounter performance issues with large datasets.
// Accounts that have at least one active contact (EXISTS subquery)
await service.Queryable<Account>()
.Where(a => service.Queryable<Contact>().Exists(
c => c.ParentCustomerId.Id == a.AccountId && c.StateCode == 0))
.ToListAsync();
// Same query using IN subquery
await service.Queryable<Account>()
.Where(a => service.Queryable<Contact>().In(
c => c.ParentCustomerId.Id == a.AccountId && c.StateCode == 0))
.ToListAsync();
// Negate with ! — Dataverse doesn't support "not exists" or "not in" as
// link-types, so negation automatically falls back to link-type="not any"
await service.Queryable<Account>()
.Where(a => !service.Queryable<Contact>().Exists(
c => c.ParentCustomerId.Id == a.AccountId))
.ToListAsync();
// Join-only (no filter) — check for any related record
await service.Queryable<Account>()
.Where(a => service.Queryable<Contact>().Exists(
c => c.ParentCustomerId.Id == a.AccountId))
.ToListAsync();
Summary
| LINQ Operator | FetchXml link-type |
Placement | Behavior |
|---|---|---|---|
.Any(predicate) |
any |
Inside <filter> |
At least one child matches |
!.Any(predicate) |
not any |
Inside <filter> |
No children match |
.All(predicate) |
all |
Inside <filter> |
All children match (conditions negated) |
!.All(predicate) |
not all |
Inside <filter> |
At least one child fails (conditions negated) |
.Exists(predicate) |
exists |
Direct child of <entity> |
Semi-join via correlated subquery |
.In(predicate) |
in |
Direct child of <entity> |
Semi-join via IN subquery |
!.Exists(predicate) |
not any (fallback) |
Inside <filter> |
No matching children |
!.In(predicate) |
not any (fallback) |
Inside <filter> |
No matching children |
DateTime Operators
Extension methods in PowerPortalsPro.Dataverse.Linq map to all FetchXml datetime condition operators:
using PowerPortalsPro.Dataverse.Linq;
Parameterless operators:
| Method | FetchXml Operator |
|---|---|
.Today() |
today |
.Yesterday() |
yesterday |
.Tomorrow() |
tomorrow |
.ThisWeek() |
this-week |
.ThisMonth() |
this-month |
.ThisYear() |
this-year |
.ThisFiscalPeriod() |
this-fiscal-period |
.ThisFiscalYear() |
this-fiscal-year |
.Last7Days() |
last-seven-days |
.LastWeek() |
last-week |
.LastMonth() |
last-month |
.LastYear() |
last-year |
.LastFiscalPeriod() |
last-fiscal-period |
.LastFiscalYear() |
last-fiscal-year |
.Next7Days() |
next-seven-days |
.NextWeek() |
next-week |
.NextMonth() |
next-month |
.NextYear() |
next-year |
.NextFiscalPeriod() |
next-fiscal-period |
.NextFiscalYear() |
next-fiscal-year |
Parameterized operators:
| Method | FetchXml Operator |
|---|---|
.LastXDays(n) |
last-x-days |
.LastXWeeks(n) |
last-x-weeks |
.LastXMonths(n) |
last-x-months |
.LastXYears(n) |
last-x-years |
.LastXHours(n) |
last-x-hours |
.LastXFiscalPeriods(n) |
last-x-fiscal-periods |
.LastXFiscalYears(n) |
last-x-fiscal-years |
.NextXDays(n) |
next-x-days |
.NextXWeeks(n) |
next-x-weeks |
.NextXMonths(n) |
next-x-months |
.NextXYears(n) |
next-x-years |
.NextXHours(n) |
next-x-hours |
.NextXFiscalPeriods(n) |
next-x-fiscal-periods |
.NextXFiscalYears(n) |
next-x-fiscal-years |
.OlderThanXMonths(n) |
olderthan-x-months |
.On(date) |
on |
.OnOrAfter(date) |
on-or-after |
.OnOrBefore(date) |
on-or-before |
.Between(from, to) |
between |
.NotBetween(from, to) |
not-between |
.InFiscalYear(year) |
in-fiscal-year |
.InFiscalPeriod(period) |
in-fiscal-period |
.InFiscalPeriodAndYear(period, year) |
in-fiscal-period-and-year |
.InOrBeforeFiscalPeriodAndYear(p, y) |
in-or-before-fiscal-period-and-year |
.InOrAfterFiscalPeriodAndYear(p, y) |
in-or-after-fiscal-period-and-year |
All DateTime methods have both DateTime and DateTime? overloads.
User and Business Unit Operators
Extension methods for user/business unit condition operators:
using PowerPortalsPro.Dataverse.Linq;
| Method | FetchXml Operator |
|---|---|
.EqualUserId() |
eq-userid |
.NotEqualUserId() |
ne-userid |
.EqualBusinessId() |
eq-businessid |
.NotEqualBusinessId() |
ne-businessid |
.Where(a => a.OwnerId.Id.EqualUserId()) // Current user's records
.Where(a => a.OwningBusinessUnit.Id.EqualBusinessId()) // Current business unit
Hierarchy Operators
Extension methods for hierarchical condition operators:
using PowerPortalsPro.Dataverse.Linq;
| Method | FetchXml Operator |
|---|---|
.Above(id) |
above |
.AboveOrEqual(id) |
eq-or-above |
.Under(id) |
under |
.UnderOrEqual(id) |
eq-or-under |
.NotUnder(id) |
not-under |
.EqualUserOrUserHierarchy() |
eq-useroruserhierarchy |
.EqualUserOrUserHierarchyAndTeams() |
eq-useroruserhierarchyandteams |
.Where(a => a.AccountId.Under(parentId))
.Where(a => a.AccountId.AboveOrEqual(childId))
Multi-Select Option Sets
Extension methods for choice column operators (contain-values / not-contain-values):
using PowerPortalsPro.Dataverse.Linq;
// ContainsValues — contain-values operator
.Where(c => c.PreferredMethods.ContainsValues(Method.Email, Method.Phone))
// Negated — not-contain-values operator
.Where(c => !c.PreferredMethods.ContainsValues(Method.Email))
// Single-item Contains — contain-values for one value
.Where(c => c.PreferredMethods.Contains(Method.Email))
// Equals — eq/ne for single value, in/not-in for multiple
.Where(c => c.PreferredMethods.Equals(Method.Email))
.Where(c => c.PreferredMethods.Equals(new[] { Method.Email, Method.Phone }))
Projections
Select specific columns using FetchXml attribute elements. Only the selected columns are retrieved from Dataverse.
// Anonymous type projection
var results = await service.Queryable<Account>()
.Select(a => new { a.Name, a.Revenue, a.PrimaryContact })
.ToListAsync();
// Project into entity type
var results = await service.Queryable<Account>()
.Select(a => new Account { AccountId = a.AccountId, Name = a.Name })
.ToListAsync();
// Ternary / null-coalesce expressions are supported in projections
.Select(a => new { a.AccountId, IsPreferred = a.IsPreferred ?? false })
Joins
Inner joins and left joins using standard LINQ join syntax translate to FetchXml link-entity elements.
// Inner join
var results = await (from a in service.Queryable<Account>()
join c in service.Queryable<Contact>()
on a.AccountId equals c.ParentCustomerId.Id
where a.Name != null
orderby c.LastName
select new { AccountName = a.Name, ContactName = c.FullName })
.ToListAsync();
// Left join (DefaultIfEmpty)
var results = await (from a in service.Queryable<Account>()
join c in service.Queryable<Contact>()
on a.AccountId equals c.ParentCustomerId.Id into contacts
from c in contacts.DefaultIfEmpty()
select new { a.Name })
.ToListAsync();
// Three-way join
var results = await (from a in service.Queryable<Account>()
join c in service.Queryable<Contact>()
on a.PrimaryContactId.Id equals c.ContactId
join pa in service.Queryable<Account>()
on c.ParentCustomerId.Id equals pa.AccountId
select new { a.Name, ContactName = c.FullName, ParentName = pa.Name })
.ToListAsync();
// Join with explicit column sets
var results = await (from a in service.Queryable<Account>("name", "createdon")
join c in service.Queryable<Contact>("firstname", "lastname")
on a.PrimaryContactId.Id equals c.ContactId
select new { a.Name, c.FirstName, c.LastName })
.ToListAsync();
// Sub-query: join on pre-filtered queryables
var activeAccounts = service.Queryable<Account>().Where(a => a.StateCode == 0);
var results = await (from a in activeAccounts
join c in service.Queryable<Contact>()
on a.PrimaryContactId.Id equals c.ContactId
select new { a.Name, c.FullName })
.ToListAsync();
// First-row join — returns only the first matching child per parent
// Translates to link-type="matchfirstrowusingcrossapply"
var results = await (from c in service.Queryable<Contact>()
join t in service.Queryable<Task>().WithFirstRow()
on c.ContactId equals t.RegardingObjectId.Id
select new { c.FullName, t.Subject })
.ToListAsync();
Ordering
Translates to FetchXml order elements.
.OrderBy(a => a.Name)
.OrderByDescending(a => a.Revenue)
// Multiple sort criteria (including across joined entities)
from a in service.Queryable<Account>()
join c in service.Queryable<Contact>() on a.PrimaryContactId.Id equals c.ContactId
orderby c.LastName, a.Name
select new { a.Name, c.LastName }
Paging
Control FetchXml paging with WithPageSize, WithPage, and Take.
// Page size and page number
var page2 = await service.Queryable<Account>()
.OrderBy(a => a.Name)
.WithPageSize(50)
.WithPage(2)
.ToListAsync();
// Top N records (FetchXml top attribute)
var top10 = await service.Queryable<Account>()
.OrderByDescending(a => a.Revenue)
.Take(10)
.ToListAsync();
ForEachPage / ForEachPageAsync
When calling ToList() or ToListAsync(), all pages are fetched automatically and combined into a single list. This is simple but loads the entire result set into memory at once, which may not be desirable for large data sets.
ForEachPage and ForEachPageAsync give you control over how each page is processed as it arrives from Dataverse. This is useful when you need to:
- Process records in batches without loading everything into memory
- Stream results to an external system as they arrive
- Apply back-pressure or throttling between pages
- Track progress across large data sets
Use WithPageSize() to control how many records are returned per page. Without it, Dataverse uses its default page size (up to 5,000 records), meaning all results may come back in a single page.
// Process 100 records at a time
await service.Queryable<Account>()
.Where(a => a.StateCode == 0)
.WithPageSize(100)
.ForEachPageAsync(async page =>
{
Console.WriteLine($"Processing batch of {page.Count} accounts...");
foreach (var account in page)
await ProcessAccountAsync(account);
});
ForEachPageAsync accepts a Func<List<T>, Task> callback and supports a CancellationToken. A synchronous ForEachPage overload accepting Action<List<T>> is also available. Both methods work with Where, Select, OrderBy, and other query operators — the filtering and projection are applied server-side in the FetchXml, so each page contains only the matching, projected results.
Distinct
Translates to the FetchXml distinct attribute:
var uniqueNames = await service.Queryable<Account>()
.Select(a => a.Name)
.Distinct()
.ToListAsync();
Aggregations
Standard LINQ aggregate methods translate to FetchXml aggregate queries.
| LINQ Method | FetchXml Aggregate |
|---|---|
.Count() |
count |
.LongCount() |
count (returns long) |
.CountColumn() |
countcolumn |
.Min(selector) |
min |
.Max(selector) |
max |
.Sum() |
sum |
.Average(selector) |
avg |
var count = await service.Queryable<Account>().CountAsync();
var count = await service.Queryable<Account>().CountAsync(a => a.Revenue > 0);
var max = await service.Queryable<Account>().MaxAsync(a => a.Revenue);
var sum = await service.Queryable<Account>().Select(a => a.NumberOfEmployees).SumAsync();
var avg = await service.Queryable<Account>().Select(a => a.PercentComplete).AverageAsync();
// CountColumnAsync — counts non-null values only
var nonNull = await service.Queryable<Account>()
.Select(a => a.NumberOfEmployees)
.CountColumnAsync();
// CountChildren — row aggregate for hierarchical entities
var results = await service.Queryable<Account>()
.Select(a => new { a.Name, Children = a.CountChildren() })
.ToListAsync();
GroupBy
GroupBy translates to FetchXml grouping with aggregation.
// Simple group with aggregates
var results = await (from a in service.Queryable<Account>()
group a by a.IndustryCode into g
select new
{
Industry = g.Key,
Count = g.Count(),
TotalRevenue = g.Sum(x => x.Revenue),
AverageRevenue = g.Average(x => x.Revenue),
MaxEmployees = g.Max(x => x.NumberOfEmployees),
MinEmployees = g.Min(x => x.NumberOfEmployees),
DescriptionCount = g.CountColumn(x => x.Description),
}).ToListAsync();
// Group by constant — aggregate without grouping
var totals = await (from a in service.Queryable<Account>()
group a by 1 into g
select new
{
Count = g.Count(),
Total = g.Sum(x => x.Revenue),
}).FirstAsync();
// Join + GroupBy — aggregate on linked entity
var results = await (from c in service.Queryable<Contact>()
join o in service.Queryable<Opportunity>()
on c.ContactId equals o.ParentContactId.Id
group o by c.ContactId into g
select new
{
ContactId = g.Key,
Count = g.Count(),
TotalRevenue = g.Sum(x => x.ActualRevenue),
}).ToListAsync();
Date Grouping
Group by date parts using the dategrouping FetchXml attribute:
| C# Expression | FetchXml dategrouping |
|---|---|
.Value.Year |
year |
.Value.Month |
month |
.Value.Day |
day |
.Week() |
week |
.Quarter() |
quarter |
.FiscalPeriod() |
fiscal-period |
.FiscalYear() |
fiscal-year |
using PowerPortalsPro.Dataverse.Linq;
var byYear = await (from o in service.Queryable<Opportunity>()
group o by o.ActualCloseDate.Value.Year into g
orderby g.Key
select new { Year = g.Key, Count = g.Count() })
.ToListAsync();
var byQuarter = await (from o in service.Queryable<Opportunity>()
group o by o.ActualCloseDate.Value.Quarter() into g
select new { Quarter = g.Key, Count = g.Count() })
.ToListAsync();
var byFiscalYear = await (from o in service.Queryable<Opportunity>()
group o by o.ActualCloseDate.Value.FiscalYear() into g
select new { FiscalYear = g.Key, Count = g.Count() })
.ToListAsync();
OptionSet Grouping
Group by OptionSetValue.Value for choice columns:
var results = await (from o in service.Queryable<Opportunity>()
group o by o.StatusReason.Value into g
select new { Status = g.Key, Count = g.Count() })
.ToListAsync();
Unbound Queries
Query without a typed proxy class using GetAttributeValue:
var results = await service.Queryable("account", "name", "revenue")
.Where(e => !string.IsNullOrEmpty(e.GetAttributeValue<string>("name")))
.Select(e => new
{
Name = e.GetAttributeValue<string>("name"),
Revenue = e.GetAttributeValue<Money>("revenue")
})
.ToListAsync();
Query Options
Configure FetchXml attributes on the query:
| Method | FetchXml Attribute | Description |
|---|---|---|
.WithPageSize(n) |
count |
Page size |
.WithPage(n) |
page |
Page number (1-based) |
.WithAggregateLimit(n) |
aggregatelimit |
Aggregate row limit (1-50,000) |
.WithDatasource(FetchDatasource.Retained) |
datasource |
Query long-term retained data |
.WithLateMaterialize() |
latematerialize |
Late materialization optimization |
.WithQueryHints(...) |
options |
SQL query hints (ForceOrder, HashJoin, etc.) |
.WithUseRawOrderBy() |
useraworderby |
Sort choice columns by integer value |
.WithFirstRow() |
matchfirstrowusingcrossapply |
On join inner source: return only the first matching child row |
.WithNoLock() |
no-lock |
Deprecated, no effect |
.ReturnRecordCount(callback) |
returntotalrecordcount |
Total record count via callback |
await service.Queryable<Account>()
.WithPageSize(100)
.WithLateMaterialize()
.WithQueryHints(SqlQueryHint.ForceOrder, SqlQueryHint.DisableRowGoal)
.ToListAsync();
Available SqlQueryHint values: ForceOrder, DisableRowGoal, EnableOptimizerHotfixes, LoopJoin, MergeJoin, HashJoin, NoPerformanceSpool, EnableHistAmendmentForAscKeys.
ReturnRecordCount / ReturnRecordCountAsync
Adds returntotalrecordcount="true" to the FetchXml query and invokes a callback with the total record count after the first page of results is retrieved. This is useful when you need to know the total number of matching rows without fetching them all — for example, to display a total count alongside a paged result set.
The callback receives a RecordCountArguments record containing TotalRecordCount (the total number of matching rows) and TotalRecordCountLimitExceeded (whether the count exceeds the 50,000 row limit).
Both methods are chainable and can be combined with any other query operators.
// Sync callback
RecordCountArguments? countArgs = null;
var results = service.Queryable<Account>()
.Where(a => a.StateCode == 0)
.ReturnRecordCount(args => countArgs = args)
.ToList();
Console.WriteLine($"Showing {results.Count} of {countArgs!.TotalRecordCount} total records");
// Async callback (.NET 8, .NET 10+)
RecordCountArguments? countArgs = null;
var results = await service.Queryable<Account>()
.Where(a => a.StateCode == 0)
.ReturnRecordCountAsync(async args =>
{
countArgs = args;
await Task.CompletedTask;
})
.ToListAsync();
Inspect Generated FetchXml
There are several ways to see the FetchXml the provider produces — two that translate the query without executing it, and two callback-based hooks that capture the exact FetchXml of each request as the query runs.
ToFetchXml() — collection queries
Use ToFetchXml() on any IQueryable<T> to translate a query to FetchXml without executing it:
var fetchXml = service.Queryable<Account>()
.Where(a => a.Name != null)
.OrderBy(a => a.Name)
.Select(a => new { a.Name, a.Revenue })
.ToFetchXml();
Console.WriteLine(fetchXml);
ToFetchXml(terminal) — aggregates and element operators
Aggregates (Count, Sum, Min, Max, Average, CountColumn) and element operators (First, Single, …) are terminal — they execute immediately and return a scalar or single element, so there is no IQueryable<T> left to call .ToFetchXml() on. This overload takes a delegate describing the terminal call and captures its FetchXml without executing it:
var countFetchXml = service.Queryable<Account>()
.ToFetchXml(q => q.Count());
var maxFetchXml = service.Queryable<Account>()
.ToFetchXml(q => q.Max(a => a.Revenue));
var firstFetchXml = service.Queryable<Account>()
.Where(a => a.Name != null)
.ToFetchXml(q => q.FirstOrDefault());
// Operators inside the delegate are translated too
var fetchXml = service.Queryable<Account>()
.ToFetchXml(q => q.Where(a => a.NumberOfEmployees > 5).Count());
CaptureFetchXml(callback) — capture during execution
CaptureFetchXml registers a callback that receives the exact FetchXml of each request immediately before it is sent to Dataverse, then returns the query for further composition or execution. Unlike ToFetchXml(), it captures every execution path — collection queries, aggregates, element operators, and async — as the query actually runs. For multi-page queries the callback fires once per page, with the page number and paging-cookie embedded in the captured FetchXml.
It is chainable and works with both sync and async execution:
// Aggregate — there's no IQueryable to inspect, but the callback still fires
var count = service.Queryable<Account>()
.CaptureFetchXml(xml => logger.LogDebug("FetchXml: {FetchXml}", xml))
.Count();
// Multi-page query — callback fires once per page as each is retrieved
var accounts = await service.Queryable<Account>()
.Where(a => a.StateCode == 0)
.WithPageSize(100)
.CaptureFetchXml(xml => logger.LogDebug("Requesting page: {FetchXml}", xml))
.ToListAsync();
Global diagnostics hook
DataverseQueryDiagnostics.FetchXmlRequested is a process-wide event raised with the FetchXml of every request, for every query executed by the provider — useful for blanket logging or telemetry without instrumenting each query. Like CaptureFetchXml, it fires once per page for multi-page queries.
// Register once at startup
DataverseQueryDiagnostics.FetchXmlRequested += xml =>
logger.LogTrace("Dataverse FetchXml: {FetchXml}", xml);
Handlers run inline on the thread issuing the request, so keep them fast and exception-free. Use
CaptureFetchXml(...)when you need to scope capture to a single query.
Materialization Hooks
Transform hooks let you intercept each row as it is turned from a raw Dataverse Entity into your result type. OnBeforeMaterialize runs on the raw Entity before projection; OnAfterMaterialize runs on the materialized result, with access to the source Entity. Both can mutate in place or return a replacement, and both are available per query (inline) and process-wide (global) — paralleling the FetchXml hooks above.
They fire for every row-materializing path — ToList/ToListAsync, First/Single, projections, and ForEachPage — but not for scalar aggregates (Count, Sum, etc.), where there is no row-to-object step.
Per-query (inline)
// OnBeforeMaterialize — adjust or replace the raw row before projection.
// Return the entity to materialize, or null to leave it unchanged.
var rows = await service.Queryable<Account>()
.OnBeforeMaterialize(e =>
{
e["computed"] = Derive(e); // Entity is mutable
return e;
})
.Select(a => new { a.Name, Computed = a.GetAttributeValue<string>("computed") })
.ToListAsync();
// OnAfterMaterialize — enrich or replace the materialized result using the source row.
// Return the result to use, or null to leave it unchanged.
var contacts = await service.Queryable<Contact>()
.OnAfterMaterialize((source, c) =>
{
c.FullName = $"{c.FirstName} {c.LastName}";
return c;
})
.ToListAsync();
Global
// Register once at startup. Applies to every query in the process.
DataverseQueryDiagnostics.BeforeMaterialize = entity => entity; // Func<Entity, Entity?>
DataverseQueryDiagnostics.AfterMaterialize = (source, result) => result; // Func<Entity, object?, object?>
The global handlers are single delegates (not multicast events like FetchXmlRequested) because a transform pipeline needs a single return value — assign a composed delegate if you need to run several.
Ordering
For both phases the global hook runs first and the per-query hook runs after it, so the per-query hook always has the final say:
global OnBeforeMaterialize → query OnBeforeMaterialize → (materialize)
→ global OnAfterMaterialize → query OnAfterMaterialize
Returning
nullfrom any hook leaves its input unchanged. Hooks run inline on the materializing thread; keep them fast and exception-free.
Async Operations (.NET 8, .NET 10+)
All query execution methods have async counterparts. These are only available when targeting .NET 10+.
| Sync | Async |
|---|---|
.ToList() |
.ToListAsync() |
.First() |
.FirstAsync() |
.FirstOrDefault() |
.FirstOrDefaultAsync() |
.Single() |
.SingleAsync() |
.SingleOrDefault() |
.SingleOrDefaultAsync() |
.Count() |
.CountAsync() |
.LongCount() |
.LongCountAsync() |
.Min(selector) |
.MinAsync(selector) |
.Max(selector) |
.MaxAsync(selector) |
.Sum() |
.SumAsync() |
.Average() |
.AverageAsync() |
.CountColumn() |
.CountColumnAsync() |
.ForEachPage(action) |
.ForEachPageAsync(func) |
.ReturnRecordCount(action) |
.ReturnRecordCountAsync(func) |
SumAsync and AverageAsync have overloads for int, int?, decimal, and decimal?. FirstAsync, FirstOrDefaultAsync, SingleAsync, SingleOrDefaultAsync, and CountAsync have overloads accepting a predicate.
var accounts = await service.Queryable<Account>().ToListAsync();
var first = await service.Queryable<Account>().FirstAsync(a => a.Name == "Contoso");
var count = await service.Queryable<Account>().CountAsync();
var sum = await service.Queryable<Account>().Select(a => a.Revenue).SumAsync();
See the ForEachPage / ForEachPageAsync section for async paged processing examples.
Requirements
| Target | SDK Package |
|---|---|
| .NET 8, .NET 10+ | Microsoft.PowerPlatform.Dataverse.Client |
| .NET Framework 4.6.2+ | Microsoft.CrmSdk.CoreAssemblies |
FetchXml Documentation
For more information about FetchXml, see the official Microsoft documentation:
- Query data using FetchXml - Overview
- FetchXml reference - Element reference
- Select columns
- Join tables
- Order rows
- Filter rows
- Condition operators - Full operator reference
- Page results
- Aggregate data
- Count rows
- Optimize performance
- Query hierarchical data
License
This project is licensed under the GNU Lesser General Public License v3.0 (LGPL-3.0). You are free to use this library in proprietary and open-source applications. If you modify the library itself, you must make those modifications available under the same license. See the LICENSE file for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Framework | net462 is compatible. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
-
.NETFramework 4.6.2
- Microsoft.CrmSdk.CoreAssemblies (>= 9.0.2.60)
-
net10.0
- Microsoft.PowerPlatform.Dataverse.Client (>= 1.2.10)
-
net8.0
- Microsoft.PowerPlatform.Dataverse.Client (>= 1.2.10)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on PowerPortalsPro.Dataverse.Linq:
| Package | Downloads |
|---|---|
|
PowerPortalsPro.Xrm
Dataverse proxy classes and XRM extensions for PowerPortalsPro portal projects. |
GitHub repositories
This package is not used by any popular GitHub repositories.