WelisaWelisa DocGen
v1.1.0 welisa.com

Query configuration#

The Query Configuration decides which data a template can merge: fields on the base object, parent lookups, child relationships (with filters, sort order and limits), and — for data that is not a record — an Apex class or JSON handed in by a Flow. A tag only populates when its field is in the query.

Important

The query is the single source of what gets loaded. The engine builds its SOQL from the Query Configuration and does not scan the template body for fields. A tag whose field is missing from the query renders as empty text, with no error.

The visual builder#

The visual query builder walks your schema like a tree: start from the base object, tick fields (blue pills), climb into + parent lookup, and expand + related list child sections — each child with its own tag name, Filter (WHERE), Sort by and Limit. The same builder appears everywhere a query gets built: Edit Template → Query Configuration, the designer's Query panel, and the Generate-with-AI step.

Multi-hop parent traversal. On any object, expand a lookup and the builder recurses into the parent's lookups — on an Opportunity, expand Account → Parent → Owner → Name and the tag becomes {Account.Parent.Owner.Name}. Capped at five hops. Each hop loads lazily.

For direct JSON editing or the legacy flat format, toggle Manual Query mode.

Query formats#

Three formats are accepted. The visual builder writes the third; the first is the most convenient for scripts.

V1 — flat field list#

A field-list fragment — not a full SOQL statement. The engine wraps it as SELECT <fields> FROM <BaseObject> WHERE Id = :recordId.

Id, Name, Industry, BillingCity, Owner.Name,
(SELECT Id, FirstName, LastName, Email FROM Contacts ORDER BY LastName)

