<FetchXmlBuilder> edits a FetchXML query without anyone writing FetchXML. It covers the parts of the language people actually reach for — the table, the columns it returns, how the rows are sorted, and a filter tree of conditions and nested groups — and it joins related tables, aggregates, and runs what it built.
The real component against this environment’s metadata. Pick a table, add a column or a condition, join a related table, and watch the FetchXML panel keep up — then press Run to see what the query matches.
Not the same as FetchXMLBuilder
This page is about the component.
FetchXMLBuilderis the C# class that composes the same XML in code, for a server-side caller building a query by hand. They produce the same thing and neither needs the other.
Every table, column and relationship is chosen from what the environment actually has. That is deliberate: a logical name typed by hand produces FetchXML that parses perfectly and then fails against Dataverse, which is the worst kind of error to hand somebody. The usePowerPortalsProFetchXmlMetadata hook wires the builder to the framework’s cached metadata endpoints, so the pickers share whatever the grids on the same page have already fetched.
// The whole of the wiring on a portal page.
import { FetchXmlBuilder, usePowerPortalsProFetchXmlMetadata } from '@powerportalspro/react-fluent';
export function QueryEditor() {
const metadata = usePowerPortalsProFetchXmlMetadata();
const [fetchXml, setFetchXml] = useState('');
return <FetchXmlBuilder value={fetchXml} onChange={setFetchXml} metadata={metadata} />;
}
Blazor needs no wiring: <FetchXmlBuilder> resolves the portal's metadata service itself and reads the same cached endpoints, so its pickers also share whatever the grids on the page have already fetched.
@* The table, columns and relationships come from the environment *@
<FetchXmlBuilder @bind-FetchXml="_fetchXml" />
The builder parses FetchXML into a document model and writes it back out. Anything it has no editor for — an attribute from a newer platform version, an element written by another tool — is carried through untouched rather than dropped on load, so opening a query in the editor and saving it never quietly costs you part of it.
What that means for a saved query
Formatting is not preserved. Indentation and attribute quoting are normalized on the way out, so a query re-saved through the builder is the same query rather than the same text.
There are three ways in, and all three go through the same reader:
value prop. Supply it with onChange for a controlled component, or use defaultValue and let the builder own its state.Whatever arrives is checked first. Malformed XML is reported with its line and column, and so is anything that would make the editor show something other than what was written — an operator or link type it does not know, a query naming no table. Those are refused outright and the query already on screen is left alone. Everything else is reported and still applied: a misspelled element or attribute, two tables sharing an alias, a condition reading from an alias nothing declares.
A join is added from the relationship picker, so the from and to columns come from the relationship rather than from memory. What a join can then contribute depends on its type:
inner, outer and matchfirstrowusingcrossapply — bring the related table’s columns back, so their columns can be projected, sorted by and compared against.exists, in, any, not any and all — return the row at most once and none of the related columns. The editor drops the alias when you switch to one, so the name is free for another join.About “all”
FetchXML’s
alldoes not mean what its name suggests — it matches rows that have related rows of which none satisfy the filter, andnot allis documented as equivalent toany. Each type is labeled by what it does rather than what it is called, and an Invert conditions action on a filter group expresses the reading people usually want.
A filter is a group of conditions combined with and or or, and groups nest inside one another to whatever depth the query needs. Four things in the tree are worth knowing about, because each answers a question FetchXML makes awkward to ask:
all does not mean “every related row matches” — it matches rows whose related rows none satisfy the filter — so expressing the intuitive reading means inverting first. It is offered only where every condition below has a true opposite; most date operators have none, and inverting part of a filter would look inverted and match the wrong rows.valueof. Six operators allow it and the platform refuses the rest outright, so the operator list narrows while the row is in that mode. Candidates come from every joined table, since a condition reaches a related column by naming the join’s alias.Rows that are not finished yet
A condition missing the value its operator needs is outlined, as is an alias that is required and blank, or shared with another column. All three produce FetchXML that parses perfectly and then fails — or silently returns the wrong rows — so the editor says so rather than leaving it to be found when the query runs. Operators that need no value, such as
Contains dataandToday, are complete on their own and are never marked.
Turn on Aggregate in the Columns section and each column says what it contributes — a value computed across the rows, or the thing they are grouped by. The functions offered narrow to the column’s type, aliases become required and are filled in for you, a grouped date column gains a date part, and sorting switches to the aliases, because an aggregate query’s rows are groups and computed values with no columns to sort by.
<fetch aggregate="true">
<entity name="account">
<attribute name="revenue" alias="sum_revenue" aggregate="sum" />
<attribute name="address1_city" alias="group_address1_city" groupby="true" />
<order alias="group_address1_city" />
</entity>
</fetch>
allowAggregate={false} withdraws the switch, for a host that reads rows rather than totals — a rule evaluated one record at a time, a saved filter, anywhere a single row of computed values would not be an answer. It is absent rather than disabled, because no change to the query would bring it back. The one exception is a query that already aggregates: it keeps its controls however the prop is set, so one pasted into the FetchXML panel can still be edited back into an ordinary query.
// The Aggregate switch is not offered at all.
<FetchXmlBuilder
metadata={metadata}
table="contact"
allowAggregate={false}
value={fetchXml}
onChange={setFetchXml}
/>
AllowAggregate="false" does the same on Blazor, with the same exception: a query that already aggregates keeps its controls, so one pasted into the FetchXML tab can still be edited back into an ordinary query.
@* Withdraw the aggregate switch *@
<FetchXmlBuilder Table="contact"
AllowAggregate="false"
@bind-FetchXml="_fetchXml" />
Every section can be switched off, and the sections are exported individually for a layout that needs them somewhere else — mount <FetchXmlBuilderProvider> and place the FetchXml*Section components yourself. Omitting a section removes its UI, never the underlying FetchXML: a query’s columns survive untouched with columns: false.
// A filter-only rule editor, pinned to one table.
<FetchXmlBuilder
metadata={metadata}
table="contact"
sections={{ table: false, columns: false, sorts: false, options: false }}
value={fetchXml}
onChange={setFetchXml}
/>
Blazor takes the same set as a Sections record, and the sections are components in their own right — cascade a FetchXmlBuilderState and place the FetchXml*Section components wherever a layout needs them. Hiding one removes its editor and never the query behind it.
@* Show the filter editor alone *@
<FetchXmlBuilder Table="contact"
Sections="_filterOnly"
@bind-FetchXml="_fetchXml" />
@code {
private readonly FetchXmlBuilderSections _filterOnly = new()
{
Table = false, Columns = false, Sorts = false, Options = false,
};
}
allowedTables narrows the table picker to the logical names you list — for a surface that has no business querying the whole environment, like a report builder over the handful of tables a portal exposes. Omit it and every table is offered. It narrows what can be chosen, not what can be shown: a query that already names a table outside the list keeps it rather than being silently rewritten, and a related table is still named by its display name wherever a join mentions it. Joins themselves are not restricted, because they follow relationships — a property of the table already chosen.
// The picker offers these three and nothing else.
<FetchXmlBuilder
metadata={metadata}
allowedTables={['account', 'contact', 'opportunity']}
value={fetchXml}
onChange={setFetchXml}
/>
AllowedTables is the Blazor equivalent, and narrows what can be chosen rather than what can be shown in exactly the same way.
@* Offer three tables and no others *@
<FetchXmlBuilder AllowedTables="_tables"
@bind-FetchXml="_fetchXml" />
@code {
private readonly string[] _tables = ["account", "contact", "opportunity"];
}
Supply renderResults and the builder grows a Results panel with a Run button, and decides when it may be pressed: once the query names a table and its columns, and no field is marked in red. Displaying rows takes a data client the builder deliberately has none of, so what a result looks like is yours — on a portal that usually means the framework’s own <MainGrid> over the query it hands back. Leave the prop off and the panel is not there.
<FetchXmlBuilder
metadata={metadata}
value={fetchXml}
onChange={setFetchXml}
// The builder supplies the button; this says what a result looks like.
renderResults={(runnable) => (
<MainGrid
key={runnable.fetchXml}
tableName={runnable.tableName}
customViewDefinitions={[{
id: BUILDER_VIEW_ID,
displayName: 'Builder query',
tableName: runnable.tableName,
fetchXml: runnable.fetchXml,
columns: runnable.columns.map((columnName) => ({ columnName, width: 200 })),
}]}
viewId={BUILDER_VIEW_ID}
/>
)}
/>
On Blazor the same panel appears once a RenderResults fragment is supplied, and the runnable query it hands back carries the table, the FetchXML and the names the columns come back under. Leave the fragment out and there is no panel.
@* The builder owns the Run button; the rows are yours to draw *@
<FetchXmlBuilder @bind-FetchXml="_fetchXml">
<RenderResults Context="runnable">
<MainGrid TableName="@runnable.TableName"
CustomViewDefinitions="@BuilderView(runnable)"
ViewIds="[BuilderViewId]" />
</RenderResults>
</FetchXmlBuilder>
@code {
private static readonly Guid BuilderViewId = Guid.Parse("00000000-0000-0000-0000-00000000f001");
private static List<ViewDefinition> BuilderView(FetchXmlRunnableQuery runnable) =>
[
new()
{
Id = BuilderViewId,
DisplayName = "Builder query",
TableName = runnable.TableName,
FetchXml = runnable.FetchXml,
Columns = [.. runnable.Columns.Select(x => new ViewColumn { ColumnName = x, Width = 200 })],
},
];
}
Name | Type | Default | Description |
|---|---|---|---|
AllowAggregate | bool | True | Whether the query may aggregate. Hides the offer when it may not. |
AllowedTables | IReadOnlyList<string>? | The only tables the picker offers, or nothing for all of them. | |
FetchXml | string? | The query being edited. | |
ReadOnly | bool | False | Shows the query without offering to change it. |
RenderResults | RenderFragment<FetchXmlRunnableQuery>? | Draws the rows the query returned, which also adds the results section. | |
Sections | FetchXmlBuilderSections | Which sections to show. Everything but the results, which needs somewhere to draw them. | |
ShowLogicalNames | bool | True | Whether tables and columns are named with their logical names beside their display names — |
Table | string? | Pins the query to one table, so the table picker is shown but disabled. |
AllowAggregateAllowedTablesFetchXmlReadOnlyRenderResultsSectionsShowLogicalNamesTableName | Type | Description |
|---|---|---|
FetchXmlChanged | EventCallback<string> | Raised whenever an edit changes the query. |
FetchXmlChanged