mirror of
https://github.com/Kingsrook/qqq.git
synced 2025-07-17 20:50:44 +00:00
Docs update - add RecordLookupHelper, RenderingWidgets, some content in Widgets, and "Supplemental Meta Data" in Tables [skip ci]
This commit is contained in:
@ -244,3 +244,41 @@ QQQ provides the mechanism for UI's to present and manage such scripts (e.g., th
|
|||||||
* `scriptTypeId` - *Serializable (typically Integer), Required* - primary key value from the `"scriptType"` table in the instance, to designate the type of the Script.
|
* `scriptTypeId` - *Serializable (typically Integer), Required* - primary key value from the `"scriptType"` table in the instance, to designate the type of the Script.
|
||||||
* `scriptTester` - *QCodeReference* - reference to a class which implements `TestScriptActionInterface`, that can be used by UI's for running an associated script to test it.
|
* `scriptTester` - *QCodeReference* - reference to a class which implements `TestScriptActionInterface`, that can be used by UI's for running an associated script to test it.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
=== Supplemental Meta Data
|
||||||
|
==== QQQ Frontend Material Dashboard
|
||||||
|
When running a QQQ application with the QQQ Frontend Material Dashboard module (QFMD),
|
||||||
|
there are various pieces of supplemental meta-data which can be assigned to a Table,
|
||||||
|
to modify some behaviors for the table in this UI.
|
||||||
|
|
||||||
|
===== Default Quick Filter Field Names
|
||||||
|
QFMD's table query has a "Basic" mode, which will always display a subset of the table's fields as quick-access filters.
|
||||||
|
By default, the "Tier 1" fields on a table (e.g., fields in a Section that is marked as T1) will be used for this purpose.
|
||||||
|
|
||||||
|
However, you can customize which fields are shown as the default quick-filter fields, by providing a list of field names in a
|
||||||
|
`MaterialDashboardTableMetaData` object, placed in the table's `supplementalMetaData`.
|
||||||
|
|
||||||
|
[source,java]
|
||||||
|
----
|
||||||
|
table.withSupplementalMetaData(new MaterialDashboardTableMetaData()
|
||||||
|
.withDefaultQuickFilterFieldNames(List.of("id", "warehouseId", "statusId", "orderDate")));
|
||||||
|
----
|
||||||
|
|
||||||
|
===== Go To Field Names
|
||||||
|
QFMD has a feature where a table's query screen can include a "Go To" button,
|
||||||
|
which a user can hit to open a modal popup, into which the user can enter a record's identifier,
|
||||||
|
to be brought directly to the record matching that identifier.
|
||||||
|
|
||||||
|
To use this feature, the table must have a List of `GotoFieldNames` set in its
|
||||||
|
`MaterialDashboardTableMetaData` object in the table's `supplementalMetaData`.
|
||||||
|
|
||||||
|
Each entry in this list is actually a list of fields, e.g., to account for a multi-value unique-key.
|
||||||
|
|
||||||
|
[source,java]
|
||||||
|
----
|
||||||
|
table.withSupplementalMetaData(new MaterialDashboardTableMetaData()
|
||||||
|
.withGotoFieldNames(List.of(
|
||||||
|
List.of("id"),
|
||||||
|
List.of("partnerName", "partnerOrderId"))));
|
||||||
|
----
|
||||||
|
@ -2,16 +2,50 @@
|
|||||||
== Widgets
|
== Widgets
|
||||||
include::../variables.adoc[]
|
include::../variables.adoc[]
|
||||||
|
|
||||||
#TODO#
|
Widgets are the most customizable UI components in QQQ.
|
||||||
|
They can be used either on App Home Screens (e.g., as Dashboard screens),
|
||||||
|
or they can be included into Record View screens.
|
||||||
|
|
||||||
|
QQQ defines several types of widgets, such as charts (pie, bar, line),
|
||||||
|
numeric displays, application-populated tables, or even fully custom HTML.
|
||||||
|
|
||||||
=== QWidgetMetaData
|
=== QWidgetMetaData
|
||||||
A Widget is defined in a QQQ Instance in a `*QWidgetMetaData*` object.
|
A Widget is defined in a QQQ Instance in a `*QWidgetMetaData*` object.
|
||||||
|
|
||||||
#TODO#
|
|
||||||
|
|
||||||
*QWidgetMetaData Properties:*
|
*QWidgetMetaData Properties:*
|
||||||
|
|
||||||
* `name` - *String, Required* - Unique name for the widget within the QQQ Instance.
|
* `name` - *String, Required* - Unique name for the widget within the QQQ Instance.
|
||||||
|
* `type` - *String, Required* - Specifies the UI & data type for the widget.
|
||||||
|
* `label` - *String* - User-facing header or title for a widget.
|
||||||
|
* `tooltip` - *String* - Text contents to be placed in a tooltip associated with the widget's label in the UI.
|
||||||
|
** Values should come from the `WidgetType` enum's `getType()` method (e.g., `WidgetType.BAR_CHART.getType()`)
|
||||||
|
* `gridColumns` - *Integer* - for a desktop-sized screen, in a 12-based grid,
|
||||||
|
how many columns the widget should consume.
|
||||||
|
* `codeReference` - *QCodeReference, Required* - Reference to the custom code,
|
||||||
|
a subclass of `AbstractWidgetRenderer`, which is responsible for loading data to render the widget.
|
||||||
|
* `footerHTML` - *String* - HTML String, which, if present, will be displayed in the
|
||||||
|
footer of the widget (not supported by all widget types).
|
||||||
|
* `isCard` - *boolean, default false* #TODO#
|
||||||
|
* `showReloadButton` - *boolean, default true* #TODO#
|
||||||
|
* `showExportButton` - *boolean, default false* #TODO#
|
||||||
|
* `dropdowns` - #TODO#
|
||||||
|
* `storeDropdownSelections` - *boolean* #TODO#
|
||||||
|
* `icons` - *Map<String, QIcon>* #TODO#
|
||||||
|
* `defaultValues` - *Map<String, Serializable>* #TODO#
|
||||||
|
|
||||||
#TODO#
|
There are also some subclasses of `QWidgetMetaData`, for some specific widget types:
|
||||||
|
|
||||||
|
*ParentWidgetMetaData Properties:*
|
||||||
|
|
||||||
|
* `title` - *String* #TODO - how does this differ from label?#
|
||||||
|
* `childWidgetNameList` - *List<String>, Required*
|
||||||
|
* `childProcessNameList` - *List<String>* #TODO appears unused - check, and delete#
|
||||||
|
* `laytoutType` - *enum of GRID or TABS, default GRID*
|
||||||
|
|
||||||
|
*QNoCodeWidgetMetaData Properties:*
|
||||||
|
|
||||||
|
* `values` - *List<AbstractWidgetValueSource>* #TODO#
|
||||||
|
* `outputs` - *List<AbstractWidgetOutput>* #TODO#
|
||||||
|
|
||||||
|
#TODO - Examples#
|
||||||
|
|
||||||
|
224
docs/misc/RenderingWidgets.adoc
Normal file
224
docs/misc/RenderingWidgets.adoc
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
== Rendering Widgets
|
||||||
|
include::../variables.adoc[]
|
||||||
|
|
||||||
|
=== WidgetRenderer classes
|
||||||
|
In general, to fully implement a Widget, you must define its `QWidgetMetaData`,
|
||||||
|
and supply a subclass of `AbstractWidgetRenderer`, to provide the data to the widget.
|
||||||
|
(Note the "No Code" category of widgets, which are an exception to this generalization).
|
||||||
|
|
||||||
|
The only method required in a subclass of `AbstractWidgetRenderer` is:
|
||||||
|
|
||||||
|
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||||
|
|
||||||
|
The fields available in `RenderWidgetInput` are:
|
||||||
|
|
||||||
|
- `Map<String, String> queryParams` - These are parameters supplied by the frontend, for example,
|
||||||
|
if a user selected values from dropdowns to control a dimension of your widget, those name/value
|
||||||
|
pairs would be in this map. Similarly, if your widget is being included on a record view screen, then
|
||||||
|
the record's primary key will be in this map.
|
||||||
|
- `QWidgetMetaDataInterface widgetMetaData` - This is the meta-data for the widget being
|
||||||
|
rendered. This can be useful in case you are using the same renderer class for multiple widgets.
|
||||||
|
|
||||||
|
The only field in `RenderWidgetOutput` is:
|
||||||
|
|
||||||
|
- `QWidgetData widgetData` - This is a base class, with several attributes, and more importantly,
|
||||||
|
several subclasses, specific to the type of widget that is being rendered.
|
||||||
|
|
||||||
|
==== Widget-Type Specific Rendering Details
|
||||||
|
Different widget types expect & require different types of values to be set in the `RenderWidgetOutput` by their renderers.
|
||||||
|
|
||||||
|
===== Pie Chart
|
||||||
|
The `WidgetType.PIE_CHART` requires an object of type `ChartData`.
|
||||||
|
The fields on this type are:
|
||||||
|
|
||||||
|
* `chartData` an instance of `ChartData.Data`, which has the following fields:
|
||||||
|
** `labels` - *List<String>, required* - the labels for the slices of the pie.
|
||||||
|
** `datasets` - *List<Dataset> required* - the data for each slice of the pie.
|
||||||
|
For a Pie chart, only a single entry in this list is used
|
||||||
|
(other chart types using `ChartData` may support more than 1 entry in this list).
|
||||||
|
Fields in this object are:
|
||||||
|
*** `label` - *String, required* - a label to describe the dataset as a whole.
|
||||||
|
e.g., "Orders" for a pie showing orders of different statuses.
|
||||||
|
*** `data` - *List<Number>, required* - the data points for each slice of the pie.
|
||||||
|
*** `color` - *String* - HTML color for the slice
|
||||||
|
*** `urls` - *List<String>* - Optional URLs for slices of the pie to link to when clicked.
|
||||||
|
*** `backgroundColors` - *List<String>* - Optional HTML color codes for each slice of the pie.
|
||||||
|
|
||||||
|
[source,java]
|
||||||
|
.Pie chart widget example
|
||||||
|
----
|
||||||
|
// meta data
|
||||||
|
new QWidgetMetaData()
|
||||||
|
.withName("pieChartExample")
|
||||||
|
.withType(WidgetType.PIE_CHART.getType())
|
||||||
|
.withGridColumns(4)
|
||||||
|
.withIsCard(true)
|
||||||
|
.withLabel("Pie Chart Example")
|
||||||
|
.withCodeReference(new QCodeReference(PieChartExampleRenderer.class));
|
||||||
|
|
||||||
|
// renderer
|
||||||
|
private List<String> labels = new ArrayList<>();
|
||||||
|
private List<String> colors = new ArrayList<>();
|
||||||
|
private List<Number> data = new ArrayList<>();
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** helper method - to add values for a slice to the lists
|
||||||
|
*******************************************************************************/
|
||||||
|
private void addSlice(String label, String color, Number datum)
|
||||||
|
{
|
||||||
|
labels.add(label);
|
||||||
|
colors.add(color);
|
||||||
|
data.add(datum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** main method of the widget renderer
|
||||||
|
*******************************************************************************/
|
||||||
|
@Override
|
||||||
|
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||||
|
{
|
||||||
|
addSlice("Apple", "#FF0000", 100);
|
||||||
|
addSlice("Orange", "#FF8000", 150);
|
||||||
|
addSlice("Banana", "#FFFF00", 75);
|
||||||
|
addSlice("Lime", "#00FF00", 100);
|
||||||
|
addSlice("Blueberry", "#0000FF", 200);
|
||||||
|
|
||||||
|
ChartData chartData = new ChartData()
|
||||||
|
.withChartData(new ChartData.Data()
|
||||||
|
.withLabels(labels)
|
||||||
|
.withDatasets(List.of(
|
||||||
|
new ChartData.Data.Dataset()
|
||||||
|
.withLabel("Flavor")
|
||||||
|
.withData(data)
|
||||||
|
.withBackgroundColors(colors)
|
||||||
|
.withUrls(urls))));
|
||||||
|
|
||||||
|
return (new RenderWidgetOutput(chartData));
|
||||||
|
}
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
===== Bar Chart
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Stacked Bar Chart
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Horizontal Bar Chart
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Child Record List
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Line Chart
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Small Line Chart
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Statistics
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Parent Widget
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Composite
|
||||||
|
A `WidgetType.COMPOSITE` is built by using one or more smaller elements, known as `Blocks`.
|
||||||
|
Note that `Blocks` can also be used in the data of some other widget types
|
||||||
|
(specifically, within a cell of a Table-type widget, or (in the future?) as a header above a pie or bar chart).
|
||||||
|
|
||||||
|
A composite widget renderer must return data of type `CompositeWidgetData`,
|
||||||
|
which has the following fields:
|
||||||
|
|
||||||
|
* `blocks` - *List<AbstractBlockWidgetData>, required* - The blocks (1 or more) being composited together to make the widget.
|
||||||
|
See below for details on the specific Block types.
|
||||||
|
* `styleOverrides` - *Map<String, Serializable>* - Optional map of CSS attributes
|
||||||
|
(named following javascriptStyleCamelCase) to apply to the `<div>` element that wraps the rendered blocks.
|
||||||
|
* `layout` - *Layout enum* - Optional specifier for how the blocks should be laid out.
|
||||||
|
e.g., predefined sets of CSS attributes to achieve specific layouts.
|
||||||
|
** Note that some blocks are designed to work within composites with specific layouts.
|
||||||
|
Look for matching names, such as `Layout.BADGES_WRAPPER` to go with `NumberIconBadgeBlock`.
|
||||||
|
|
||||||
|
[source,java]
|
||||||
|
.Composite widget example - consisting of 3 Progress Bar Blocks, and one Divider Block
|
||||||
|
----
|
||||||
|
// meta data
|
||||||
|
new QWidgetMetaData()
|
||||||
|
.withName("compositeExample")
|
||||||
|
.withType(WidgetType.COMPOSITE.getType())
|
||||||
|
.withGridColumns(4)
|
||||||
|
.withIsCard(true)
|
||||||
|
.withLabel("Composite Example")
|
||||||
|
.withCodeReference(new QCodeReference(CompositeExampleRenderer.class));
|
||||||
|
|
||||||
|
// renderer
|
||||||
|
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||||
|
{
|
||||||
|
CompositeWidgetData data = new CompositeWidgetData();
|
||||||
|
|
||||||
|
data.addBlock(new ProgressBarBlockData()
|
||||||
|
.withValues(new ProgressBarValues()
|
||||||
|
.withHeading("Blocks")
|
||||||
|
.withPercent(new BigDecimal("78.5"))));
|
||||||
|
|
||||||
|
data.addBlock(new ProgressBarBlockData()
|
||||||
|
.withValues(new ProgressBarValues()
|
||||||
|
.withHeading("Progress")
|
||||||
|
.withPercent(new BigDecimal(0))));
|
||||||
|
|
||||||
|
data.addBlock(new DividerBlockData());
|
||||||
|
|
||||||
|
data.addBlock(new ProgressBarBlockData()
|
||||||
|
.withStyles(new ProgressBarStyles().withBarColor("#C0C000"))
|
||||||
|
.withValues(new ProgressBarValues()
|
||||||
|
.withHeading("Custom Color")
|
||||||
|
.withPercent(new BigDecimal("75.3"))));
|
||||||
|
|
||||||
|
return (new RenderWidgetOutput(data));
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
|
||||||
|
===== Table
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== HTML
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Divider
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Process
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Stepper
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Data Bag Viewer
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
===== Script Viewer
|
||||||
|
#todo#
|
||||||
|
|
||||||
|
=== Block-type Specific Rendering Details
|
||||||
|
For Composite-type widgets (or other widgets which can include blocks),
|
||||||
|
there are specific data classes required to be returned by the widget renderer.
|
||||||
|
|
||||||
|
Each block type defines a subclass of `AbstractBlockWidgetData`,
|
||||||
|
which is a generic class with 3 type parameters:
|
||||||
|
|
||||||
|
* `V` - an implementation of `BlockValuesInterface` - to define the type of values that the block uses.
|
||||||
|
* `S` - an implementation of `BlockSlotsInterface` (expected to be an `enum`) - to define the "slots" in the block,
|
||||||
|
that can have Tooltips and/or Links applied to them.
|
||||||
|
* `SX` - an implementation of `BlockStylesInterface` - to define the types of style customizations that the block supports.
|
||||||
|
|
||||||
|
These type parameters are designed to ensure type-safety for the application developer,
|
||||||
|
to ensure that only
|
||||||
|
|
||||||
|
=== Additional Tips
|
||||||
|
|
||||||
|
* To make a Dashboard page (e.g., an App consisting of Widgets) with a parent widget use the parent widget's label as the page's label:
|
||||||
|
** On the `QAppMetaData` that contains the Parent widget, call
|
||||||
|
`.withSupplementalMetaData(new MaterialDashboardAppMetaData().withShowAppLabelOnHomeScreen(false))`.
|
||||||
|
** In the Parent widget's renderer, on the `ParentWidgetData`, call `setLabel("My Label")` and
|
||||||
|
`setIsLabelPageTitle(true)`.
|
272
docs/utilities/RecordLookupHelper.adoc
Normal file
272
docs/utilities/RecordLookupHelper.adoc
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
== RecordLookupHelper
|
||||||
|
include::../variables.adoc[]
|
||||||
|
|
||||||
|
`RecordLookupHelper` is a utility class that exists to help you... lookup records :)
|
||||||
|
|
||||||
|
OK, I'll try to give a little more context:
|
||||||
|
|
||||||
|
=== Motivation 1: Performance
|
||||||
|
One of the most significant performance optimizations that the team behind QQQ has found time and time again,
|
||||||
|
is to minimize the number of times you have to perform an I/O operation.
|
||||||
|
To just say it more plainly:
|
||||||
|
Make fewer calls to your database (or other backend).
|
||||||
|
|
||||||
|
This is part of why the DML actions in QQQ (InsertAction, UpdateAction, DeleteAction) are all written to work on multiple records:
|
||||||
|
If you've got to insert 1,000 records, the performance difference between doing that as 1,000 SQL INSERT statements vs. just 1 statement cannot be overstated.
|
||||||
|
|
||||||
|
Similarly then, for looking up records:
|
||||||
|
If we can do 1 round-trip to the database backend - that is - 1 query to fetch _n_ records,
|
||||||
|
then in almost all cases it will be significantly faster than doing _n_ queries, one-by-one, for those _n_ records.
|
||||||
|
|
||||||
|
The primary reason why `RecordLookupHelper` exists is to help you cut down on the number of times you have to make a round-trip to a backend data store to fetch records within a process.
|
||||||
|
|
||||||
|
[sidebar]
|
||||||
|
This basically is version of caching, isn't it?
|
||||||
|
Take a set of data from "far away" (e.g., database), and bring it "closer" (local or instance variables), for faster access.
|
||||||
|
So we may describe this as a "cache" through the rest of this document.
|
||||||
|
|
||||||
|
=== Motivation 2: Convenience
|
||||||
|
|
||||||
|
So, given that one wants to try to minimize the number of queries being executed to look up data in a QQQ processes,
|
||||||
|
one can certainly do this "by-hand" in each process that they write.
|
||||||
|
|
||||||
|
Doing this kind of record caching in a QQQ Process `BackendStep` may be done as:
|
||||||
|
|
||||||
|
* Adding a `Map<Integer, QRecord>` as a field in your class.
|
||||||
|
* Setting up and running a `QueryAction`, with a filter based on the collection of the keys you need to look up, then iterating over (or streaming) the results into the map field.
|
||||||
|
* Getting values out of the map when you need to use them (dealing with missing values as needed).
|
||||||
|
|
||||||
|
That's not so bad, but, it does get a little verbose, especially if you're going to have several such caches in your class.
|
||||||
|
|
||||||
|
As such, the second reason that `RecordLookupHelper` exists, is to be a reusable and convenient way to do this kind of optimization,
|
||||||
|
by providing methods to perform the bulk query & map building operation described above,
|
||||||
|
while also providing some convenient methods for accessing such data after it's been fetched.
|
||||||
|
In addition, a single instance of `RecordLookupHelper` can provide this service for multiple tables at once
|
||||||
|
(e.g., so you don't need to add a field to your class for each type of data that you're trying to cache).
|
||||||
|
|
||||||
|
=== Use Cases
|
||||||
|
==== Preload records, then access them
|
||||||
|
Scenario:
|
||||||
|
|
||||||
|
* We're writing a process `BackendStep` that uses `shipment` records as input.
|
||||||
|
* We need to know the `order` record associated with each `shipment` (via an `orderId` foreign key), for some business logic that isn't germaine to the explanation of `RecordLookupHelper`.
|
||||||
|
* We also to access some field on the `shippingPartner` record assigned to each `shipment`.
|
||||||
|
** Note that here, the `shipment` table has a `partnerCode` field, which relates to the `code` unique-key in the `shippingPartner` table.
|
||||||
|
** It's also worth mentioning, we only have a handful of `shippingPartner` records in our database, and we never expect to have very many more than that.
|
||||||
|
|
||||||
|
[source,java]
|
||||||
|
.Example of a process step using a RecordLookupHelper to preload records
|
||||||
|
----
|
||||||
|
public class MyShipmentProcessStep implements BackendStep
|
||||||
|
{
|
||||||
|
// Add a new RecordLookupHelper field, which will "cache" both orders and shippingPartners
|
||||||
|
private RecordLookupHelper recordLookupHelper = new RecordLookupHelper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException;
|
||||||
|
{
|
||||||
|
// lookup the full shippingPartner table (because it's cheap & easy to do so)
|
||||||
|
// use the partner's "code" as the key field (e.g,. they key in the helper's internal map).
|
||||||
|
recordLookupHelper.preloadRecords("shippingPartner", "code");
|
||||||
|
|
||||||
|
// get all of the orderIds from the input shipments
|
||||||
|
List<Serializable> orderIds = input.getRecords().stream()
|
||||||
|
.map(r -> r.getValue("id")).toList();
|
||||||
|
|
||||||
|
// fetch the orders related to by these shipments
|
||||||
|
recordLookupHelper.preloadRecords("order", "id", orderIds);
|
||||||
|
|
||||||
|
for(QRecord shipment : input.getRecords())
|
||||||
|
{
|
||||||
|
// get someConfigField from the shippingPartner assigned to the shipment
|
||||||
|
Boolean someConfig = recordLookupHelper.getRecordValue("shippingPartner", "someConfigField", "code", shipment.getValue("partnerCode"));
|
||||||
|
|
||||||
|
// get the order record assigned to the shipment
|
||||||
|
QRecord order = recordLookupHelper.getRecordByKey("order", "id", shipment.getValue("orderId"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
==== Lazy fetching records
|
||||||
|
Scenario:
|
||||||
|
|
||||||
|
* We have a `BackendStep` that is taking in `purchaseOrderHeader` records, from an API partner.
|
||||||
|
* For each record, we need to make an API call to the partner to fetch the `purchaseOrderLine` records under that header.
|
||||||
|
** In this contrived example, the partner's API forces us to do these lookups order-by-order...
|
||||||
|
* Each `purchaseOrderLine` that we fetch will have a `sku` on it - a reference to our `item` table.
|
||||||
|
** We need to look up each `item` to apply some business logic.
|
||||||
|
** We assume there are very many item records in the backend database, so we don't want to pre-load the full table.
|
||||||
|
Also, we don't know what `sku` values we will need until we fetch the `purchaseOrderLine`.
|
||||||
|
|
||||||
|
This is a situation where we can use `RecordLookupHelper` to lazily fetch the `item` records as we discover them,
|
||||||
|
and it will take care of not re-fetching ones that it has already loaded.
|
||||||
|
|
||||||
|
[source,java]
|
||||||
|
.Example of a process step using a RecordLookupHelper to lazy fetch records
|
||||||
|
----
|
||||||
|
public class MyPurchaseOrderProcessStep implements BackendStep
|
||||||
|
{
|
||||||
|
// Add a new RecordLookupHelper field, which will "cache" lazy-loaded item records
|
||||||
|
private RecordLookupHelper recordLookupHelper = new RecordLookupHelper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException;
|
||||||
|
{
|
||||||
|
for(QRecord poHeader : input.getRecords())
|
||||||
|
{
|
||||||
|
// fetch the lines under the header
|
||||||
|
Serializable poNo = poHeader.getValue("poNo");
|
||||||
|
List<QRecord> poLines = new QueryAction().execute(new QueryInput("purchaseOrderLine")
|
||||||
|
.withFilter(new QQueryFilter(new QFilterCriteria("poNo", EQUALS, poNo))));
|
||||||
|
|
||||||
|
for(QRecord poLine : poLines)
|
||||||
|
{
|
||||||
|
// use recordLookupHelper to lazy-load item records by SKU.
|
||||||
|
QRecord item = recordLookupHelper.getRecordByKey("item", "sku", poLine.getValue("sku"));
|
||||||
|
|
||||||
|
// business logic related to item performed here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
In this example, we will be doing exactly 1 query on the `item` table for each unique `sku` that is found across all of the `poLine` records we process.
|
||||||
|
That is to say, if the same `sku` appears on only 1 `poLine`, or if it appears on 100 `poLines`, we will still only query once for that `sku`.
|
||||||
|
|
||||||
|
A slight tweak could be made to the example above, to make 1 `item` table lookup for each `poHeader` record:
|
||||||
|
|
||||||
|
[source,java]
|
||||||
|
.Tweaked example doing 1 item lookup per poLine
|
||||||
|
----
|
||||||
|
// continuing from above, after the List<QRecord> poLines has been built
|
||||||
|
|
||||||
|
// get all of the skus from the lines
|
||||||
|
List<Serializable> skus = poLines.stream().map(r -> r.getValue("sku")).toList();
|
||||||
|
|
||||||
|
// preload the items for the skus
|
||||||
|
recordLookupHelper.preloadRecords("item", "sku", new QQueryFilter(new QFilterCriteria("sku", IN, skus)));
|
||||||
|
|
||||||
|
for(QRecord poLine : poLines)
|
||||||
|
{
|
||||||
|
// get the items from the helper
|
||||||
|
QRecord item = recordLookupHelper.getRecordByKey("item", "sku", poLine.getValue("sku"));
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
In this example, we've made a trade-off: We will query the `item` table exactly 1 time for each `poHeader` that we process.
|
||||||
|
However, if the same `sku` is on every PO that we process, we will end up fetching it multiple times.
|
||||||
|
|
||||||
|
This could end up being better or worse than the previous example, depending on the distribution of the data we are dealing with.
|
||||||
|
|
||||||
|
A further tweak, a hybrid approach, could potentially reap the benefits of both of these examples (at the tradeoff of, more code, more complexity):
|
||||||
|
|
||||||
|
[source,java]
|
||||||
|
.Tweaked example doing 1 item lookup per poLine, but only for not-previously-encountered skus
|
||||||
|
----
|
||||||
|
// Critically - we must tell our recordLookupHelper to NOT do any one-off lookups in this table
|
||||||
|
recordLookupHelper.setMayNotDoOneOffLookups("item", "sku");
|
||||||
|
|
||||||
|
// continuing from above, after the List<QRecord> poLines has been built
|
||||||
|
|
||||||
|
// get all of the skus from the lines
|
||||||
|
List<Serializable> skus = poLines.stream().map(r -> r.getValue("sku")).toList();
|
||||||
|
|
||||||
|
// determine which skus have not yet been loaded - e.g., they are not in the recordLookupHelper.
|
||||||
|
// this is why we needed to tell it above not to do one-off lookups; else it would lazy-load each sku here.
|
||||||
|
List<Serializable> skusToLoad = new ArrayList<>();
|
||||||
|
for(Serializable sku : skus)
|
||||||
|
{
|
||||||
|
if(recordLookupHelper.getRecordByKey("item", "sku", sku) == null)
|
||||||
|
{
|
||||||
|
skusToLoad.add(sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// preload the item records for any skus that are still needed
|
||||||
|
if(!skusToLoad.isEmpty())
|
||||||
|
{
|
||||||
|
recordLookupHelper.preloadRecords("item", "sku",
|
||||||
|
new QQueryFilter(new QFilterCriteria("sku", IN, skusToLoad)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// continue as above
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
In this example, we will start by querying the `item` table once for each `poHeader`, but,
|
||||||
|
if we eventually encounter a PO where all of its `skus` have already been loaded, then we may be able to avoid any `item` queries for such a PO.
|
||||||
|
|
||||||
|
=== Implementation Details
|
||||||
|
|
||||||
|
* Internally, an instance of `RecordLookupHelper` maintains a number of `Maps`,
|
||||||
|
with QQQ table names and field names as keys.
|
||||||
|
* The accessing/lazy-fetching methods (e.g., any method whose name starts with `getRecord`)
|
||||||
|
all begin by looking in these internal maps for the `tableName` and `keyFieldName` that they take as parameters.
|
||||||
|
** If they find an entry in the maps, then it is used for producing a return value.
|
||||||
|
** If they do not find an entry, then they will perform the a `QueryAction`,
|
||||||
|
to try to fetch the requested record from the table's backend.
|
||||||
|
*** Unless the `setMayNotDoOneOffLookups` method has been called for the `(tableName, keyFieldName)` pair.
|
||||||
|
|
||||||
|
=== Full API
|
||||||
|
|
||||||
|
==== Methods for accessing and lazy-fetching
|
||||||
|
* `getRecordByKey(String tableName, String keyFieldName, Serializable key)`
|
||||||
|
|
||||||
|
Get a `QRecord` from `tableName`, where `keyFieldName` = `key`.
|
||||||
|
|
||||||
|
* `getRecordValue(String tableName, String requestedField, String keyFieldName, Serializable key)`
|
||||||
|
|
||||||
|
Get the field `requestedField` from the record in `tableName`, where `keyFieldName` = `key`, as a `Serializable`.
|
||||||
|
If the record is not found, `null` is returned.
|
||||||
|
|
||||||
|
* `getRecordValue(String tableName, String requestedField, String keyFieldName, Serializable key, Class<T> type)`
|
||||||
|
|
||||||
|
Get the field `requestedField` from the record in `tableName`, where `keyFieldName` = `key`, as an instance of `type`.
|
||||||
|
If the record is not found, `null` is returned.
|
||||||
|
|
||||||
|
* `getRecordId(String tableName, String keyFieldName, Serializable key)`
|
||||||
|
|
||||||
|
Get the primary key of the record in `tableName`, where `keyFieldName` = `key`, as a `Serializable`.
|
||||||
|
If the record is not found, `null` is returned.
|
||||||
|
|
||||||
|
* `getRecordId(String tableName, String keyFieldName, Serializable key, Class<T> type)`
|
||||||
|
|
||||||
|
Get the primary key of the record in `tableName`, where `keyFieldName` = `key`, as an instance of `type`.
|
||||||
|
If the record is not found, `null` is returned.
|
||||||
|
|
||||||
|
* `getRecordByUniqueKey(String tableName, Map<String, Serializable> uniqueKey)`
|
||||||
|
|
||||||
|
Get a `QRecord` from `tableName`, where the record matches the field/value pairs in `uniqueKey`.
|
||||||
|
|
||||||
|
_Note: this method does not use the same internal map as the rest of the class.
|
||||||
|
As such, it does not take advantage of any data fetched via the preload methods.
|
||||||
|
It is only used for caching lazy-fetches._
|
||||||
|
|
||||||
|
==== Methods for preloading
|
||||||
|
* `preloadRecords(String tableName, String keyFieldName)`
|
||||||
|
|
||||||
|
Query for all records from `tableName`, storing them in an internal map keyed by the field `keyFieldName`.
|
||||||
|
|
||||||
|
* `preloadRecords(String tableName, String keyFieldName, QQueryFilter filter)`
|
||||||
|
|
||||||
|
Query for records matching `filter` from `tableName`,
|
||||||
|
storing them in an internal map keyed by the field `keyFieldName`.
|
||||||
|
|
||||||
|
* `preloadRecords(String tableName, String keyFieldName, List<Serializable> inList)`
|
||||||
|
|
||||||
|
Query for records with the field `keyFieldName` having a value in `inList` from `tableName`,
|
||||||
|
storing them in an internal map keyed by the field `keyFieldName`.
|
||||||
|
|
||||||
|
==== Config Methods
|
||||||
|
* `setMayNotDoOneOffLookups(String tableName, String fieldName)`
|
||||||
|
|
||||||
|
For cases where you know that you have preloaded records for `tableName`, keyed by `fieldName`,
|
||||||
|
and you know that some of the keys may not have been found,
|
||||||
|
so you want to avoid doing a query when a missed key is found in one of the `getRecord...` methods,
|
||||||
|
then if you call this method, an internal flag is set, which will prevent any such one-off lookups.
|
||||||
|
|
||||||
|
In other words, if this method has been called for a `(tableName, fieldName)` pair,
|
||||||
|
then the `getRecord...` methods will only look in the internal map for records,
|
||||||
|
and no queries will be performed to look for records.
|
Reference in New Issue
Block a user