Rules: no SELECT, FROM or WHERE at the top level; children are standard subqueries (SELECT … FROM <RelationshipName>) with the exact relationship name (Contacts, OpportunityLineItems, Lines__r); parent fields by dot notation. Detected when the configuration does not start with {.

Warning

Pasting a full SELECT … FROM … WHERE … statement mis-parses into a junk tag and child subqueries are never registered — every loop renders empty. Paste the fragment only. Bare RelationshipName(field1, field2) is also not valid and silently drops the relationship.

V2 — JSON with junction support#

Adds many-to-many junctions (Account ↔ Contact via AccountContactRelation):

{
  "v": 2,
  "baseObject": "Opportunity",
  "baseFields": ["Name"],
  "parentFields": ["Account.Name"],
  "children": [{ "rel": "OpportunityLineItems", "fields": ["Name"] }],
  "junctions": [{
    "junctionRel": "OpportunityContactRoles",
    "targetObject": "Contact",
    "targetIdField": "ContactId",
    "targetFields": ["FirstName"]
  }]
}

V3 — query tree (preferred)#

A tree of nodes; each node is one SOQL query stitched into its parent's data by lookupField. Any depth, any number of relationships.

{
  "v": 3,
  "root": "Account",
  "nodes": [
    { "id": "n0", "object": "Account", "fields": ["Name"], "parentFields": ["Owner.Name"],
      "parentNode": null, "lookupField": null, "relationshipName": null },
    { "id": "n1", "object": "Contact", "fields": ["FirstName", "LastName"], "parentFields": [],
      "parentNode": "n0", "lookupField": "AccountId", "relationshipName": "Contacts" },
    { "id": "n2", "object": "Opportunity", "fields": ["Name", "Amount"], "parentFields": [],
      "parentNode": "n0", "lookupField": "AccountId", "relationshipName": "Opportunities" },
    { "id": "n3", "object": "OpportunityLineItem", "fields": ["Quantity"], "parentFields": ["Product2.Name"],
      "parentNode": "n2", "lookupField": "OpportunityId", "relationshipName": "OpportunityLineItems" }
  ]
}

Each child node supports fields, parentFields (dotted lookups such as Product2.Name), where (sanitised), orderBy (sanitised) and limit. These apply to both the normal path and the large-dataset path. The automatic large-dataset routing in Flows requires a V3 configuration.

Verifying the query#

Open the template's Copy-Paste Tags tab: every field and every child loop must appear as a pill. If a child is missing, the query did not register it. After editing the query through the API, reopen the template — the tag list caches client-side.

A query that looks right but still yields an empty loop usually means the generating user lacks Read access on the child object (or field-level security on its fields). Generation honours the running user's permissions; an administrator sees rows a standard user does not.

Apex Data Provider — class-backed templates#

When the data is not an SObject — an external API response, computed totals, cross-object aggregations — implement the welisa.DocGenDataProvider interface in your org and bind the template to that class. The merge engine calls your class at render time and merges whatever map you return.

1. Write the provider#

global with sharing class MyAccountBriefProvider implements welisa.DocGenDataProvider {
    global Map<String, Object> getData(Id recordId) {
        Account a = [SELECT Id, Name, Industry, AnnualRevenue, Owner.Name FROM Account WHERE Id = :recordId LIMIT 1];
        Decimal score = a.AnnualRevenue == null ? 0 : Math.min(100, Math.log(a.AnnualRevenue.doubleValue() + 1) * 6);

        List<Object> contacts = new List<Object>();
        for (Contact c : [SELECT FirstName, LastName, Email, Title FROM Contact WHERE AccountId = :recordId]) {
            contacts.add(new Map<String, Object>{ 'FullName' => c.FirstName + ' ' + c.LastName, 'Title' => c.Title, 'Email' => c.Email });
        }
        return new Map<String, Object>{
            'Name' => a.Name,                                                    // {Name}
            'Industry' => a.Industry,
            'CustomerScore' => score,                                            // computed → {CustomerScore}
            'Owner' => new Map<String, Object>{ 'Name' => a.Owner.Name },        // {Owner.Name}
            'Contacts' => new Map<String, Object>{ 'records' => contacts, 'totalSize' => contacts.size() } // {#Contacts}…{/Contacts}, {COUNT:Contacts}
        };
    }
    global List<String> getFieldNames() {
        // Powers the tag cheat sheet in the wizard. Dot-notation for parents; '#' and '/' wrap loop boundaries.
        return new List<String>{ 'Name', 'Industry', 'CustomerScore', 'Owner.Name', '#Contacts', 'Contacts.FullName', 'Contacts.Title', 'Contacts.Email', '/Contacts' };
    }
}

Both methods are required and global. Nested maps become parent-lookup tags, lists of maps become loops, and totalSize on a loop map lets {#IF Contacts.totalSize != 0} work.

2. Bind the template#

In the create wizard, Step 1 — Data Source → Apex Class (Data Provider) → search your class (the picker only lists classes implementing the interface). The wizard validates it, calls getFieldNames() and shows the available tags. Behind the scenes the Query Configuration becomes {"v":4,"provider":"MyAccountBriefProvider"}. An existing template can be switched from Edit → Query Configuration → Use Apex data provider.

3. Generate#

Exactly like any other template — runner, Flow action or welisa.DocGenService.generateDocument(templateId, recordId, null); the binding is detected automatically.

Common patterns: do a callout in getData (mind governor limits and DML-before-callout rules); aggregate across objects in Apex; read Custom Metadata to shape the data; ignore recordId entirely for "report for the current user" templates.

JSON data from a Flow#

If the data already sits in a Flow variable, skip the class: the Generate Document Flow action accepts a JSON Data input, and welisa.DocGenService.generatePdfBlobFromData(templateId, dataMap) accepts an Apex map directly. Same engine, same tags.

When a template will only ever receive its data this way, pick JSON Data (from Flow) as the Data Source in the wizard: the base-object picker, query builder and class picker are skipped. Such templates are intentionally invisible to the record-page runner and are invoked only from Flows. The JSON shape mirrors the data map: {Field}, {Parent.Field} and {#Loop} over a records array:

{
  "Name": "Acme Corp",
  "Amount": 50000,
  "Items": { "records": [ { "Product": "Widget", "Qty": 2, "Price": 100 }, { "Product": "Gadget", "Qty": 1, "Price": 250 } ] }
}

Classic approvals#

Add the standalone word Approvals to the Query Configuration (V1: Name, Status__c, Approvals) to expose the record's Classic Approval history as a loop — see {#Approvals} in the merge tag reference.