FetchXML query builder
<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.
React only, for now
This component exists on the React stack only — there is no Blazor equivalent yet. A Blazor portal that needs to store a FetchXML query can still do so; what it does not have is this editor for building one.
Try it
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.
Metadata is required, not optional
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} />;
}
The query round-trips
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.
Supplying a query
There are three ways in, and all three go through the same reader:
- The
valueprop. Supply it withonChangefor a controlled component, or usedefaultValueand let the builder own its state. - The FetchXML panel. Paste or type a query and the editor rebuilds itself from it about a second after you stop typing.
- A saved view. Start from a view in the Filters header lists the table’s views and takes the whole query from the one you pick. It is a copy — nothing is written back to the view.
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.
Related tables
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:
- Returning types —
inner,outerandmatchfirstrowusingcrossapply— bring the related table’s columns back, so their columns can be projected, sorted by and compared against. - Filtering types —
exists,in,any,not anyandall— 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. - Has no related row is a preset rather than a link type: FetchXML has none for it, so the editor writes the outer join and the null test on the joined key that the platform expects, and keeps the two in step.
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 labelled 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.
Aggregate queries
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}
/>
Mounting part of it
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}
/>
Restricting the tables
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}
/>
Running the query
Supply renderResults and the builder grows a Results panel with a Run button, and decides when it may be pressed. 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}
/>
)}
/>
