Compare commits

..
Author SHA1 Message Date
james.maes 3683375dbf ci: pin settings-safe QQQ orb 0.6.8
Use reviewed release tooling for QRun-IO/qqq-all#10; keep release versions unchanged.
2026-09-30 10:51:05 -05:00
james.maes 92a1425c34 build: re-pin to qbit-build-parent 2.0.0 (#7)
Closes QRun-IO/qqq#768. Part of QRun-IO/qqq-all#1.
2026-09-26 01:09:15 -05:00
james.maes 7ac729eff8 docs: note qqq 4.0 baseline in README and CLAUDE.md
The README prerequisite and the CLAUDE.md build facts now match the 2.0.0 parent.

Part of QRun-IO/qqq-all#1
2026-09-25 20:50:39 -05:00
james.maes d1a8515c44 ci: keep package installs unattended on machine executors
Adds qbit-quick-search's configure-package-install pre-step so needrestart cannot hang install_java (QRun-IO/qqq-orb#11).

Part of QRun-IO/qqq-all#1
2026-09-25 20:50:24 -05:00
james.maes 341827916c build: add opt-in qqq-snapshot profile
-Pqqq-snapshot imports qqq-bom-pom 4.1.0-SNAPSHOT (override with -Dqqq.snapshot.version) and adds the Central snapshots repository.

Part of QRun-IO/qqq-all#1
2026-09-25 20:49:24 -05:00
james.maes ca909fdb53 test: cover api field names in filters and extract associations
Guards the qqq 4.0 migrations: filter validation and execution resolve fields per api version, and the extract step still fetches associations.

Part of QRun-IO/qqq-all#1
2026-09-25 20:47:45 -05:00
james.maes dd7c7f4fda build: re-pin to qbit-build-parent 2.0.0 (qqq 4.0.0)
The parent's qqq BOM now sets the qqq module versions; material dashboard moves to 0.41.0 (built for qqq 4.0) and code uses the qqq 4.0 replacements for removed APIs.

Part of QRun-IO/qqq-all#1
2026-09-25 20:47:27 -05:00
james.maes 34f620ea43 chore: merge main into develop
Brings main's LICENSE, NOTICE, CODEOWNERS, CLAUDE.md, release config and README; keeps develop's orb 0.6 and revision 0.114.0-SNAPSHOT.

Part of QRun-IO/qqq-all#1
2026-09-25 20:41:44 -05:00
Darin Kelkhoff 80a632634a Merge pull request #6 from QRun-IO/feature/follow-all-exposed-joins-in-input-record-filter-step
Feature/follow all exposed joins in input record filter step
2026-03-24 16:37:14 -05:00
Darin Kelkhoff 349882379c chore: cleanup from code review 2026-02-16 16:38:54 -06:00
Darin Kelkhoff 3b8f659b69 chore: update other qqq deps to pinned branch version 2026-02-16 16:30:00 -06:00
Darin Kelkhoff 8770f6331d chore: update to kingsrook/qqq-orb@0.6 2026-02-16 16:23:46 -06:00
Darin Kelkhoff a1fa73bf45 chore: update qbit-build-parent to 1.5.1 (java 21) 2026-02-16 16:21:01 -06:00
Darin Kelkhoff 2149b265ad chore: update qqq version to tagged branch build (fix for validation w/ flipped joins) 2026-02-16 16:17:07 -06:00
Darin Kelkhoff 6d0ad8b033 feat(recordWorkflowsFilterJoins): Improve support for filtering by an exposed join on a table, including relationships including 'many's.
Also expose a class, RecordWorkflowContextJoinedRecordHelper, that application-defined workflow types can use to make their filter steps work the same way.

Add large test on InputRecordFilterStep - that tries to do as many versions of 1-n, n-1, n-1-n, 1-n-1, etc, that my friend claude and i could come up with - along with a CompanyHierarchyTablesAndJoinsMetaDataProducer that defines a nice complex set of tables & joins.
2026-02-16 16:04:40 -06:00
Darin Kelkhoff 95f4ae1fa5 fix(containers): fix case where a container that ends with a conditional, with an empty path - when that's executed, the null to-step-no coming out of that step was ending the workflow too early.
instead of, break the loop at that point, and pop the container if in a container.

add a test for this - and - refactor the test class to avoid nested blocks, letting each test-case be its own method
2026-01-26 15:24:00 -06:00
Darin Kelkhoff 84e1ef8dd8 fix: correct the names of these widgets 2026-01-26 14:07:32 -06:00
Darin Kelkhoff 4a13f52556 fix(recordWorkflows): filter workflowId field by workflowTypeName when adding it to tableTriggers 2025-12-15 11:24:46 -06:00
Darin Kelkhoff 4965ba611a Merge pull request #3 from QRun-IO/feature/multi-forking-steps
Feature/multi forking steps
2025-12-01 10:32:49 -06:00
Darin Kelkhoff 93581b13a1 fix: not frontend publish, guy 2025-11-26 16:31:05 -06:00
Darin Kelkhoff 0c1a7c24e6 chore: add publish_feature 2025-11-26 16:24:27 -06:00
Darin Kelkhoff 1fc7091657 feat(apiVersions): Look for apiName and apiVersion params as top-level params (in addition to under "workflowRevision." prefix) 2025-11-24 16:33:23 -06:00
Darin Kelkhoff 566793ff00 feat(filterInputs): Refactor getFilterFromInput, extracting getFilterFromJsonOrObject from it 2025-11-24 16:32:49 -06:00
Darin Kelkhoff d119fd8a90 feat(multiForkingSteps): Add support for multi-forking / variable-output steps
including:
- allowedDescendantStepTypes property on workflowStepType
- add workflow-level validator
- new field in WorkflowStepOutput to indicate that a step within a multi-forking execution should terminate the entire workflow.
- allow null toStep to be stored in a link, to help track flow out of variable steps at end-of-graph
- replace GetWorkflowStepSummaryProcess with GetWorkflowStepDynamicValuesProcess, which also includes outboundLinkOptions for VARIABLE mode steps
2025-11-24 16:32:00 -06:00
Tim Chamberlain 6db3d2aa0f initialized input data on workflow step type 2025-10-30 09:58:30 -05:00
CircleCI 6846aff6dc Bump version to 0.114.0-SNAPSHOT [skip ci] 2025-10-29 17:40:03 +00:00
Tim Chamberlain 4f115e53c6 updates from code review feedback 2025-10-29 12:37:53 -05:00
Tim Chamberlain 20f0d38dd1 updated help help on dynamic value field 2025-10-28 15:49:29 -05:00
Tim Chamberlain 09eca84ecb updated to handle workflow type help help 2025-10-28 14:46:28 -05:00
CircleCI 0950a439e4 Bump version to 0.3.0-SNAPSHOT [skip ci] 2025-10-22 13:42:26 +00:00
james.maes 7e67a581e0 build(circleci): updated to 0.3.12 - fixed develop branch detection 2025-10-22 08:40:32 -05:00
james.maes 6c3de53631 Empty commit and push - testing circleci qqq-orb 2025-10-22 08:32:17 -05:00
Darin Kelkhoff 41c6cba913 fix(helpContent): handle null helpContent object as signal to remove content - not to null pointer or build list with null member 2025-10-20 16:04:53 -05:00
CircleCI 51e4a14778 Bump version to 0.2.0 [skip ci] 2025-09-29 20:35:34 +00:00
james.maes aa804e2fc5 ci(circleci): updated to latest production qqq orb - 0.3.8 2025-09-29 15:34:30 -05:00
Darin Kelkhoff 07f72dae49 Merge pull request #2 from Kingsrook/feature/ui-fixes-2025-09
Feature/UI fixes 2025 09
2025-09-23 15:34:25 -05:00
Darin Kelkhoff 910d5c9858 fix(updateRecordFieldStep): Fix cases where possible values weren't being translated in step summary/log (when "tableName." prefix was present).
Also factor some static util method out, for use in application-defined steps of a similar nature.
2025-09-23 11:13:18 -05:00
Darin Kelkhoff 6ef1727eb8 feat(updateRecordFieldStep): Adjust possible value filters that use "${input.myFieldName}" to consider getting that value (via coalescing) from "??${input.workflow.myFieldName}"
Add initial tests on this class
2025-09-23 11:11:53 -05:00
Darin Kelkhoff fce13986b4 feat(storeRevision): more error checking and update format of exception message if there are errors
- put newlines between sub-errors (UI will display as multiple lines)
- put Step # in front of all per-step error messages
- Check for null workflow step type specifically
- Check for missing or invalid JSON for steps and links
- avoid NPE if null user in session
- initial test on this process (at least its validation logic)
2025-09-23 10:52:05 -05:00
james.maes c8f3bb4312 chore: bump snapshot version after release 2025-09-18 14:47:43 -05:00
53 changed files with 4271 additions and 645 deletions

No files matched your search

+86 -10
View File
@@ -1,14 +1,90 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
workflows:
setup:
test_only:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /v.*/
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|main|release/.*|hotfix/.*|integration.*)/
tags:
only: []
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only:
- develop
publish_feature:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: feature
filters:
branches:
only:
- feature/.*
tags:
only: [/publish.*/]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only:
- /release\/.*/
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/v.*/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only:
- /hotfix\/.*/
-7
View File
@@ -1,7 +0,0 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
-23
View File
@@ -1,23 +0,0 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
-74
View File
@@ -1,74 +0,0 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
-16
View File
@@ -1,16 +0,0 @@
profile: qbit
orb_version: '0.8'
default_branch: main
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: v-prefix
test_branch_policy: all
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: false
+4 -3
View File
@@ -14,9 +14,10 @@ vault:
Key facts from the review (see dossier for detail):
- origin/develop is 32 commits ahead of main (unreleased 2026 feature work, pinned to
a throwaway `0.40.0-join-enhancements-*` qqq build); main is the released line on
qqq 0.35.0 via parent `com.kingsrook:qbit-build-parent:1.5.1`.
- develop carries unreleased 2026 feature work plus everything on main, and builds on
qqq 4.0.0 via parent `com.kingsrook:qbit-build-parent:2.0.0` (`-Pqqq-snapshot`
checks qqq 4.1.0-SNAPSHOT); main is the released line (0.4.0) on qqq 0.35.0 via
parent 1.5.1.
- README and CHANGELOG.md do not describe this codebase (template rot) — trust the
source and the dossier instead.
- Licensing is inconsistent (LICENSE/NOTICE Apache-2.0 vs pom + file headers AGPL-3.0
+1 -1
View File
@@ -24,7 +24,7 @@ This QBit provides a declarative workflow engine. Define states and transitions,
### Prerequisites
- QQQ application (v4.1.0+)
- QQQ application (v4.0.0+)
- Database backend configured
### Installation
+40 -21
View File
@@ -21,16 +21,15 @@
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.5.0-SNAPSHOT</revision>
<qqq.version>4.1.0-RC.21</qqq.version>
<revision>0.114.0-SNAPSHOT</revision>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>1.5.1</version>
<version>2.0.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -70,19 +69,6 @@
</scm>
<dependencyManagement>
<dependencies>
<!-- Published QQQ 4.1 core (minimum QQQ 4.1), imported ahead of the parent's BOM. -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -100,10 +86,8 @@
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<!-- 0.27.0-RC.1 references com.kingsrook.qqq.backend.javalin.QJavalinMetaData, which QQQ 4.x moved to com.kingsrook.qqq.middleware.javalin
(NoClassDefFoundError when the form adjusters register). 0.42.0-RC.2 is the published line for 4.x; its public API is a superset. -->
<artifactId>qqq-frontend-material-dashboard</artifactId>
<version>0.42.0-RC.2</version>
<version>0.41.0</version>
</dependency>
<dependency>
@@ -114,9 +98,44 @@
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<profiles>
<!-- Opt-in check against the next qqq line: mvn -B verify -Pqqq-snapshot -->
<!-- (override the version with -Dqqq.snapshot.version=...). Default builds -->
<!-- take the qqq version only from qbit-build-parent (ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
<properties>
<qqq.snapshot.version>4.1.0-SNAPSHOT</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
<repository>
<id>sonatype-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</profile>
</profiles>
</project>
@@ -173,8 +173,9 @@ public class WorkflowsQBitProducer implements QBitMetaDataProducer<WorkflowsQBit
tableTriggerTable.withField(new QFieldMetaData("workflowId", QFieldType.INTEGER)
.withBackendName("workflow_id")
.withPossibleValueSourceName(Workflow.TABLE_NAME)
.withPossibleValueSourceFilter(new QQueryFilter(
new QFilterCriteria("tableName", QCriteriaOperator.EQUALS, "${input.tableName}"))));
.withPossibleValueSourceFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("workflowTypeName", QCriteriaOperator.EQUALS, RecordWorkflowsDefinition.WORKFLOW_TYPE))
.withCriteria(new QFilterCriteria("tableName", QCriteriaOperator.EQUALS, "${input.tableName}"))));
//////////////////////////////////////////////////////////////////////////////
// place workflowId field after scriptId in whatever section scriptId is in //
@@ -28,7 +28,7 @@ package com.kingsrook.qbits.workflows.definition;
** ONE is a regular statement, that just flows to its next.
** CONTAINER is itself a no-op, but it wraps (for UI reasons) a sequence
** TWO is for an if/else
** Unclear exactly how we'd do variable number of outbounds (e.g., a switch)
** VARIABLE is for a variable number - 1 or more
***************************************************************************/
public enum OutboundLinkMode
{
@@ -36,4 +36,5 @@ public enum OutboundLinkMode
ONE,
CONTAINER,
TWO,
VARIABLE
}
@@ -26,6 +26,7 @@ import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.google.gson.reflect.TypeToken;
import com.kingsrook.qbits.workflows.execution.WorkflowStepExecutorInterface;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
@@ -56,8 +57,10 @@ public class WorkflowStepType implements Serializable
private OutboundLinkMode outboundLinkMode;
private List<OutboundLinkOption> outboundLinkOptions;
private ArrayList<QFieldMetaData> inputFields;
private ArrayList<String> inputWidgetNames;
private Set<String> allowedDescendantStepTypes;
private ArrayList<QFieldMetaData> inputFields = new ArrayList<>();
private ArrayList<String> inputWidgetNames = new ArrayList<>();
@@ -83,6 +86,17 @@ public class WorkflowStepType implements Serializable
/***************************************************************************
* for a variable-output type step, it must dynamically define its outbound
* links
***************************************************************************/
public List<OutboundLinkOption> getDynamicOutboundLinkOptions(Integer workflowId, Map<String, Serializable> inputValues) throws QException
{
return (null);
}
/***************************************************************************
**
***************************************************************************/
@@ -409,6 +423,7 @@ public class WorkflowStepType implements Serializable
}
/*******************************************************************************
* Getter for validator
* @see #withValidator(QCodeReference)
@@ -445,4 +460,42 @@ public class WorkflowStepType implements Serializable
}
/*******************************************************************************
* Getter for allowedDescendantStepTypes
* @see #withAllowedDescendantStepTypes(Set)
*******************************************************************************/
public Set<String> getAllowedDescendantStepTypes()
{
return (this.allowedDescendantStepTypes);
}
/*******************************************************************************
* Setter for allowedDescendantStepTypes
* @see #withAllowedDescendantStepTypes(Set)
*******************************************************************************/
public void setAllowedDescendantStepTypes(Set<String> allowedDescendantStepTypes)
{
this.allowedDescendantStepTypes = allowedDescendantStepTypes;
}
/*******************************************************************************
* Fluent setter for allowedDescendantStepTypes
*
* @param allowedDescendantStepTypes
* to limit what step types can be placed inside this step type within a graph.
* for example,
* @return this
*******************************************************************************/
public WorkflowStepType withAllowedDescendantStepTypes(Set<String> allowedDescendantStepTypes)
{
this.allowedDescendantStepTypes = allowedDescendantStepTypes;
return (this);
}
}
@@ -25,16 +25,19 @@ package com.kingsrook.qbits.workflows.definition;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.google.gson.reflect.TypeToken;
import com.kingsrook.qbits.workflows.execution.WorkflowTypeExecutorInterface;
import com.kingsrook.qbits.workflows.execution.WorkflowTypeTesterInterface;
import com.kingsrook.qbits.workflows.execution.WorkflowTypeValidatorInterface;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -50,9 +53,11 @@ public class WorkflowType implements Serializable
private String label;
private String description;
private QCodeReference executor;
private QCodeReference validator;
private QCodeReference tester;
private ArrayList<WorkflowStepTypeCategory> stepTypeCategories;
protected Map<String, List<QHelpContent>> helpContent;
private ArrayList<WorkflowStepTypeCategory> stepTypeCategories;
@@ -81,6 +86,11 @@ public class WorkflowType implements Serializable
qInstanceValidator.validateSimpleCodeReference("WorkflowType [" + name + "]: executor ", executor, WorkflowTypeExecutorInterface.class);
}
if(validator != null)
{
qInstanceValidator.validateSimpleCodeReference("WorkflowType [" + name + "]: validator ", validator, WorkflowTypeValidatorInterface.class);
}
if(tester != null)
{
qInstanceValidator.validateSimpleCodeReference("WorkflowType [" + name + "]: tester ", tester, WorkflowTypeTesterInterface.class);
@@ -266,6 +276,8 @@ public class WorkflowType implements Serializable
return Collections.emptyMap();
}
/*******************************************************************************
* Getter for tester
* @see #withTester(QCodeReference)
@@ -303,4 +315,92 @@ public class WorkflowType implements Serializable
}
/*******************************************************************************
** Getter for helpContent
*******************************************************************************/
public Map<String, List<QHelpContent>> getHelpContent()
{
return (this.helpContent);
}
/*******************************************************************************
** Setter for helpContent
*******************************************************************************/
public void setHelpContent(Map<String, List<QHelpContent>> helpContent)
{
this.helpContent = helpContent;
}
/*******************************************************************************
** Fluent setter for helpContent
*******************************************************************************/
public WorkflowType withHelpContent(Map<String, List<QHelpContent>> helpContent)
{
this.helpContent = helpContent;
return (this);
}
/*******************************************************************************
** Fluent setter for helpContent
*******************************************************************************/
public WorkflowType withHelpContent(String key, QHelpContent helpContent)
{
if(this.helpContent == null)
{
this.helpContent = new HashMap<>();
}
if(!this.helpContent.containsKey(key))
{
this.helpContent.put(key, new ArrayList<>());
}
this.helpContent.get(key).add(helpContent);
return (this);
}
/*******************************************************************************
* Getter for validator
* @see #withValidator(QCodeReference)
*******************************************************************************/
public QCodeReference getValidator()
{
return (this.validator);
}
/*******************************************************************************
* Setter for validator
* @see #withValidator(QCodeReference)
*******************************************************************************/
public void setValidator(QCodeReference validator)
{
this.validator = validator;
}
/*******************************************************************************
* Fluent setter for validator
*
* @param validator reference to implementation of
* {@link com.kingsrook.qbits.workflows.execution.WorkflowTypeValidatorInterface}
*
* @return this
*******************************************************************************/
public WorkflowType withValidator(QCodeReference validator)
{
this.validator = validator;
return (this);
}
}
@@ -203,6 +203,8 @@ public class WorkflowsRegistry implements QSupplementalInstanceMetaData, QHelpCo
/***************************************************************************
* plug in to the QQQ Help Content system, to allow help content to set
* descriptions and labels for workflow steps.
*
* Note: helpContent can come in here as null, if it has been, e.g., deleted!
***************************************************************************/
@Override
public void acceptHelpContent(QInstance qInstance, QHelpContent helpContent, Map<String, String> nameValuePairs)
@@ -215,11 +217,18 @@ public class WorkflowsRegistry implements QSupplementalInstanceMetaData, QHelpCo
{
if("description".equals(nameValuePairs.get("slot")))
{
workflowStepType.setDescription(helpContent.getContentAsHtml());
workflowStepType.setDescription(helpContent == null ? null : helpContent.getContentAsHtml());
}
else if("label".equals(nameValuePairs.get("slot")))
{
workflowStepType.setLabel(helpContent.getContent());
if(helpContent != null)
{
workflowStepType.setLabel(helpContent.getContent());
}
else
{
LOG.info("Not removing workflow step type label after deleted help content (original label is not known...", logPair("name", workflowStepTypeName));
}
}
else if(nameValuePairs.containsKey("field"))
{
@@ -229,9 +238,10 @@ public class WorkflowsRegistry implements QSupplementalInstanceMetaData, QHelpCo
.withType(QFieldType.STRING)
.withLabel("Step Description")
.withName("qswdDescription")
.withHelpContent(helpContent)
.withGridColumns(12);
newField.setHelpContents(helpContent == null ? null : ListBuilder.of(helpContent));
List<QFieldMetaData> existingFields = workflowStepType.getInputFields();
existingFields.add(newField);
workflowStepType.setInputFields(existingFields);
@@ -242,13 +252,24 @@ public class WorkflowsRegistry implements QSupplementalInstanceMetaData, QHelpCo
{
if(nameValuePairs.get("field").equals(fieldMetaData.getName()))
{
fieldMetaData.setHelpContents(ListBuilder.of(helpContent));
fieldMetaData.setHelpContents(helpContent == null ? null : ListBuilder.of(helpContent));
}
}
}
}
}
}
else if(nameValuePairs.containsKey("workflow") && nameValuePairs.containsKey("field"))
{
if(workflowTypes != null)
{
for(String workflowTypeName : workflowTypes.keySet())
{
String key = "workflow:" + nameValuePairs.get("workflow") + ";field:" + nameValuePairs.get("field");
workflowTypes.get(workflowTypeName).withHelpContent(key, helpContent);
}
}
}
}
}
@@ -0,0 +1,41 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.execution;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qbits.workflows.model.WorkflowRunLogStep;
import com.kingsrook.qbits.workflows.model.WorkflowStep;
import com.kingsrook.qqq.backend.core.utils.ListingHash;
/***************************************************************************
* state data carried throughout the {@link WorkflowExecutor} - which is
* useful to "leak out" in a managed way, e.g., for step types that do their
* own more sophisticated kind of execution, e.g., {@link WorkflowMultiForkingStepExecutorInterface}
***************************************************************************/
record ExecutionPayload(Map<Integer, WorkflowStep> stepMap, ListingHash<Integer, WorkflowLink> linkMap, WorkflowTypeExecutorInterface workflowTypeExecutor, AtomicInteger seqNo, List<WorkflowRunLogStep> logStepList)
{
}
@@ -0,0 +1,167 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.execution;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qbits.workflows.model.WorkflowStep;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.ListingHash;
/*******************************************************************************
* given a graph, find the first "join point" where multiple forks come together.
* Credit: https://chatgpt.com/share/68000e8f-879c-8011-bb33-0564eb77a1f2
*******************************************************************************/
public class FindFirstJoinPoint
{
/***************************************************************************
**
***************************************************************************/
public static Integer executeForFromStepNo(Integer fromStepNo, Map<Integer, WorkflowStep> stepMap, ListingHash<Integer, WorkflowLink> linksMapByFromStepNo)
{
List<Integer> startLinks = CollectionUtils.nonNullList(linksMapByFromStepNo.get(fromStepNo)).stream().map(ws -> ws.getToStepNo()).toList();
return execute(startLinks, stepMap, linksMapByFromStepNo);
}
/***************************************************************************
**
***************************************************************************/
public static Integer executeForLinks(Collection<WorkflowLink> startLinks, Map<Integer, WorkflowStep> stepMap, ListingHash<Integer, WorkflowLink> linksMapByFromStepNo)
{
return execute(startLinks.stream().map(ws -> ws.getToStepNo()).toList(), stepMap, linksMapByFromStepNo);
}
/***************************************************************************
**
***************************************************************************/
@SuppressWarnings("Java8MapApi")
public static Integer execute(Collection<Integer> startStepNos, Map<Integer, WorkflowStep> stepMap, ListingHash<Integer, WorkflowLink> linksMapByFromStepNo)
{
//////////////////////////////////////////////////////////////////////////////////
// visited: map each node to a set of source step numbers that have reached it. //
//////////////////////////////////////////////////////////////////////////////////
ListingHash<Integer, Integer> visited = new ListingHash<>();
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Initialize the multi-source BFS queue. Each entry carries the current node id and its originating source. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
Deque<BFSQueueNode> queue = new ArrayDeque<>();
////////////////////////////////////////////////////////////////////////////////
// Seed the queue with each starting node, marking each as visited by itself. //
////////////////////////////////////////////////////////////////////////////////
for(Integer node : startStepNos)
{
visited.add(node, node);
queue.add(new BFSQueueNode(node, node));
}
///////////////////////////////////
// Perform the multi-source BFS. //
///////////////////////////////////
while(!queue.isEmpty())
{
BFSQueueNode firstNode = queue.removeFirst();
Integer current = firstNode.current;
Integer source = firstNode.source;
////////////////////////////////////////////////////////////////////
// Get the list of outgoing links/neighbors for the current node. //
////////////////////////////////////////////////////////////////////
List<WorkflowLink> neighbors = Objects.requireNonNullElse(linksMapByFromStepNo.get(current), Collections.emptyList());
for(WorkflowLink neighbor : neighbors)
{
Integer neighborId = neighbor.getToStepNo();
if(neighborId == null)
{
continue;
}
///////////////////////////////////////////////////////////////////////////
// Retrieve or initialize the set of sources that visited this neighbor. //
///////////////////////////////////////////////////////////////////////////
List<Integer> sourcesSet = visited.get(neighborId);
if(sourcesSet == null)
{
sourcesSet = new ArrayList<>();
visited.put(neighborId, sourcesSet);
}
/////////////////////////////////////////////////////////////////////
// If the current source has not visited the neighbor yet, add it. //
/////////////////////////////////////////////////////////////////////
if(!sourcesSet.contains(source))
{
sourcesSet.add(source);
//////////////////////////////////////////////////////////////
// Check if the neighbor was reached from multiple sources. //
// todo: should this be it has to equal all of them? //
// if(sourcesSet.size() == startStepNos.size()) //
//////////////////////////////////////////////////////////////
if(sourcesSet.size() > 1)
{
///////////////////////////////////////////////////////////////////////////////////////
// This is the first join point encountered – return the corresponding WorkflowStep. //
///////////////////////////////////////////////////////////////////////////////////////
return (stepMap.get(neighborId).getStepNo());
}
/////////////////////////////////////////////////////////////////
// Continue the BFS from this neighbor for the current source. //
/////////////////////////////////////////////////////////////////
queue.push(new BFSQueueNode(neighborId, source));
}
}
}
///////////////////////////////////////////////////////////////
// If no join point was found across all paths, return null. //
///////////////////////////////////////////////////////////////
return null;
}
/***************************************************************************
*
***************************************************************************/
private record BFSQueueNode(Integer current, Integer source)
{
}
}
@@ -42,6 +42,8 @@ public class WorkflowExecutionContext
private QBackendTransaction transaction;
private WorkflowRunLog workflowRunLog;
private WorkflowExecutor executor;
private boolean isTestRun = false;
@@ -235,4 +237,26 @@ public class WorkflowExecutionContext
return (this);
}
/*******************************************************************************
* Getter for executor
* @see #setExecutor(WorkflowExecutor)
*******************************************************************************/
WorkflowExecutor getExecutor()
{
return (this.executor);
}
/*******************************************************************************
* Setter for executor
* @param executor the object executing the workflow.
*******************************************************************************/
void setExecutor(WorkflowExecutor executor)
{
this.executor = executor;
}
}
@@ -31,6 +31,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Stack;
import java.util.concurrent.atomic.AtomicInteger;
import com.google.gson.reflect.TypeToken;
import com.kingsrook.qbits.workflows.definition.OutboundLinkMode;
import com.kingsrook.qbits.workflows.definition.WorkflowStepType;
@@ -71,6 +72,7 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer<WorkflowInput, W
private WorkflowRunLog inputWorkflowRunLog;
private Stack<Integer> containerStack = new Stack<>();
private ExecutionPayload payload;
@@ -167,31 +169,10 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer<WorkflowInput, W
///////////////
// step loop //
///////////////
Integer stepNo = workflowRevision.getStartStepNo();
int seqNo = 1;
while(stepNo != null)
{
WorkflowStep step = stepMap.get(stepNo);
if(step == null)
{
throw new QException("Step not found by stepNo: " + stepNo);
}
WorkflowRunLogStep workflowRunLogStep = new WorkflowRunLogStep();
workflowRunLogStep.setWorkflowStepId(step.getId());
workflowRunLogStep.setSeqNo(seqNo);
workflowRunLogStep.setStartTimestamp(Instant.now());
logStepList.add(workflowRunLogStep);
WorkflowStepOutput workflowStepOutput = executeStep(step, workflowTypeExecutor, context);
workflowRunLogStep.setOutputData(ValueUtils.getValueAsString(workflowStepOutput.outputData()));
workflowRunLogStep.setMessage(workflowStepOutput.message());
stepNo = getNextStepNo(workflowStepOutput.outputData(), step, linkMap, stepMap, false);
workflowRunLogStep.setEndTimestamp(Instant.now());
seqNo++;
}
ExecutionPayload payload = new ExecutionPayload(stepMap, linkMap, workflowTypeExecutor, new AtomicInteger(1), logStepList);
this.payload = payload;
context.setExecutor(this);
runStepLoop(workflowRevision.getStartStepNo(), context, null);
workflowTypeExecutor.postRun(context);
@@ -239,6 +220,51 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer<WorkflowInput, W
/***************************************************************************
* @return true if the workflow should immediately stop
***************************************************************************/
boolean runStepLoop(Integer stepNo, WorkflowExecutionContext context, Integer stopStepNo) throws QException
{
while(stepNo != null)
{
WorkflowStep step = payload.stepMap().get(stepNo);
if(step == null)
{
throw new QException("Step not found by stepNo: " + stepNo);
}
WorkflowRunLogStep workflowRunLogStep = new WorkflowRunLogStep();
workflowRunLogStep.setWorkflowStepId(step.getId());
workflowRunLogStep.setStartTimestamp(Instant.now());
WorkflowStepOutput workflowStepOutput = executeStep(step, payload, context);
stepNo = getNextStepNo(workflowStepOutput.outputData(), step, payload.linkMap(), payload.stepMap(), false);
workflowRunLogStep.setEndTimestamp(Instant.now());
workflowRunLogStep.setOutputData(ValueUtils.getValueAsString(workflowStepOutput.outputData()));
workflowRunLogStep.setMessage(workflowStepOutput.message());
workflowRunLogStep.setSeqNo(payload.seqNo().getAndIncrement());
payload.logStepList().add(workflowRunLogStep);
if(stopStepNo != null && Objects.equals(stepNo, stopStepNo))
{
LOG.debug("Stopping at requested step", logPair("stopStepNo", stepNo));
return (false);
}
if(workflowStepOutput.shouldWorkflowImmediatelyStop())
{
LOG.info("Stopping step loop due to step output indicating workflow should immediately stop", logPair("stepNo", stepNo));
return (true);
}
}
return (false);
}
/***************************************************************************
**
***************************************************************************/
@@ -332,13 +358,23 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer<WorkflowInput, W
}
}
Integer fromStepNo = fromStep.getStepNo();
List<WorkflowLink> links = linkMap.get(fromStepNo);
Class<? extends Serializable> stepOutputClass = stepOutput == null ? null : stepOutput.getClass();
Integer fromStepNo = fromStep.getStepNo();
List<WorkflowLink> links = linkMap.get(fromStepNo);
if(OutboundLinkMode.VARIABLE.equals(fromWorkflowStepType.getOutboundLinkMode()))
{
/////////////////////////////////////////////////////////////////////////////
// after a VARIABLE step (where its branches were all executed in //
// processMultiForkingStep), find the first join point, and step to there. //
/////////////////////////////////////////////////////////////////////////////
Integer joinStepNo = FindFirstJoinPoint.execute(links.stream().map(wl -> wl.getToStepNo()).toList(), stepMap, linkMap);
return (joinStepNo);
}
//////////////////////////////////////////////////////////////////////////////////////
// look for a link between the fromStepNo matching the stepOutput / condition value //
//////////////////////////////////////////////////////////////////////////////////////
List<String> evaluatedConditionValues = new ArrayList<>();
for(WorkflowLink link : CollectionUtils.nonNullList(links))
{
if(link.getConditionValue() == null)
@@ -350,12 +386,29 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer<WorkflowInput, W
}
else
{
evaluatedConditionValues.add(link.getConditionValue());
try
{
Class<? extends Serializable> stepOutputClass = stepOutput == null ? null : stepOutput.getClass();
Serializable valueAsType = stepOutputClass == null ? link.getConditionValue() : ValueUtils.getValueAsType(stepOutputClass, link.getConditionValue());
if(Objects.equals(valueAsType, stepOutput))
{
return link.getToStepNo();
////////////////////////////////////////////////////////////////////////////////
// a null link to-step no here may (always?) mean that a branch is empty. //
// but, rather than just returning null, we'll instead break this loop, so we //
// can be sure to hit the check for being inside a container before the end //
// of this method (which, if we're not in a container, will return null). //
// see testContainerEndingWithConditionalWithOneEmptySide //
////////////////////////////////////////////////////////////////////////////////
if(link.getToStepNo() != null)
{
return link.getToStepNo();
}
else
{
LOG.debug("Found link with condition value matching stepOutput, but no toStepNo - this may be expected for an empty branch within a container?", logPair("fromStepNo", link.getFromStepNo()), logPair("condition", link.getConditionValue()));
break;
}
}
}
catch(Exception e)
@@ -365,6 +418,11 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer<WorkflowInput, W
}
}
if(CollectionUtils.nullSafeHasContents(evaluatedConditionValues))
{
LOG.info("Evaluated at least 1 link condition value, but found no matches", logPair("evaluatedConditionValues", evaluatedConditionValues));
}
/////////////////////////////////////////////////////////////////////////
// if we didn't find a next-step, but there is something on the stack, //
// then look for a pop out of that frame //
@@ -385,7 +443,7 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer<WorkflowInput, W
/***************************************************************************
**
***************************************************************************/
private WorkflowStepOutput executeStep(WorkflowStep step, WorkflowTypeExecutorInterface workflowTypeExecutor, WorkflowExecutionContext context) throws QException
private WorkflowStepOutput executeStep(WorkflowStep step, ExecutionPayload payload, WorkflowExecutionContext context) throws QException
{
WorkflowStepType workflowStepType = WorkflowsRegistry.of(QContext.getQInstance()).getWorkflowStepType(step.getWorkflowStepTypeName());
if(workflowStepType == null)
@@ -399,8 +457,7 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer<WorkflowInput, W
}
WorkflowStepExecutorInterface workflowStepExecutor = QCodeLoader.getAdHoc(WorkflowStepExecutorInterface.class, workflowStepType.getExecutor());
workflowTypeExecutor.preStep(step, context);
payload.workflowTypeExecutor().preStep(step, context);
JSONObject jsonObject = JsonUtils.toJSONObject(step.getInputValuesJson());
Map<String, Object> mapValues = jsonObject.toMap();
@@ -415,13 +472,23 @@ public class WorkflowExecutor extends AbstractQActionBiConsumer<WorkflowInput, W
}
WorkflowStepOutput workflowStepOutput = workflowStepExecutor.execute(step, inputValues, context);
workflowStepOutput = workflowTypeExecutor.postStep(step, context, workflowStepOutput);
workflowStepOutput = payload.workflowTypeExecutor().postStep(step, context, workflowStepOutput);
return workflowStepOutput;
}
/***************************************************************************
*
***************************************************************************/
ExecutionPayload getPayload()
{
return (this.payload);
}
/***************************************************************************
**
***************************************************************************/
@@ -0,0 +1,134 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.execution;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qbits.workflows.model.WorkflowRunLogStep;
import com.kingsrook.qbits.workflows.model.WorkflowStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/***************************************************************************
* interface for workflow step executors that are of a variable output type,
* and which can execute 0 or more of their outbound links.
***************************************************************************/
public interface WorkflowMultiForkingStepExecutorInterface extends WorkflowStepExecutorInterface
{
QLogger LOG = QLogger.getLogger(WorkflowMultiForkingStepExecutorInterface.class);
/***************************************************************************
* the Execute method for such steps should perform an executor.runStepLoop
* for each of the outbound links of the step (assuming they are supposed
* allowed to run).
***************************************************************************/
@Override
default WorkflowStepOutput execute(WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext context) throws QException
{
///////////////////////////////////////////////////////////////////////
// get the various sub-workflows ("fork links") to run from the step //
///////////////////////////////////////////////////////////////////////
WorkflowExecutor executor = context.getExecutor();
ExecutionPayload payload = executor.getPayload();
List<WorkflowLink> forkLinks = payload.linkMap().get(step.getStepNo());
preRun(step, inputValues, context);
sortForks(forkLinks, step, inputValues, context);
Integer stopStepNo = FindFirstJoinPoint.execute(forkLinks.stream().map(wl -> wl.getToStepNo()).toList(), payload.stepMap(), payload.linkMap());
boolean shouldWorkflowImmediatelyStop = false;
for(WorkflowLink link : forkLinks)
{
WorkflowRunLogStep workflowRunLogStep = new WorkflowRunLogStep();
workflowRunLogStep.setWorkflowStepId(step.getId());
workflowRunLogStep.setSeqNo(payload.seqNo().getAndIncrement());
workflowRunLogStep.setStartTimestamp(Instant.now());
WorkflowStepOutput workflowStepOutput = conditionallyExecuteFork(step, link, inputValues, context);
if(Objects.equals(workflowStepOutput.outputData(), true))
{
shouldWorkflowImmediatelyStop = executor.runStepLoop(link.getToStepNo(), context, stopStepNo);
}
workflowRunLogStep.setOutputData(ValueUtils.getValueAsString(workflowStepOutput.outputData()));
workflowRunLogStep.setMessage(workflowStepOutput.message());
workflowRunLogStep.setEndTimestamp(Instant.now());
payload.logStepList().add(workflowRunLogStep);
if(shouldWorkflowImmediatelyStop)
{
LOG.info("Stopping fork-link loop, due to step output indicating workflow should immediately stop", logPair("linkStartStepNo", link.getToStepNo()));
break;
}
}
WorkflowStepOutput workflowStepOutput = makeWorkflowStepOutput(step, inputValues, context);
postRun(step, inputValues, context);
if(shouldWorkflowImmediatelyStop)
{
workflowStepOutput = new WorkflowStepOutput(workflowStepOutput.outputData(), workflowStepOutput.message(), true);
}
return workflowStepOutput;
}
/***************************************************************************
* make sure the forks are executed in the order that the step expects.
***************************************************************************/
void sortForks(List<WorkflowLink> forkLinks, WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext context);
/***************************************************************************
* decide if a particular fork should be executed - and if so, then do so.
***************************************************************************/
WorkflowStepOutput conditionallyExecuteFork(WorkflowStep step, WorkflowLink fork, Map<String, Serializable> inputValues, WorkflowExecutionContext context) throws QException;
/***************************************************************************
* make output for the entire step, e.g., taking into account all of its
* forks.
***************************************************************************/
WorkflowStepOutput makeWorkflowStepOutput(WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext context) throws QException;
/***************************************************************************
* optional code to run before anything else in this interface
***************************************************************************/
default void preRun(WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext context) throws QException
{
}
/***************************************************************************
* optional code to run after anything else in this interface
***************************************************************************/
default void postRun(WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext context) throws QException
{
}
}
@@ -28,16 +28,26 @@ import java.io.Serializable;
/***************************************************************************
**
***************************************************************************/
public record WorkflowStepOutput(Serializable outputData, String message)
public record WorkflowStepOutput(Serializable outputData, String message, boolean shouldWorkflowImmediatelyStop)
{
/*******************************************************************************
** Constructor
**
*******************************************************************************/
public WorkflowStepOutput(Serializable outputData, String message)
{
this(outputData, message, false);
}
/*******************************************************************************
** Constructor
**
*******************************************************************************/
public WorkflowStepOutput(Serializable outputData)
{
this(outputData, null);
this(outputData, null, false);
}
@@ -48,7 +58,7 @@ public record WorkflowStepOutput(Serializable outputData, String message)
*******************************************************************************/
public WorkflowStepOutput()
{
this(null, null);
this(null, null, false);
}
}
@@ -0,0 +1,51 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.execution;
import java.util.List;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qbits.workflows.model.WorkflowStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
* interface for the code that validates the overall structure of a workflow,
* e.g., when it's being saved. This would be where you'd validate relationships
* between steps (e.g., this step must have some other step within it)
*******************************************************************************/
public interface WorkflowTypeValidatorInterface
{
/***************************************************************************
* validate that a workflow can be saved.
*
* @param workflow the workflow record under which a new revision is being saved
* @param workflowRevision the new revision record that's been built
* @param workflowSteps list of workflow steps being saved
* @param workflowLinks list of workflow links being saved
* @param errors out param - any validation errors should be added to this list.
***************************************************************************/
void validate(QRecord workflow, QRecord workflowRevision, List<WorkflowStep> workflowSteps, List<WorkflowLink> workflowLinks, List<String> errors) throws QException;
}
@@ -0,0 +1,72 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.execution.nodes;
import java.util.ArrayList;
import java.util.List;
/***************************************************************************
* list of nodes in a graph representation of a workflow
***************************************************************************/
public record NodeSequence(List<StepNode> nodes)
{
/*******************************************************************************
** Constructor
**
*******************************************************************************/
public NodeSequence()
{
this(new ArrayList<>());
}
/***************************************************************************
*
***************************************************************************/
public void add(StepNode node)
{
nodes.add(node);
}
/***************************************************************************
*
***************************************************************************/
public StepNode get(int i)
{
return nodes.get(i);
}
/***************************************************************************
*
***************************************************************************/
public int size()
{
return (nodes.size());
}
}
@@ -0,0 +1,47 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.execution.nodes;
import java.util.HashMap;
import java.util.Map;
import com.kingsrook.qbits.workflows.model.WorkflowStep;
/***************************************************************************
* Single step, plus, if it opens a nested scope (e.g., branches, or a container)
* then those subSequences, along with their conditionalValues (guards/labels)
* as map keys
***************************************************************************/
public record StepNode(WorkflowStep step, Map<String, NodeSequence> subSequences)
{
/*******************************************************************************
** Constructor for step w/o subSequences
**
*******************************************************************************/
public StepNode(WorkflowStep step)
{
this(step, new HashMap<>());
}
}
@@ -0,0 +1,224 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.execution.nodes;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.workflows.definition.OutboundLinkMode;
import com.kingsrook.qbits.workflows.definition.WorkflowStepType;
import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry;
import com.kingsrook.qbits.workflows.execution.FindFirstJoinPoint;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qbits.workflows.model.WorkflowStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.ListingHash;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
* Class that can convert a list of steps & links (as a workflow revision is
* stored in a database) to a hierarchical graph data structure - a list of nodes,
* each of which is a step, and optionally a map of lists of sub-branches
* (which can be more useful for various kinds of processing).
*******************************************************************************/
public class StepNodeGraphBuilder
{
private static final QLogger LOG = QLogger.getLogger(StepNodeGraphBuilder.class);
/***************************************************************************
*
***************************************************************************/
public static NodeSequence buildNodeSequence(Integer startStepNo, List<WorkflowStep> workflowSteps, List<WorkflowLink> workflowLinks)
{
Map<Integer, WorkflowStep> stepMap = CollectionUtils.listToMap(workflowSteps, ws -> ws.getStepNo());
ListingHash<Integer, WorkflowLink> linksFromMap = new ListingHash<>();
Map<Integer, Set<Integer>> linksToMap = new HashMap<>();
for(WorkflowLink workflowLink : workflowLinks)
{
linksFromMap.add(workflowLink.getFromStepNo(), workflowLink);
if(workflowLink.getToStepNo() != null)
{
Set<Integer> subToSet = linksToMap.computeIfAbsent(workflowLink.getToStepNo(), k -> new HashSet<>());
subToSet.add(workflowLink.getFromStepNo());
}
}
NodeSequence nodeSequence = new NodeSequence();
buildNodeSequence(nodeSequence, stepMap.get(startStepNo), stepMap, linksFromMap, null, 0);
return (nodeSequence);
}
/***************************************************************************
*
***************************************************************************/
private static void buildNodeSequence(NodeSequence nodeSequence, WorkflowStep startStep, Map<Integer, WorkflowStep> stepMap, ListingHash<Integer, WorkflowLink> linksFromMap, Integer joinStepNo, Integer depth)
{
WorkflowsRegistry workflowsRegistry = WorkflowsRegistry.of(QContext.getQInstance());
WorkflowStep currentWorkflowStep = startStep;
while(currentWorkflowStep != null)
{
WorkflowStepType workflowStepType = workflowsRegistry.getWorkflowStepType(currentWorkflowStep.getWorkflowStepTypeName());
if(workflowStepType == null)
{
LOG.warn("Unrecognized workflow step type: " + currentWorkflowStep.getWorkflowStepTypeName());
return;
}
if(Objects.equals(currentWorkflowStep.getStepNo(), joinStepNo))
{
//////////////////////////////////////////////////////////////////////////////
// if we've reached the join step, it's the end of this sequence, so return //
//////////////////////////////////////////////////////////////////////////////
return;
}
StepNode stepNode = new StepNode(currentWorkflowStep);
nodeSequence.add(stepNode);
List<WorkflowLink> outboundLinks = linksFromMap.get(currentWorkflowStep.getStepNo());
if(CollectionUtils.nullSafeIsEmpty(outboundLinks))
{
/////////////////////////////////////////////////////////////////////////////
// if no outbound links, then we're at the end of this sequence, so return //
/////////////////////////////////////////////////////////////////////////////
return;
}
else if(outboundLinks.size() == 1 && !StringUtils.hasContent(outboundLinks.get(0).getConditionValue())) // ?? avoid being tripped by a branch w/ only 1 side populated at the end of the graph
{
///////////////////////////////////////////////////////////////////
// 1 outbound link here means we're not in a switch/if-else/fork //
///////////////////////////////////////////////////////////////////
WorkflowLink outboundLink = outboundLinks.get(0);
currentWorkflowStep = outboundLink.getToStepNo() == null ? null : stepMap.get(outboundLink.getToStepNo());
////////////////////////////////
// todo is this == null okay? //
////////////////////////////////
if(currentWorkflowStep == null || currentWorkflowStep.getStepNo().equals(joinStepNo))
{
///////////////////////////////////////////////////////
// this means we're ending a nested (recursive) call //
///////////////////////////////////////////////////////
return;
}
}
else
{
if(workflowStepType.getOutboundLinkMode().equals(OutboundLinkMode.TWO) || workflowStepType.getOutboundLinkMode().equals(OutboundLinkMode.VARIABLE))
{
///////////////////////////////////////////////////////////////////////////////////////////////////
// this means we are starting a fork. find where it ends - where the branches first re-converge //
///////////////////////////////////////////////////////////////////////////////////////////////////
Integer branchJoin = FindFirstJoinPoint.executeForLinks(outboundLinks, stepMap, linksFromMap);
////////////////////////////////////////////
// for each branch, make a recursive call //
////////////////////////////////////////////
for(WorkflowLink outboundLink : outboundLinks)
{
String conditionValue = outboundLink.getConditionValue();
NodeSequence branchSequence = new NodeSequence();
stepNode.subSequences().put(conditionValue, branchSequence);
if(branchJoin != null && Objects.equals(branchJoin, outboundLink.getToStepNo()))
{
////////////////////////////////////////////////////////////////////////
// this indicates an empty branch - so continue to next outbound link //
////////////////////////////////////////////////////////////////////////
continue;
}
///////////////////////////////////////////////////////////////////////////////////////////
// if this is a step out of the branch (e.g., to a join), then don't process it in here. //
// also though, this line doesn't actually get hit, so... //
///////////////////////////////////////////////////////////////////////////////////////////
if(Objects.equals(outboundLink.getToStepNo(), joinStepNo))
{
continue;
}
else
{
if(outboundLink.getToStepNo() != null)
{
buildNodeSequence(branchSequence, stepMap.get(outboundLink.getToStepNo()), stepMap, linksFromMap, branchJoin, depth + 1);
}
}
}
////////////////////////////////////////////////////////////////////////////////
// after handling all the branches, then continue the loop with the join step //
////////////////////////////////////////////////////////////////////////////////
currentWorkflowStep = branchJoin == null ? null : stepMap.get(branchJoin);
}
else if(workflowStepType.getOutboundLinkMode().equals(OutboundLinkMode.CONTAINER))
{
///////////////////////////////////////////////////////////////////////
// if the container is non-empty, there should be a push link for it //
///////////////////////////////////////////////////////////////////////
Optional<WorkflowLink> pushLink = outboundLinks.stream().filter(ol -> "push".equals(ol.getConditionValue())).findFirst();
if(pushLink.isPresent())
{
if(pushLink.get().getToStepNo() != null)
{
NodeSequence containerSequence = new NodeSequence();
stepNode.subSequences().put("push", containerSequence);
buildNodeSequence(containerSequence, stepMap.get(pushLink.get().getToStepNo()), stepMap, linksFromMap, null, depth + 1);
}
}
Optional<WorkflowLink> popLink = outboundLinks.stream().filter(ol -> "pop".equals(ol.getConditionValue())).findFirst();
if(popLink.isPresent())
{
if(popLink.get().getToStepNo() != null)
{
currentWorkflowStep = stepMap.get(popLink.get().getToStepNo());
}
}
else
{
return;
}
}
else
{
LOG.warn("expected to have multiple outbound links for a step of type: " + currentWorkflowStep.getWorkflowStepTypeName());
}
}
}
}
}
@@ -25,8 +25,10 @@ package com.kingsrook.qbits.workflows.implementations.recordworkflows;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.workflows.definition.OutboundLinkMode;
import com.kingsrook.qbits.workflows.definition.OutboundLinkOption;
import com.kingsrook.qbits.workflows.definition.WorkflowStepType;
@@ -55,6 +57,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.utils.BackendQueryFilterUtils;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
@@ -107,8 +110,10 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS
**
***************************************************************************/
@Override
public WorkflowStepOutput execute(WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext context) throws QException
public WorkflowStepOutput execute(WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext workflowExecutionContext) throws QException
{
RecordWorkflowContext context = (RecordWorkflowContext) workflowExecutionContext;
QRecord record = (QRecord) context.getValues().get("record");
if(record == null)
{
@@ -131,26 +136,38 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS
///////////////////////////////////////////////////////////////////////////////
filter.applyCriteriaOptionToAllCriteria(CriteriaOption.CASE_INSENSITIVE);
List<QRecordWithJoinedRecords> recordWithJoins = buildCrossProduct(record, filter, context);
List<QRecordWithJoinedRecords> recordWithJoins = buildCrossProduct(record, filter, context.recordWorkflowContextJoinedRecordHelper.get());
return evaluateCrossProduct(recordWithJoins, filter);
}
/***************************************************************************
* For a given record, and a filter, and a {@link RecordWorkflowContextJoinedRecordHelper}
* object from a live workflow context, build a List of {@link QRecordWithJoinedRecords}
* that can be used to evaluate if a record (with joins) matches a filter.
*
* <p>Note that this method is public, and called by application-defined workflows.
* In other words, this method is part of our published API in here!</p>
*
* @param record the main record in the workflow
* @param filter the filter which may have joins (thus the need for a cross-product)
* @param joinedRecordHelper object from context of the workflow that knows
* about cached join records and records being inserted
* and/or deleted.
* @return {@code List<QRecordWithJoinedRecords>} the cross product that the
* filter should be evaluated against.
***************************************************************************/
public List<QRecordWithJoinedRecords> buildCrossProduct(QRecord record, QQueryFilter filter, WorkflowExecutionContext workflowExecutionContext) throws QException
public static List<QRecordWithJoinedRecords> buildCrossProduct(QRecord record, QQueryFilter filter, RecordWorkflowContextJoinedRecordHelper joinedRecordHelper) throws QException
{
RecordWorkflowContext context = (RecordWorkflowContext) workflowExecutionContext;
List<QRecordWithJoinedRecords> crossProduct = new ArrayList<>();
crossProduct.add(new QRecordWithJoinedRecords(record));
List<QueryJoin> joinsInFilter = BackendQueryFilterUtils.identifyJoinsInFilter(context.getWorkflow().getTableName(), filter);
List<QueryJoin> joinsInFilter = BackendQueryFilterUtils.identifyJoinsInFilter(joinedRecordHelper.getTableName(), filter);
joinsInFilter = sortQueryJoinsFromMainTableOutward(joinedRecordHelper.getTableName(), joinsInFilter);
for(QueryJoin join : joinsInFilter)
{
crossProduct = expandCrossProductViaJoin(crossProduct, join, context);
crossProduct = expandCrossProductViaJoin(crossProduct, join, joinedRecordHelper);
}
return crossProduct;
@@ -158,16 +175,96 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS
/*******************************************************************************
* Sort the query joins so that they fan outward from the main table to ones
* farther away.
*
* <p>This is so, if we've got a -> b -> c -- we'll process 'b' before 'c',
* as we need the records in 'b' to find the records in 'c'.</p>
*
* <p><i>Note that this method was copied from {@code AbstractRDBMSAction}</i></p>
*******************************************************************************/
private static List<QueryJoin> sortQueryJoinsFromMainTableOutward(String mainTableName, List<QueryJoin> queryJoins)
{
List<QueryJoin> rs = new ArrayList<>();
////////////////////////////////////////////////////////////////////////////////
// make a copy of the input list that we can feel safe removing elements from //
////////////////////////////////////////////////////////////////////////////////
List<QueryJoin> inputListCopy = new ArrayList<>(queryJoins);
///////////////////////////////////////////////////////////////////////////////////////////////////
// keep track of the tables (or aliases) that we've seen - that's what we'll "grow" outward from //
///////////////////////////////////////////////////////////////////////////////////////////////////
Set<String> seenTablesOrAliases = new HashSet<>();
seenTablesOrAliases.add(mainTableName);
////////////////////////////////////////////////////////////////////////////////////
// loop as long as there are more tables in the inputList, and the keepGoing flag //
// is set (e.g., indicating that we added something in the last iteration) //
////////////////////////////////////////////////////////////////////////////////////
boolean keepGoing = true;
while(!inputListCopy.isEmpty() && keepGoing)
{
keepGoing = false;
Iterator<QueryJoin> iterator = inputListCopy.iterator();
while(iterator.hasNext())
{
QueryJoin nextQueryJoin = iterator.next();
//////////////////////////////////////////////////////////////////////////
// get the baseTableOrAlias from this join - and if it isn't set in the //
// QueryJoin, then get it from the left-side of the join's metaData //
//////////////////////////////////////////////////////////////////////////
String baseTableOrAlias = nextQueryJoin.getBaseTableOrAlias();
if(baseTableOrAlias == null && nextQueryJoin.getJoinMetaData() != null)
{
baseTableOrAlias = nextQueryJoin.getJoinMetaData().getLeftTable();
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if we have a baseTableOrAlias (would we ever not?), and we've seen it before - OR - we've seen this query join's joinTableOrAlias, //
// then we can add this pair of namesOrAliases to our seen-set, remove this queryJoin from the inputListCopy (iterator), and keep going //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if((StringUtils.hasContent(baseTableOrAlias) && seenTablesOrAliases.contains(baseTableOrAlias)) || seenTablesOrAliases.contains(nextQueryJoin.getJoinTableOrItsAlias()))
{
rs.add(nextQueryJoin);
if(StringUtils.hasContent(baseTableOrAlias))
{
seenTablesOrAliases.add(baseTableOrAlias);
}
seenTablesOrAliases.add(nextQueryJoin.getJoinTableOrItsAlias());
iterator.remove();
keepGoing = true;
}
}
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// in case any are left, add them all here - does this ever happen? //
// the only time a conditional breakpoint here fires in the RDBMS test suite, is in query designed to throw. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
rs.addAll(inputListCopy);
return (rs);
}
/***************************************************************************
*
***************************************************************************/
private static List<QRecordWithJoinedRecords> expandCrossProductViaJoin(List<QRecordWithJoinedRecords> orderWithJoinedRecords, QueryJoin queryJoin, RecordWorkflowContext context) throws QException
private static List<QRecordWithJoinedRecords> expandCrossProductViaJoin(List<QRecordWithJoinedRecords> recordWithJoinedRecords, QueryJoin queryJoin, RecordWorkflowContextJoinedRecordHelper joinedRecordHelper) throws QException
{
String joinTableName = queryJoin.getJoinTable();
ArrayList<QRecord> recordsToBeInserted = context.recordsToInsert.get().computeIfAbsent(joinTableName, k -> new ArrayList<>());
List<QRecord> recordsAlreadyInBackend = context.getJoinRecords(queryJoin);
HashSet<Serializable> idsToDelete = context.primaryKeysToDelete.get().computeIfAbsent(joinTableName, k -> new HashSet<>());
String primaryKeyField = QContext.getQInstance().getTable(joinTableName).getPrimaryKeyField();
String joinTableName = queryJoin.getJoinTable();
List<QRecord> recordsToBeInserted = joinedRecordHelper.getRecordsToInsert(joinTableName);
List<QRecord> recordsAlreadyInBackend = joinedRecordHelper.getJoinRecords(queryJoin);
Set<Serializable> idsToDelete = joinedRecordHelper.getPrimaryKeysToDelete(joinTableName);
String primaryKeyField = QContext.getQInstance().getTable(joinTableName).getPrimaryKeyField();
//////////////////////////////////////////////////////////////////////////////////////////////////////
// add records that already existed to the cross product, filtering out ones that are to be deleted //
@@ -186,8 +283,8 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS
////////////////////////////////////
CollectionUtils.addAllIfNotNull(recordsToCross, recordsToBeInserted);
orderWithJoinedRecords = makeCrossProduct(orderWithJoinedRecords, joinTableName, recordsToCross);
return orderWithJoinedRecords;
recordWithJoinedRecords = makeCrossProduct(recordWithJoinedRecords, joinTableName, recordsToCross);
return recordWithJoinedRecords;
}
@@ -195,18 +292,21 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS
/***************************************************************************
*
***************************************************************************/
private static List<QRecordWithJoinedRecords> makeCrossProduct(List<QRecordWithJoinedRecords> orderWithJoinedRecords, String joinTableName, List<QRecord> joinRecodsToCross)
private static List<QRecordWithJoinedRecords> makeCrossProduct(List<QRecordWithJoinedRecords> recordsWithJoinedRecords, String joinTableName, List<QRecord> joinRecordsToCross)
{
if(!joinRecodsToCross.isEmpty())
if(recordsWithJoinedRecords.isEmpty() || joinRecordsToCross.isEmpty())
{
List<QRecordWithJoinedRecords> newCrossProduct = new ArrayList<>();
for(QRecordWithJoinedRecords orderWithJoinedRecord : orderWithJoinedRecords)
{
newCrossProduct.addAll(orderWithJoinedRecord.buildCrossProduct(joinTableName, joinRecodsToCross));
}
orderWithJoinedRecords = newCrossProduct;
return new ArrayList<>();
}
return orderWithJoinedRecords;
List<QRecordWithJoinedRecords> newCrossProduct = new ArrayList<>();
for(QRecordWithJoinedRecords recordWithJoinedRecord : recordsWithJoinedRecords)
{
newCrossProduct.addAll(recordWithJoinedRecord.buildCrossProduct(joinTableName, joinRecordsToCross));
}
recordsWithJoinedRecords = newCrossProduct;
return recordsWithJoinedRecords;
}
@@ -25,24 +25,16 @@ package com.kingsrook.qbits.workflows.implementations.recordworkflows;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.google.gson.reflect.TypeToken;
import com.kingsrook.qbits.workflows.execution.LazyInitObjectInWorkflowContext;
import com.kingsrook.qbits.workflows.execution.ObjectInWorkflowContext;
import com.kingsrook.qbits.workflows.execution.WorkflowExecutionContext;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
@@ -57,17 +49,43 @@ public class RecordWorkflowContext extends WorkflowExecutionContext
public final ObjectInWorkflowContext<HashMap<String, ArrayList<QRecord>>> recordsToInsert = new ObjectInWorkflowContext<>(this, "recordsToInsert", new HashMap<>());
public final ObjectInWorkflowContext<HashMap<String, HashSet<Serializable>>> primaryKeysToDelete = new ObjectInWorkflowContext<>(this, "primaryKeysToDelete", new HashMap<>());
///////////////////////////////////////////////////////////////////////////////////////////
// records already stored in the backend that are joined with the main record. //
// should only be accessed via the getRelatedRecords method, which lazy inits (by query) //
///////////////////////////////////////////////////////////////////////////////////////////
private final ObjectInWorkflowContext<HashMap<JoinKey, ArrayList<QRecord>>> joinRecords = new ObjectInWorkflowContext<>(this, "joinRecords", new HashMap<>());
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// records already stored in the backend that are joined with the main record - they used to be stored in a map kept //
// right here, but they've migrated into the RecordWorkflowContextJoinedRecordHelper. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public final ObjectInWorkflowContext<RecordWorkflowContextJoinedRecordHelper> recordWorkflowContextJoinedRecordHelper = new LazyInitObjectInWorkflowContext<>(this, "recordWorkflowContextJoinedRecordHelper", () -> newRecordWorkflowContextJoinedRecordHelper(this));
private record JoinKey(String joinName, ArrayList<Serializable> joinValues)
/***************************************************************************
* Construct a new RecordWorkflowContextJoinedRecordHelper, which uses this
* context class's members to get the list of records to insert and the
* set of ids to delete
***************************************************************************/
private RecordWorkflowContextJoinedRecordHelper newRecordWorkflowContextJoinedRecordHelper(RecordWorkflowContext recordWorkflowContext)
{
return new RecordWorkflowContextJoinedRecordHelper(getWorkflow().getTableName(), record.get())
{
/***************************************************************************
*
***************************************************************************/
@Override
public List<QRecord> getRecordsToInsert(String tableName)
{
return recordWorkflowContext.recordsToInsert.get().computeIfAbsent(tableName, (k) -> new ArrayList<>());
}
/***************************************************************************
*
***************************************************************************/
@Override
public Set<Serializable> getPrimaryKeysToDelete(String tableName)
{
return recordWorkflowContext.primaryKeysToDelete.get().computeIfAbsent(tableName, (k) -> new HashSet<>());
}
};
}
@@ -147,81 +165,11 @@ public class RecordWorkflowContext extends WorkflowExecutionContext
/***************************************************************************
*
* allow join-records, which are normally managed by the recordWorkflowContextJoinedRecordHelper
* to be explicitly set in that object. Useful for test scenarios, maybe more.
***************************************************************************/
public List<QRecord> getJoinRecords(QueryJoin queryJoin) throws QException
public void setJoinRecords(QueryJoin queryJoin, List<QRecord> associatedRecords)
{
JoinRecordsKeyConstruction result = getJoinRecordsKeyConstruction(queryJoin, record.get());
if(joinRecords.get().get(result.key()) == null)
{
ArrayList<QRecord> records = CollectionUtils.useOrWrap(result.makeEmpty() ? Collections.emptyList() : QueryAction.execute(queryJoin.getJoinTable(), result.filter()), new TypeToken<>() {});
joinRecords.get().put(result.key(), records);
}
return joinRecords.get().get(result.key());
}
/***************************************************************************
*
***************************************************************************/
private JoinRecordsKeyConstruction getJoinRecordsKeyConstruction(QueryJoin queryJoin, QRecord mainRecord)
{
ArrayList<Serializable> joinValues = new ArrayList<>();
JoinKey key = new JoinKey(queryJoin.getJoinMetaData().getName(), joinValues);
String baseTableName = getWorkflow().getTableName();
QQueryFilter filter = new QQueryFilter();
boolean makeEmpty = false;
for(JoinOn joinOn : queryJoin.getJoinMetaData().getJoinOns())
{
Serializable mainTableValue;
String joinTableField;
if(queryJoin.getJoinMetaData().getLeftTable().equals(baseTableName))
{
mainTableValue = mainRecord.getValue(joinOn.getLeftField());
joinTableField = joinOn.getRightField();
}
else
{
mainTableValue = mainRecord.getValue(joinOn.getRightField());
joinTableField = joinOn.getLeftField();
}
key.joinValues.add(mainTableValue);
if(mainTableValue == null)
{
// throw (new QException("Missing join value in " + baseTableName + " getting related " + tableName + " records"));
makeEmpty = true;
}
filter.addCriteria(new QFilterCriteria(joinTableField, QCriteriaOperator.EQUALS, mainTableValue));
}
JoinRecordsKeyConstruction result = new JoinRecordsKeyConstruction(key, filter, makeEmpty);
return result;
}
/***************************************************************************
*
***************************************************************************/
private record JoinRecordsKeyConstruction(JoinKey key, QQueryFilter filter, boolean makeEmpty)
{
}
/***************************************************************************
*
***************************************************************************/
public void setJoinRecords(QueryJoin queryJoin, QRecord mainRecord, List<QRecord> records)
{
ArrayList<QRecord> recordArrayList = CollectionUtils.useOrWrap(records, new TypeToken<>() {});
JoinRecordsKeyConstruction result = getJoinRecordsKeyConstruction(queryJoin, mainRecord);
joinRecords.get().put(result.key(), recordArrayList);
recordWorkflowContextJoinedRecordHelper.get().setJoinRecords(queryJoin, associatedRecords);
}
}
@@ -0,0 +1,238 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.implementations.recordworkflows;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.google.gson.reflect.TypeToken;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
* For workflows that are centered around a single record and may work with
* records joined to that record, an instance of this class can be put into
* the workflow's context. It can then be used, specifically, by the
* {@link InputRecordFilterStep} to build up a cross-product used to do in-
* memory filter operation.
*******************************************************************************/
public class RecordWorkflowContextJoinedRecordHelper implements Serializable
{
private final String tableName;
private final QRecord record;
private Map<String, List<QRecord>> joinRecords = new HashMap<>();
/*******************************************************************************
** Constructor
**
*******************************************************************************/
public RecordWorkflowContextJoinedRecordHelper(String tableName, QRecord record)
{
this.tableName = tableName;
this.record = record;
}
/***************************************************************************
* if the context is collecting records that will be inserted when the
* workflow is done, and those need to be included, e.g., for in-memory
* filters - then override this method (e.g., via an anonymous inner class
* in the specific workflow context class in question), and return appropriate
* pending-insertion records here.
***************************************************************************/
public List<QRecord> getRecordsToInsert(String tableName)
{
return Collections.emptyList();
}
/***************************************************************************
* if the context is collecting primary keys that will be deleted when the
* workflow is done, and those records need to be excluded, e.g., for in-memory
* filters - then override this method (e.g., via an anonymous inner class
* in the specific workflow context class in question), and return appropriate
* pending-deletion keys here.
***************************************************************************/
public Set<Serializable> getPrimaryKeysToDelete(String tableName)
{
return Collections.emptySet();
}
/***************************************************************************
* Clear the join records map. Specifically useful if the context is being
* used both during and after workflow execution (e.g. before and after
* records have been stored).
***************************************************************************/
public void reset()
{
joinRecords.clear();
}
/***************************************************************************
* get the join records for a specific query join against the main record
* of the workflow.
*
* <p>internally this method does cache those lookups - see {@code reset()}
* to clear that cache.</p>
***************************************************************************/
public List<QRecord> getJoinRecords(QueryJoin queryJoin) throws QException
{
String joinTableOrAlias = queryJoin.getJoinTableOrItsAlias();
if(!StringUtils.hasContent(joinTableOrAlias))
{
throw (new QException("Missing joinTableOrAlias in queryJoin: " + queryJoin));
}
List<QRecord> baseRecords;
if(queryJoin.getBaseTableOrAlias().equals(tableName))
{
baseRecords = List.of(record);
}
else
{
baseRecords = Objects.requireNonNullElseGet(joinRecords.get(queryJoin.getBaseTableOrAlias()), ArrayList::new);
}
QQueryFilter joinRecordsFilter = getJoinRecordsFilter(queryJoin, baseRecords);
if(joinRecords.get(joinTableOrAlias) == null)
{
List<QRecord> records = joinRecordsFilter == null ? Collections.emptyList() : QueryAction.execute(queryJoin.getJoinTable(), joinRecordsFilter);
joinRecords.put(joinTableOrAlias, records);
}
return joinRecords.get(joinTableOrAlias);
}
/***************************************************************************
* For a given queryJoin and a list of 'base records', build a filter that
* can find join records.
*
* <p>Note that this will potentially be a (a=? AND b=?) OR (a=? AND b=?)
* style query... </p>
*
* @return filter to find join records - or - null if there should be no
* join records returned (e.g., empty base record list).
***************************************************************************/
protected QQueryFilter getJoinRecordsFilter(QueryJoin queryJoin, List<QRecord> baseRecords)
{
String baseTableName = queryJoin.getBaseTableOrAlias();
if(CollectionUtils.nullSafeIsEmpty(baseRecords))
{
return (null);
}
QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR);
for(QRecord baseRecord : baseRecords)
{
QQueryFilter subFilter = new QQueryFilter();
filter.addSubFilter(subFilter);
for(JoinOn joinOn : queryJoin.getJoinMetaData().getJoinOns())
{
Serializable mainTableValue;
String joinTableField;
if(queryJoin.getJoinMetaData().getLeftTable().equals(baseTableName))
{
mainTableValue = baseRecord.getValue(joinOn.getLeftField());
joinTableField = joinOn.getRightField();
}
else
{
mainTableValue = baseRecord.getValue(joinOn.getRightField());
joinTableField = joinOn.getLeftField();
}
if(mainTableValue == null)
{
subFilter.addCriteria(new QFilterCriteria(joinTableField, QCriteriaOperator.FALSE));
}
else
{
subFilter.addCriteria(new QFilterCriteria(joinTableField, QCriteriaOperator.EQUALS, mainTableValue));
}
}
}
return (filter);
}
/***************************************************************************
*
***************************************************************************/
public void setJoinRecords(QueryJoin queryJoin, List<QRecord> joinRecords)
{
ArrayList<QRecord> recordArrayList = CollectionUtils.useOrWrap(joinRecords, new TypeToken<>() {});
this.joinRecords.put(queryJoin.getJoinTableOrItsAlias(), recordArrayList);
}
/*******************************************************************************
** Getter for tableName
**
*******************************************************************************/
public String getTableName()
{
return tableName;
}
/*******************************************************************************
** Getter for record
**
*******************************************************************************/
public QRecord getRecord()
{
return record;
}
}
@@ -98,6 +98,11 @@ public class RecordWorkflowInputRecordFilterWidget extends AbstractWidgetRendere
public static void applyApiNameAndVersionFromInputToWidgetData(RenderWidgetInput input, FilterAndColumnsSetupData widgetData)
{
String apiName = input.getQueryParams().get("workflowRevision.apiName");
if(!StringUtils.hasContent(apiName))
{
apiName = input.getQueryParams().get("apiName");
}
if(StringUtils.hasContent(apiName))
{
widgetData.setApiName(apiName);
@@ -113,6 +118,11 @@ public class RecordWorkflowInputRecordFilterWidget extends AbstractWidgetRendere
}
String apiVersion = input.getQueryParams().get("workflowRevision.apiVersion");
if(!StringUtils.hasContent(apiVersion))
{
apiVersion = input.getQueryParams().get("apiVersion");
}
if(StringUtils.hasContent(apiVersion))
{
widgetData.setApiVersion(apiVersion);
@@ -116,9 +116,10 @@ public class RecordWorkflowTypeTester implements WorkflowTypeTesterInterface
QueryJoin queryJoin = new QueryJoin()
.withJoinTable(associatedTableName)
.withBaseTableOrAlias(tableName)
.withJoinMetaData(QContext.getQInstance().getJoin(association.get().getJoinName()));
recordWorkflowContext.setJoinRecords(queryJoin, inputRecord, associatedRecords);
recordWorkflowContext.setJoinRecords(queryJoin, associatedRecords);
}
}
}
@@ -198,6 +199,12 @@ public class RecordWorkflowTypeTester implements WorkflowTypeTesterInterface
{
RecordWorkflowContext context = (RecordWorkflowContext) workflowOutput.getContext();
/////////////////////////////////////////////////////////////////////////////////////////////////
// reset the pre-looked up joins in this object (since after the workflow is complete, records //
// will be stored, which wouldn't have been before, so it might have cached old record lists.) //
/////////////////////////////////////////////////////////////////////////////////////////////////
context.recordWorkflowContextJoinedRecordHelper.get().reset();
QRecord record = getRecordForTestAssertionFilter(workflowOutput, assertion);
if(record == null)
{
@@ -210,7 +217,7 @@ public class RecordWorkflowTypeTester implements WorkflowTypeTesterInterface
// re-use the order filtering logic of InputRecordFilterStep (e.g., building joins) //
//////////////////////////////////////////////////////////////////////////////////////
InputRecordFilterStep inputRecordFilterStep = new InputRecordFilterStep();
List<QRecordWithJoinedRecords> orderWithJoinedRecords = inputRecordFilterStep.buildCrossProduct(record, filter, context);
List<QRecordWithJoinedRecords> orderWithJoinedRecords = InputRecordFilterStep.buildCrossProduct(record, filter, context.recordWorkflowContextJoinedRecordHelper.get());
WorkflowStepOutput workflowStepOutput = inputRecordFilterStep.evaluateCrossProduct(orderWithJoinedRecords, filter);
return (Objects.equals(workflowStepOutput.outputData(), true));
@@ -84,18 +84,28 @@ public class RecordWorkflowUtils
/***************************************************************************
**
* expects name in inputValues to be "queryFilterJson"
***************************************************************************/
public static QQueryFilter getFilterFromInput(Map<String, ?> inputValues) throws QException
{
Object object = inputValues.get("queryFilterJson");
if(object == null)
{
throw (new QException("Could not find filter in input values"));
}
return getFilterFromJsonOrObject(object);
}
/***************************************************************************
* supported input types are: QQueryFilter, String (json), Map.
***************************************************************************/
public static QQueryFilter getFilterFromJsonOrObject(Object object) throws QException
{
try
{
Object object = inputValues.get("queryFilterJson");
if(object == null)
{
throw (new QException("Could not find filter in input values"));
}
if(object instanceof QQueryFilter qQueryFilter)
{
return (qQueryFilter);
@@ -115,7 +125,7 @@ public class RecordWorkflowUtils
}
catch(IOException e)
{
throw new QException("Error getting filter from input values", e);
throw new QException("Error getting filter from Object", e);
}
}
@@ -80,8 +80,8 @@ public class RunRecordWorkflowExtractStep extends ExtractViaQueryStep
@Override
protected void customizeInputPreQuery(RunBackendStepInput runBackendStepInput, QueryInput queryInput)
{
super.customizeInputPreQuery(runBackendStepInput, queryInput);
queryInput.setIncludeAssociations(true);
super.customizeInputPreQuery(runBackendStepInput, queryInput);
}
}
@@ -25,16 +25,21 @@ package com.kingsrook.qbits.workflows.implementations.recordworkflows;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.workflows.definition.WorkflowStepType;
import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry;
import com.kingsrook.qbits.workflows.implementations.WorkflowStepUtils;
import com.kingsrook.qbits.workflows.model.WorkflowRevision;
import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceOutput;
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
@@ -128,12 +133,24 @@ public class UpdateInputRecordFieldMetaDataAdjuster implements FormAdjusterInter
{
QTableMetaData table = QContext.getQInstance().getTable(tableName);
FieldAndJoinTable fieldAndJoinTable = FieldAndJoinTable.get(table, newValue);
updatedField = fieldAndJoinTable.field().clone();
updatedField.setName("value");
updatedField.setLabel("Value (" + fieldAndJoinTable.field().getLabel() + ")");
}
if(fieldAndJoinTable != null)
////////////////////////////////////////////////////////////////////////////////
// check the workflow registry to see if there is help content for this field //
////////////////////////////////////////////////////////////////////////////////
WorkflowsRegistry workflowsRegistry = WorkflowsRegistry.of(QContext.getQInstance());
WorkflowStepType workflowStepType = workflowsRegistry.getWorkflowStepType(UpdateInputRecordFieldStep.NAME);
if(workflowStepType != null)
{
for(QFieldMetaData fieldMetaData : CollectionUtils.nonNullList(workflowStepType.getInputFields()))
{
updatedField = fieldAndJoinTable.field().clone();
updatedField.setName("value");
updatedField.setLabel("Value (" + fieldAndJoinTable.field().getLabel() + ")");
if(fieldMetaData.getName().equals("value"))
{
updatedField.setHelpContents(fieldMetaData.getHelpContents());
}
}
}
}
@@ -143,6 +160,8 @@ public class UpdateInputRecordFieldMetaDataAdjuster implements FormAdjusterInter
}
}
adjustPossibleValueSourceFilterCriteriaInputs(updatedField.getPossibleValueSourceFilter());
///////////////////////////////////////////////////////////////////////////////////
// build the output object, starting with the new meta-data of the 'value' field //
///////////////////////////////////////////////////////////////////////////////////
@@ -186,6 +205,43 @@ public class UpdateInputRecordFieldMetaDataAdjuster implements FormAdjusterInter
/***************************************************************************
* if the field has a possible-value-source filter on it, and that filter
* uses an input field e.g., ${input.someField} - consider that this value
* *might* be an attribute on the workflow so add an optional
* ??${input.workflow.someField} after it.
***************************************************************************/
static void adjustPossibleValueSourceFilterCriteriaInputs(QQueryFilter filter)
{
if(filter == null)
{
return;
}
for(QFilterCriteria criteria : CollectionUtils.nonNullList(filter.getCriteria()))
{
List<Serializable> values = CollectionUtils.nonNullList(criteria.getValues());
ListIterator<Serializable> valueIterator = values.listIterator();
while(valueIterator.hasNext())
{
Serializable value = valueIterator.next();
if(value instanceof String stringValue && stringValue.startsWith("${input."))
{
String inputFieldName = stringValue.replace("${input.", "").replace("}", "");
String updatedValue = stringValue + "??${input.workflow." + inputFieldName + "}";
valueIterator.set(updatedValue);
}
}
}
for(QQueryFilter subFilter : CollectionUtils.nonNullList(filter.getSubFilters()))
{
adjustPossibleValueSourceFilterCriteriaInputs(subFilter);
}
}
/***************************************************************************
**
***************************************************************************/
@@ -60,7 +60,10 @@ import org.json.JSONObject;
/*******************************************************************************
** workflow step that updates a record with one value in one field
* Workflow step that updates a record with one value in one field
*
* This class includes some public static methods that can be useful for other
* workflow step implementations that have similar functionality.
*******************************************************************************/
public class UpdateInputRecordFieldStep extends WorkflowStepType implements WorkflowStepExecutorInterface, WorkflowStepValidatorInterface
{
@@ -101,7 +104,15 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work
/***************************************************************************
**
* For a given fieldName, tableName, and api and version (which come from a
* workflowRevision), get QFieldMetaData for that field (if it's in that
* API Version).
*
* @param fieldNameMaybeWithTableNamePrefix - field name - which can be
* prefixed by tableName + "."
* @param tableName table name
* @param workflowRevision from which api name and version will come
* @return optional containing QFieldMetaData if found, or empty if not.
***************************************************************************/
public static Optional<QFieldMetaData> getApiField(String fieldNameMaybeWithTableNamePrefix, String tableName, WorkflowRevision workflowRevision) throws QException
{
@@ -121,6 +132,31 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work
***************************************************************************/
@Override
public String getDynamicStepSummary(Integer workflowId, Map<String, Serializable> inputValues) throws QException
{
QRecord workflowRecord = GetAction.execute(Workflow.TABLE_NAME, workflowId);
String tableName = workflowRecord.getValueString("tableName");
return getStepSummary(inputValues, tableName, false);
}
/***************************************************************************
* Helper for {@link #getDynamicStepSummary(Integer, Map)} or
* {@link #execute(WorkflowStep, Map, WorkflowExecutionContext)} to generate
* a string like "${fieldLabel} will be / was set to ${value}" or "... cleared out"
* where the value will be a translated possible value if applicable.
*
* @param inputValues map of values user assigned to the step. expected to
* include String fieldName and String value.
* @param tableName table that the field should come from. Many times this
* may be the tableName on the workflow - but it could be
* a hard-coded tableName in other implementations of this
* step type.
* @param isPastTense to format the message as "will be" or "was". e.g.,
* for getDynamicStepSummary would be false ("will be")
* and for execute would be true ("was").
***************************************************************************/
public static String getStepSummary(Map<String, Serializable> inputValues, String tableName, boolean isPastTense)
{
String fieldName = ValueUtils.getValueAsString(inputValues.get("fieldName"));
String value = ValueUtils.getValueAsString(inputValues.get("value"));
@@ -137,45 +173,38 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work
}
}
return getStepSummary(workflowId, fieldName, apiName, apiVersion, value, false);
return getStepSummary(fieldName, tableName, apiName, apiVersion, value, isPastTense);
}
/***************************************************************************
**
*
***************************************************************************/
private String getStepSummary(Integer workflowId, String fieldName, String apiName, String apiVersion, String value, boolean isPastTense)
private static String getStepSummary(String fieldName, String tableName, String apiName, String apiVersion, String value, boolean isPastTense)
{
String fieldLabel = null;
QTableMetaData table = null;
QTableMetaData table = QContext.getQInstance().getTable(tableName);
if(StringUtils.hasContent(fieldName))
{
try
{
if(workflowId != null)
WorkflowRevision workflowRevision = new WorkflowRevision().withApiName(apiName).withApiVersion(apiVersion);
if(WorkflowStepUtils.useApi(workflowRevision))
{
QRecord workflowRecord = GetAction.execute(Workflow.TABLE_NAME, workflowId);
table = QContext.getQInstance().getTable(workflowRecord.getValueString("tableName"));
WorkflowRevision workflowRevision = new WorkflowRevision().withApiName(apiName).withApiVersion(apiVersion);
if(WorkflowStepUtils.useApi(workflowRevision))
Optional<QFieldMetaData> optionalApiField = getApiField(fieldName, tableName, workflowRevision);
if(optionalApiField.isPresent())
{
Optional<QFieldMetaData> optionalApiField = getApiField(fieldName, table.getName(), workflowRevision);
if(optionalApiField.isPresent())
{
// fieldName = optionalApiField.field().getName(); // hmm?
fieldLabel = optionalApiField.get().getLabel();
}
}
else
{
FieldAndJoinTable fieldAndJoinTable = FieldAndJoinTable.get(table, fieldName);
fieldName = fieldAndJoinTable.field().getName();
fieldLabel = fieldAndJoinTable.field().getLabel();
fieldLabel = optionalApiField.get().getLabel();
}
}
else
{
FieldAndJoinTable fieldAndJoinTable = FieldAndJoinTable.get(table, fieldName);
fieldName = fieldAndJoinTable.field().getName();
fieldLabel = fieldAndJoinTable.field().getLabel();
}
}
catch(Exception e)
{
@@ -183,15 +212,22 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work
}
}
String verb = isPastTense ? "was" : "will be";
if(StringUtils.hasContent(fieldLabel))
{
String verb = isPastTense ? "was" : "will be";
if(StringUtils.hasContent(value))
{
String displayValue = value;
try
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// field names from this PVS are always table.field - but QValueFormatter doesn't expect that - so strip away table. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(fieldName != null && fieldName.startsWith(tableName + "."))
{
fieldName = fieldName.replace(tableName + ".", "");
}
QRecord record = new QRecord().withValue(fieldName, value);
QValueFormatter.setDisplayValuesInRecordsIncludingPossibleValueTranslations(table, List.of(record));
displayValue = record.getDisplayValue(fieldName);
@@ -241,7 +277,8 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work
context.doesRecordNeedUpdated.set(true);
String stepSummary = getStepSummary(context.getWorkflow().getId(), fieldName, context.getWorkflowRevision().getApiName(), context.getWorkflowRevision().getApiVersion(), value, true);
String tableName = context.getWorkflow().getTableName();
String stepSummary = getStepSummary(fieldName, tableName, context.getWorkflowRevision().getApiName(), context.getWorkflowRevision().getApiVersion(), value, true);
return new WorkflowStepOutput(value, stepSummary);
}
@@ -284,12 +321,34 @@ public class UpdateInputRecordFieldStep extends WorkflowStepType implements Work
@Override
public void validate(WorkflowStep step, Map<String, Serializable> inputValues, QRecord workflowRevision, QRecord workflow, List<String> errors) throws QException
{
if(WorkflowStepUtils.useApi(new WorkflowRevision(workflowRevision)))
validate(workflow.getValueString("tableName"), inputValues, workflowRevision, errors);
}
/***************************************************************************
* implementation logic for {@link #validate(WorkflowStep, Map, QRecord, QRecord, List)}.
* makes sure that, if the workflow uses API, that the field is a valid field
* for the api version being used in the workflow revision.
*
* @param tableName table that the field should come from. Many times this
* may be the tableName on the workflow - but it could be
* a hard-coded tableName in other implementations of this
* step type.
* @param inputValues map of values user assigned to the step. expected to
* include String fieldName.
* @param workflowRevision from which api name and version will come
* @param errors Out param - list of error message strings, which will be
* added to if the input fieldName isn't in the table for the
* api version on the workflowRevision
***************************************************************************/
public static void validate(String tableName, Map<String, Serializable> inputValues, QRecord workflowRevision, List<String> errors) throws QException
{
if(workflowRevision != null && WorkflowStepUtils.useApi(new WorkflowRevision(workflowRevision)))
{
String fieldName = ValueUtils.getValueAsString(inputValues.get("fieldName"));
String apiName = ValueUtils.getValueAsString(workflowRevision.getValueString("apiName"));
String apiVersion = ValueUtils.getValueAsString(workflowRevision.getValueString("apiVersion"));
String tableName = ValueUtils.getValueAsString(workflow.getValueString("tableName"));
Optional<QFieldMetaData> optionalApiField = getApiField(fieldName, tableName, new WorkflowRevision(workflowRevision));
if(optionalApiField.isEmpty())
@@ -32,7 +32,7 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
*******************************************************************************/
public class WorkflowRevisionViewerWidget extends BaseQSequentialWorkflowWidgetRenderer
{
public static final String NAME = "WorkflowRunLogViewer";
public static final String NAME = "WorkflowRevisionViewerWidget";
@@ -43,7 +43,7 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
*******************************************************************************/
public class WorkflowRunLogViewerWidget extends BaseQSequentialWorkflowWidgetRenderer
{
public static final String NAME = "WorkflowRevisionViewer";
public static final String NAME = "WorkflowRunLogViewerWidget";
@@ -84,7 +84,7 @@ public class WorkflowLink extends QRecordEntity
@QField(isRequired = true)
private Integer fromStepNo;
@QField(isRequired = true)
@QField()
private Integer toStepNo;
@QField(maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
@@ -0,0 +1,116 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.google.gson.reflect.TypeToken;
import com.kingsrook.qbits.workflows.definition.OutboundLinkMode;
import com.kingsrook.qbits.workflows.definition.OutboundLinkOption;
import com.kingsrook.qbits.workflows.definition.WorkflowStepType;
import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
* process to get values that the backend must compute about a workflow step,
* based on input values, e.g., as a user is editing the step in a UI.
*
* Originally this was just the step summary (e.g., workflowStepType.getDynamicStepSummary)
*
* Then, when variable outboundLinkModes were added, also responsible for returning
* the link options to use for a step, based on its input values.
*
*******************************************************************************/
public class GetWorkflowStepDynamicValuesProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "getWorkflowStepDynamicValues";
/***************************************************************************
**
***************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
.withStep(new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(getClass()))
.withInputData(new QFunctionInputMetaData()
.withField(new QFieldMetaData("workflowId", QFieldType.INTEGER))
.withField(new QFieldMetaData("workflowStepTypeName", QFieldType.STRING))
.withField(new QFieldMetaData("values", QFieldType.INTEGER)) // JSON
));
}
/***************************************************************************
**
***************************************************************************/
@Override
public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
{
Integer workflowId = runBackendStepInput.getValueInteger("workflowId");
String workflowStepTypeName = runBackendStepInput.getValueString("workflowStepTypeName");
String values = runBackendStepInput.getValueString("values");
Map<String, Serializable> valueAsMap = ValueUtils.getValueAsMap(values);
WorkflowStepType workflowStepType = WorkflowsRegistry.of(QContext.getQInstance()).getWorkflowStepType(workflowStepTypeName);
runBackendStepOutput.addValue("summary", workflowStepType.getDynamicStepSummary(workflowId, valueAsMap));
if(OutboundLinkMode.VARIABLE.equals(workflowStepType.getOutboundLinkMode()))
{
List<OutboundLinkOption> dynamicOutboundLinkOptions = workflowStepType.getDynamicOutboundLinkOptions(workflowId, valueAsMap);
if(dynamicOutboundLinkOptions != null)
{
ArrayList<OutboundLinkOption> outboundLinkOptions = CollectionUtils.useOrWrap(dynamicOutboundLinkOptions, new TypeToken<>() {});
runBackendStepOutput.addValue("outboundLinkOptions", outboundLinkOptions);
}
}
}
}
@@ -48,6 +48,7 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
** process to get a dynamic summary of a workflow step, e.g., as it's being
** edited in a UI.
*******************************************************************************/
@Deprecated(since = "0.115.0 - November 2025 - Replaced by GetWorkflowStepDynamicValuesProcess")
public class GetWorkflowStepSummaryProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "getWorkflowStepSummary";
@@ -58,7 +59,6 @@ public class GetWorkflowStepSummaryProcess implements BackendStep, MetaDataProdu
**
***************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
@@ -41,12 +41,14 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
@@ -116,8 +118,12 @@ public class GetWorkflowTypeDefinitionProcess implements BackendStep, MetaDataPr
throw new QException("Workflow type not found: " + workflowTypeName);
}
////////////////////////////////////////////////////////////////////////////////
// save any help content on current workflow and put back after customization //
////////////////////////////////////////////////////////////////////////////////
Map<String, List<QHelpContent>> helpContent = workflowType.getHelpContent();
workflowType = workflowType.customizeBasedOnWorkflow(workflowRecord);
workflowType.setHelpContent(helpContent);
runBackendStepOutput.addValue("workflowType", workflowType);
////////////////////////////////////////////////////////////////////////////////
@@ -167,4 +173,24 @@ public class GetWorkflowTypeDefinitionProcess implements BackendStep, MetaDataPr
}
}
/***************************************************************************
**
***************************************************************************/
protected Map<String, List<HelpContent>> getWorkflowTypeHelpContent(Map<String, HelpContent> helpContentMap)
{
Map<String, List<HelpContent>> workflowTypeHelpContent = new LinkedHashMap<>();
String[] workflowTypeKeys = new String[] { "workflow:editor;field:workflowName", "workflow:editor;field:versionNo", "workflow:editor;field:apiName", "workflow:editor;field:apiVersion", "workflow:tester;field:workflowTestScenarioId" };
for(String key : workflowTypeKeys)
{
if(helpContentMap.containsKey(key))
{
workflowTypeHelpContent.put(key, List.of(helpContentMap.get(key)));
}
}
return (workflowTypeHelpContent);
}
}
@@ -29,8 +29,10 @@ import java.util.Map;
import java.util.Objects;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.workflows.definition.WorkflowStepType;
import com.kingsrook.qbits.workflows.definition.WorkflowType;
import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry;
import com.kingsrook.qbits.workflows.execution.WorkflowStepValidatorInterface;
import com.kingsrook.qbits.workflows.execution.WorkflowTypeValidatorInterface;
import com.kingsrook.qbits.workflows.model.Workflow;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qbits.workflows.model.WorkflowRevision;
@@ -140,6 +142,38 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro
throw (new QUserFacingException("Workflow not found by id: " + workflowId));
}
String stepsJSON = runBackendStepInput.getValueString("steps");
if(!StringUtils.hasContent(stepsJSON))
{
throw (new QUserFacingException("Workflow steps input was not provided."));
}
List<WorkflowStep> workflowSteps;
try
{
workflowSteps = JsonUtils.toObject(stepsJSON, new TypeReference<>() {});
}
catch(Exception e)
{
throw (new QUserFacingException("Error parsing workflow steps json: " + e.getMessage(), e));
}
String linksJSON = runBackendStepInput.getValueString("links");
if(!StringUtils.hasContent(linksJSON))
{
throw (new QUserFacingException("Workflow links input was not provided."));
}
List<WorkflowLink> workflowLinks;
try
{
workflowLinks = JsonUtils.toObject(linksJSON, new TypeReference<>() {});
}
catch(Exception e)
{
throw (new QUserFacingException("Error parsing workflow links json: " + e.getMessage(), e));
}
////////////////////////////
// look up next versionNo //
////////////////////////////
@@ -166,15 +200,15 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro
// insert the workflow revision //
//////////////////////////////////
String commitMessage = runBackendStepInput.getValueString("commitMessage");
String userName = ObjectUtils.tryElse(() -> QContext.getQSession().getUser().getFullName(), "Unknown");
WorkflowRevision workflowRevision = new WorkflowRevision();
workflowRevision.setWorkflowId(workflowId);
workflowRevision.setVersionNo(versionNo);
workflowRevision.setCommitMessage(StringUtils.hasContent(commitMessage) ? commitMessage : "New workflow revision created by " + QContext.getQSession().getUser().getFullName());
workflowRevision.setCommitMessage(StringUtils.hasContent(commitMessage) ? commitMessage : "New workflow revision created by " + userName);
workflowRevision.setApiName(runBackendStepInput.getValueString("apiName"));
workflowRevision.setApiVersion(runBackendStepInput.getValueString("apiVersion"));
workflowRevision.setAuthor(ObjectUtils.tryElse(() -> QContext.getQSession().getUser().getFullName(), "Unknown"));
workflowRevision.setAuthor(userName);
List<WorkflowStep> workflowSteps = JsonUtils.toObject(runBackendStepInput.getValueString("steps"), new TypeReference<>() {});
if(!workflowSteps.isEmpty())
{
workflowRevision.setStartStepNo(workflowSteps.get(0).getStepNo());
@@ -208,19 +242,24 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro
//////////////////////////////////////////////////////////////////////////////////////////////
// store steps (first setting revision id, validating, and putting a fresh summary on each) //
//////////////////////////////////////////////////////////////////////////////////////////////
List<String> errors = new ArrayList<>();
List<String> errors = new ArrayList<>();
List<String> errorsForThisStep = new ArrayList<>();
workflowSteps.forEach(step ->
{
try
{
step.setWorkflowRevisionId(insertedRevisionId);
WorkflowStepType workflowStepType = WorkflowsRegistry.of(QContext.getQInstance()).getWorkflowStepType(step.getWorkflowStepTypeName());
Map<String, Serializable> inputValues = ValueUtils.getValueAsMap(step.getInputValuesJson());
WorkflowStepType workflowStepType = WorkflowsRegistry.of(QContext.getQInstance()).getWorkflowStepType(step.getWorkflowStepTypeName());
if(workflowStepType == null)
{
throw (new QUserFacingException("Unknown workflow step type " + step.getWorkflowStepTypeName()));
}
////////////////////////////////////
// summary - if it fails, :shrug: //
////////////////////////////////////
Map<String, Serializable> inputValues = ValueUtils.getValueAsMap(step.getInputValuesJson());
try
{
step.setSummary(workflowStepType.getDynamicStepSummary(workflowId, inputValues));
@@ -237,18 +276,37 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro
if(validatorCodeReference != null)
{
WorkflowStepValidatorInterface validator = QCodeLoader.getAdHoc(WorkflowStepValidatorInterface.class, validatorCodeReference);
validator.validate(step, inputValues, workflowRevisionRecord, workflowRecord, errors);
validator.validate(step, inputValues, workflowRevisionRecord, workflowRecord, errorsForThisStep);
if(!errorsForThisStep.isEmpty())
{
///////////////////////////////////////
// put Step # in front of all errors //
///////////////////////////////////////
errors.addAll(errorsForThisStep.stream().map(e -> "Step " + step.getStepNo() + ": " + e).toList());
errorsForThisStep.clear();
}
}
}
catch(Exception e)
{
errors.add("Error processing step [" + step.getSummary() + "]: " + e.getMessage());
errors.add("Error processing Step " + step.getStepNo() + ": " + e.getMessage());
}
});
////////////////////////////////////////////////////////////
// do workflow-level validation as well, if so configured //
////////////////////////////////////////////////////////////
WorkflowType workflowType = WorkflowsRegistry.of(QContext.getQInstance()).getWorkflowType(workflowRecord.getValueString("workflowTypeName"));
if(workflowType.getValidator() != null)
{
WorkflowTypeValidatorInterface validator = QCodeLoader.getAdHoc(WorkflowTypeValidatorInterface.class, workflowType.getValidator());
validator.validate(workflowRecord, workflowRevisionRecord, workflowSteps, workflowLinks, errors);
}
if(!errors.isEmpty())
{
throw (new QUserFacingException("Validation errors in steps: " + StringUtils.joinWithCommasAndAnd(errors)));
String message = errors.size() + " validation error" + StringUtils.plural(errors) + " occurred before the workflow could be saved:\n" + StringUtils.join("\n", errors);
throw (new QUserFacingException(message));
}
new InsertAction().execute(new InsertInput(WorkflowStep.TABLE_NAME).withRecordEntities(workflowSteps).withTransaction(transaction));
@@ -256,7 +314,6 @@ public class StoreNewWorkflowRevisionProcess implements BackendStep, MetaDataPro
/////////////////
// store links //
/////////////////
List<WorkflowLink> workflowLinks = JsonUtils.toObject(runBackendStepInput.getValueString("links"), new TypeReference<>() {});
workflowLinks.forEach(link -> link.setWorkflowRevisionId(insertedRevisionId));
new InsertAction().execute(new InsertInput(WorkflowLink.TABLE_NAME).withRecordEntities(workflowLinks).withTransaction(transaction));
@@ -136,7 +136,7 @@ public class BaseTest
/***************************************************************************
**
***************************************************************************/
private QInstance defineQInstance() throws QException
protected QInstance defineQInstance() throws QException
{
/////////////////////////////////////
// basic definition of an instance //
@@ -0,0 +1,324 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows;
import java.util.List;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
* MetaData Producer that builds several tables and joins, for a mock HR database.
*
* <p>ER Diagram (joins only):</p>
*
* <pre>
* +-------------+
* | company |
* +-------------+
* | ↓ 1:N
* |
* N:1 ↑ |
* +-------------+ → M:1 (manager)
* | department |-------------------+
* +-------------+ |
* | ↓ 1:N |
* | |
* N:1 ↑ | 1:M ↑ v
* +-------------+ 1:1 +----------------+
* | employee |---------->| employeeDetail |
* +-------------+ +----------------+
* ↓ 1:N | ^ \
* | | \ M:1 (mentor, self-join)
* | +----+
* N:1 ↑ |
* +-------------+ → M:1 +----------+
* | jobHistory |---------->| jobTitle |
* +-------------+ 1:M ← +----------+
* </pre>
*******************************************************************************/
public class CompanyHierarchyTablesAndJoinsMetaDataProducer implements MetaDataProducerInterface<MetaDataProducerMultiOutput>
{
public static final String COMPANY_TABLE = "company";
public static final String DEPARTMENT_TABLE = "department";
public static final String EMPLOYEE_TABLE = "employee";
public static final String EMPLOYEE_DETAIL_TABLE = "employeeDetail";
public static final String JOB_TITLE_TABLE = "jobTitle";
public static final String JOB_HISTORY_TABLE = "jobHistory";
public static final String COMPANY_JOIN_DEPARTMENT = "companyDepartments";
public static final String DEPARTMENT_JOIN_COMPANY = "departmentCompany";
public static final String DEPARTMENT_JOIN_EMPLOYEE = "departmentEmployees";
public static final String EMPLOYEE_JOIN_DEPARTMENT = "employeeDepartment";
public static final String EMPLOYEE_JOIN_EMPLOYEE_AS_MENTOR = "employeeMentor";
public static final String EMPLOYEE_JOIN_EMPLOYEE_DETAIL = "employeeEmployeeDetail";
public static final String EMPLOYEE_DETAIL_JOIN_EMPLOYEE = "employeeDetailEmployee";
public static final String EMPLOYEE_JOIN_JOB_HISTORY = "employeeJobHistories";
public static final String JOB_HISTORY_JOIN_EMPLOYEE = "jobHistoryEmployee";
public static final String JOB_HISTORY_JOIN_JOB_TITLE = "jobHistoryJobTitle";
public static final String JOB_TITLE_JOIN_JOB_HISTORY = "jobTitleJobHistories";
public static final String DEPARTMENT_JOIN_MANAGER = "departmentManager";
/***************************************************************************
*
***************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
{
MetaDataProducerMultiOutput rs = new MetaDataProducerMultiOutput();
////////////
// Tables //
////////////
/////////////////////////////////////
// Company (root of the hierarchy) //
/////////////////////////////////////
rs.add(new QTableMetaData()
.withName(COMPANY_TABLE)
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("name", QFieldType.STRING))
.withField(new QFieldMetaData("industry", QFieldType.STRING))
.withField(new QFieldMetaData("foundedDate", QFieldType.DATE))
.withExposedJoin(new ExposedJoin().withJoinTable(EMPLOYEE_TABLE).withJoinPath(List.of(DEPARTMENT_JOIN_COMPANY, DEPARTMENT_JOIN_EMPLOYEE)))
.withExposedJoin(new ExposedJoin().withJoinTable(EMPLOYEE_DETAIL_TABLE).withJoinPath(List.of(COMPANY_JOIN_DEPARTMENT, DEPARTMENT_JOIN_MANAGER, EMPLOYEE_JOIN_EMPLOYEE_DETAIL))));
//////////////////////////////////////////////////////////////////////////////////
// Department (many departments per company; has a manager FK back to employee) //
//////////////////////////////////////////////////////////////////////////////////
rs.add(new QTableMetaData()
.withName(DEPARTMENT_TABLE)
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("companyId", QFieldType.INTEGER)) // FK -> company
.withField(new QFieldMetaData("managerId", QFieldType.INTEGER)) // FK -> employee (self-ref via join)
.withField(new QFieldMetaData("name", QFieldType.STRING))
.withField(new QFieldMetaData("budget", QFieldType.DECIMAL))
.withExposedJoin(new ExposedJoin().withJoinTable(EMPLOYEE_DETAIL_TABLE).withJoinPath(List.of(DEPARTMENT_JOIN_EMPLOYEE, EMPLOYEE_JOIN_EMPLOYEE_DETAIL)))
.withExposedJoin(new ExposedJoin().withJoinTable(JOB_HISTORY_TABLE).withJoinPath(List.of(DEPARTMENT_JOIN_MANAGER, EMPLOYEE_JOIN_JOB_HISTORY)))
);
/////////////////////////////////////////////////////////////////////
// Employee (many employees per department; 1:1 to EmployeeDetail) //
/////////////////////////////////////////////////////////////////////
rs.add(new QTableMetaData()
.withName(EMPLOYEE_TABLE)
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("departmentId", QFieldType.INTEGER)) // FK -> department
.withField(new QFieldMetaData("mentorEmployeeId", QFieldType.INTEGER)) // FK -> employee (self-join)
.withField(new QFieldMetaData("firstName", QFieldType.STRING))
.withField(new QFieldMetaData("lastName", QFieldType.STRING))
.withField(new QFieldMetaData("email", QFieldType.STRING))
.withField(new QFieldMetaData("hireDate", QFieldType.DATE))
.withField(new QFieldMetaData("isActive", QFieldType.BOOLEAN))
.withExposedJoin(new ExposedJoin().withJoinTable(COMPANY_TABLE).withJoinPath(List.of(EMPLOYEE_JOIN_DEPARTMENT, DEPARTMENT_JOIN_COMPANY)))
.withExposedJoin(new ExposedJoin().withJoinTable(JOB_TITLE_TABLE).withJoinPath(List.of(EMPLOYEE_JOIN_JOB_HISTORY, JOB_HISTORY_JOIN_JOB_TITLE)))
);
//////////////////////////////////////////////////////////////////
// EmployeeDetail (1:1 with employee - extended/sensitive info) //
//////////////////////////////////////////////////////////////////
rs.add(new QTableMetaData()
.withName(EMPLOYEE_DETAIL_TABLE)
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("employeeId", QFieldType.INTEGER)) // FK -> employee (1:1)
.withField(new QFieldMetaData("phone", QFieldType.STRING))
.withField(new QFieldMetaData("emergencyContact", QFieldType.STRING))
.withField(new QFieldMetaData("salary", QFieldType.DECIMAL))
.withField(new QFieldMetaData("benefitsTier", QFieldType.STRING)));
////////////////////////////////////////////////////////////////////
// JobTitle (lookup table - many employees can share a job title) //
////////////////////////////////////////////////////////////////////
rs.add(new QTableMetaData()
.withName(JOB_TITLE_TABLE)
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("name", QFieldType.STRING))
.withField(new QFieldMetaData("grade", QFieldType.STRING))
.withField(new QFieldMetaData("isExempt", QFieldType.BOOLEAN)));
///////////////////////////////////////////////////////////////////////////////
// JobHistory (many job history rows per employee; each row n:1 to jobTitle) //
///////////////////////////////////////////////////////////////////////////////
rs.add(new QTableMetaData()
.withName(JOB_HISTORY_TABLE)
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("employeeId", QFieldType.INTEGER)) // FK -> employee
.withField(new QFieldMetaData("jobTitleId", QFieldType.INTEGER)) // FK -> jobTitle
.withField(new QFieldMetaData("startDate", QFieldType.DATE))
.withField(new QFieldMetaData("endDate", QFieldType.DATE))
.withField(new QFieldMetaData("salaryAtTime", QFieldType.DECIMAL)));
/////////////////////////////////////////
// set common attributes on all tables //
/////////////////////////////////////////
rs.getEach(QTableMetaData.class).forEach(t ->
{
t.setPrimaryKeyField("id");
t.setBackendName(BaseTest.MEMORY_BACKEND_NAME);
});
///////////
// Joins //
///////////
/////////////////////////////////////////////////////////////////
// company 1:n department (company.id = department.companyId) //
/////////////////////////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(COMPANY_JOIN_DEPARTMENT)
.withLeftTable(COMPANY_TABLE)
.withRightTable(DEPARTMENT_TABLE)
.withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn("id", "companyId"))
.withOrderBy(new QFilterOrderBy("name")));
///////////////////////////////////////////////////////////////////////////////////////////
// department n:1 company (flip of above - useful when traversing from department side) //
///////////////////////////////////////////////////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(DEPARTMENT_JOIN_COMPANY)
.withLeftTable(DEPARTMENT_TABLE)
.withRightTable(COMPANY_TABLE)
.withType(JoinType.MANY_TO_ONE)
.withJoinOn(new JoinOn("companyId", "id")));
//////////////////////////////////////////////////////////////////////
// department 1:n employee (department.id = employee.departmentId) //
//////////////////////////////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(DEPARTMENT_JOIN_EMPLOYEE)
.withLeftTable(DEPARTMENT_TABLE)
.withRightTable(EMPLOYEE_TABLE)
.withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn("id", "departmentId"))
.withOrderBy(new QFilterOrderBy("lastName"))
.withOrderBy(new QFilterOrderBy("firstName")));
//////////////////////////////////////////////
// employee n:1 department (flip of above) //
//////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(EMPLOYEE_JOIN_DEPARTMENT)
.withLeftTable(EMPLOYEE_TABLE)
.withRightTable(DEPARTMENT_TABLE)
.withType(JoinType.MANY_TO_ONE)
.withJoinOn(new JoinOn("departmentId", "id")));
/////////////////////////////////////////////////
// employee n:1 employee (mentor relationship) //
/////////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(EMPLOYEE_JOIN_EMPLOYEE_AS_MENTOR)
.withLeftTable(EMPLOYEE_TABLE) // the mentee
.withRightTable(EMPLOYEE_TABLE) // the mentor
.withType(JoinType.MANY_TO_ONE)
.withJoinOn(new JoinOn("mentorEmployeeId", "id")));
////////////////////////////////////////////////////////////////////////////
// employee 1:1 employeeDetail (employee.id = employeeDetail.employeeId) //
////////////////////////////////////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(EMPLOYEE_JOIN_EMPLOYEE_DETAIL)
.withLeftTable(EMPLOYEE_TABLE)
.withRightTable(EMPLOYEE_DETAIL_TABLE)
.withType(JoinType.ONE_TO_ONE)
.withJoinOn(new JoinOn("id", "employeeId")));
//////////////////////////////////////////////////
// employeeDetail 1:1 employee (flip of above) //
//////////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(EMPLOYEE_DETAIL_JOIN_EMPLOYEE)
.withLeftTable(EMPLOYEE_DETAIL_TABLE)
.withRightTable(EMPLOYEE_TABLE)
.withType(JoinType.ONE_TO_ONE)
.withJoinOn(new JoinOn("employeeId", "id")));
////////////////////////////////////////////////////////////////////
// employee 1:n jobHistory (employee.id = jobHistory.employeeId) //
////////////////////////////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(EMPLOYEE_JOIN_JOB_HISTORY)
.withLeftTable(EMPLOYEE_TABLE)
.withRightTable(JOB_HISTORY_TABLE)
.withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn("id", "employeeId"))
.withOrderBy(new QFilterOrderBy("startDate", false))); // most recent first
//////////////////////////////////////////////
// jobHistory n:1 employee (flip of above) //
//////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(JOB_HISTORY_JOIN_EMPLOYEE)
.withLeftTable(JOB_HISTORY_TABLE)
.withRightTable(EMPLOYEE_TABLE)
.withType(JoinType.MANY_TO_ONE)
.withJoinOn(new JoinOn("employeeId", "id")));
////////////////////////////////////////////////////////////////////
// jobHistory n:1 jobTitle (jobHistory.jobTitleId = jobTitle.id) //
////////////////////////////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(JOB_HISTORY_JOIN_JOB_TITLE)
.withLeftTable(JOB_HISTORY_TABLE)
.withRightTable(JOB_TITLE_TABLE)
.withType(JoinType.MANY_TO_ONE)
.withJoinOn(new JoinOn("jobTitleId", "id")));
//////////////////////////////////////////////
// jobTitle 1:n jobHistory (flip of above) //
//////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(JOB_TITLE_JOIN_JOB_HISTORY)
.withLeftTable(JOB_TITLE_TABLE)
.withRightTable(JOB_HISTORY_TABLE)
.withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn("id", "jobTitleId"))
.withOrderBy(new QFilterOrderBy("startDate", false)));
/////////////////////////////////////////////////////////////////////////////
// department n:1 employee (manager) (department.managerId = employee.id) //
/////////////////////////////////////////////////////////////////////////////
rs.add(new QJoinMetaData()
.withName(DEPARTMENT_JOIN_MANAGER)
.withLeftTable(DEPARTMENT_TABLE)
.withRightTable(EMPLOYEE_TABLE)
.withType(JoinType.MANY_TO_ONE)
.withJoinOn(new JoinOn("managerId", "id")));
return (rs);
}
}
@@ -32,11 +32,13 @@ import com.kingsrook.qbits.workflows.definition.WorkflowType;
import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry;
import com.kingsrook.qbits.workflows.execution.WorkflowExecutionContext;
import com.kingsrook.qbits.workflows.execution.WorkflowInput;
import com.kingsrook.qbits.workflows.execution.WorkflowMultiForkingStepExecutorInterface;
import com.kingsrook.qbits.workflows.execution.WorkflowStepExecutorInterface;
import com.kingsrook.qbits.workflows.execution.WorkflowStepOutput;
import com.kingsrook.qbits.workflows.execution.WorkflowTypeExecutorInterface;
import com.kingsrook.qbits.workflows.execution.WorkflowTypeTesterInterface;
import com.kingsrook.qbits.workflows.model.Workflow;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qbits.workflows.model.WorkflowRevision;
import com.kingsrook.qbits.workflows.model.WorkflowStep;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -56,6 +58,8 @@ public class TestWorkflowDefinitions
public static final String BOOLEAN_CONDITIONAL = "booleanConditional";
public static final String CONTAINER = "container";
public static final String SPLIT_BY_LETTERS_IN_NAME = "splitByLettersInName";
/***************************************************************************
@@ -93,6 +97,12 @@ public class TestWorkflowDefinitions
.withOutboundLinkMode(OutboundLinkMode.CONTAINER)
.withLabel("Container")
.withDescription("Group steps in the UI"));
WorkflowsRegistry.of(QContext.getQInstance()).registerWorkflowStepType(new WorkflowStepType()
.withName(SPLIT_BY_LETTERS_IN_NAME)
.withOutboundLinkMode(OutboundLinkMode.VARIABLE)
.withLabel("Split by letters in name")
.withExecutor(new QCodeReference(SplitByLettersInNameExecutor.class)));
}
@@ -225,4 +235,57 @@ public class TestWorkflowDefinitions
}
}
/***************************************************************************
*
***************************************************************************/
public static class SplitByLettersInNameExecutor implements WorkflowMultiForkingStepExecutorInterface
{
/***************************************************************************
*
***************************************************************************/
@Override
public WorkflowStepOutput conditionallyExecuteFork(WorkflowStep step, WorkflowLink fork, Map<String, Serializable> inputValues, WorkflowExecutionContext context)
{
String conditionValue = fork.getConditionValue();
String name = ValueUtils.getValueAsString(context.getValues().get("name"));
if(name.contains(conditionValue))
{
return (new WorkflowStepOutput(true, "name [" + name + "] contains [" + conditionValue + "]"));
}
else
{
return (new WorkflowStepOutput(false, "name [" + name + "] does not contain [" + conditionValue + "]"));
}
}
/***************************************************************************
*
***************************************************************************/
@Override
public WorkflowStepOutput makeWorkflowStepOutput(WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext context) throws QException
{
return new WorkflowStepOutput(1, "Output");
}
/***************************************************************************
*
***************************************************************************/
@Override
public void sortForks(List<WorkflowLink> forkLinks, WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext context)
{
/////////////////////
// noop by default //
/////////////////////
}
}
}
@@ -27,7 +27,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.kingsrook.qbits.workflows.implementations.recordworkflows.RecordWorkflowsDefinition;
import com.kingsrook.qbits.workflows.model.Workflow;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qbits.workflows.model.WorkflowRevision;
@@ -149,7 +148,7 @@ public class WorkflowsTestDataSource
public static Workflow insertWorkflowAndInitialRevision(String workflowTypeName, String tableName, String apiName, String apiVersion) throws QException
{
QRecord workflow = new InsertAction().execute(new InsertInput(Workflow.TABLE_NAME).withRecordEntity(new Workflow()
.withWorkflowTypeName(RecordWorkflowsDefinition.WORKFLOW_TYPE)
.withWorkflowTypeName(workflowTypeName)
.withTableName(tableName)
.withName("test-" + UUID.randomUUID())
)).getRecords().get(0);
@@ -0,0 +1,96 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.definition;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.workflows.BaseTest;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/*******************************************************************************
** Unit test for WorkflowsRegistry
*******************************************************************************/
class WorkflowsRegistryTest extends BaseTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void test() throws QException
{
String stepTypeName = "myStepType";
WorkflowsRegistry workflowsRegistry = new WorkflowsRegistry();
workflowsRegistry.registerWorkflowStepType(new WorkflowStepType()
.withName(stepTypeName)
.withDescription("originalDescription")
.withLabel("originalLabel")
.withInputFields(List.of(new QFieldMetaData("myField", QFieldType.STRING))));
/////////////////////
// set description //
/////////////////////
workflowsRegistry.acceptHelpContent(QContext.getQInstance(), new QHelpContent("newDescription"), Map.of("workflowStepType", stepTypeName, "slot", "description"));
assertEquals("newDescription", workflowsRegistry.getWorkflowStepType(stepTypeName).getDescription());
///////////////////////
// clear description //
///////////////////////
workflowsRegistry.acceptHelpContent(QContext.getQInstance(), null, Map.of("workflowStepType", stepTypeName, "slot", "description"));
assertNull(workflowsRegistry.getWorkflowStepType(stepTypeName).getDescription());
///////////////
// set label //
///////////////
workflowsRegistry.acceptHelpContent(QContext.getQInstance(), new QHelpContent("newLabel"), Map.of("workflowStepType", stepTypeName, "slot", "label"));
assertEquals("newLabel", workflowsRegistry.getWorkflowStepType(stepTypeName).getLabel());
////////////////////////////////////////////////////////
// clear label, though we can't, so it says as it was //
////////////////////////////////////////////////////////
workflowsRegistry.acceptHelpContent(QContext.getQInstance(), null, Map.of("workflowStepType", stepTypeName, "slot", "label"));
assertEquals("newLabel", workflowsRegistry.getWorkflowStepType(stepTypeName).getLabel());
////////////////////
// set field help //
////////////////////
workflowsRegistry.acceptHelpContent(QContext.getQInstance(), new QHelpContent("fieldHelp"), Map.of("workflowStepType", stepTypeName, "field", "myField"));
assertEquals("fieldHelp", workflowsRegistry.getWorkflowStepType(stepTypeName).getInputFields().get(0).getHelpContents().get(0).getContent());
//////////////////////
// clear field help //
//////////////////////
workflowsRegistry.acceptHelpContent(QContext.getQInstance(), null, Map.of("workflowStepType", stepTypeName, "field", "myField"));
assertNull(workflowsRegistry.getWorkflowStepType(stepTypeName).getInputFields().get(0).getHelpContents());
}
}
@@ -0,0 +1,130 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.execution;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.workflows.BaseTest;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qbits.workflows.model.WorkflowStep;
import com.kingsrook.qqq.backend.core.utils.ListingHash;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/*******************************************************************************
** Unit test for FindFirstJoinPoint
*******************************************************************************/
class FindFirstJoinPointTest extends BaseTest
{
/*******************************************************************************
* 1
* / | \
* / | \
* 2 3 4
* | | / \
* | 5 6 7
* | | | |
* | | | 8
* | | \ /
* | | 9
* \ | /
* 10
*******************************************************************************/
@Test
void test()
{
Map<Integer, WorkflowStep> nodeMap = makeNodeMap(10);
ListingHash<Integer, WorkflowLink> outEdges = new ListingHash<>();
addEdge(outEdges, 1, 2);
addEdge(outEdges, 1, 3);
addEdge(outEdges, 1, 4);
addEdge(outEdges, 2, 10);
addEdge(outEdges, 3, 5);
addEdge(outEdges, 4, 6);
addEdge(outEdges, 5, 10);
addEdge(outEdges, 6, 9);
addEdge(outEdges, 7, 8);
addEdge(outEdges, 8, 9);
addEdge(outEdges, 9, 10);
/////////////////////////////////
// start from the top (node 1) //
/////////////////////////////////
assertEquals(10, FindFirstJoinPoint.execute(List.of(2, 3, 4), nodeMap, outEdges));
////////////////////////////////////////////
// start from the rightmost fork (node 4) //
////////////////////////////////////////////
assertEquals(9, FindFirstJoinPoint.execute(List.of(6, 7), nodeMap, outEdges));
//////////////////////////////////////////////////////////////////////////////////
// remove node 10 - so they all just end and don't re-join (so expect null out) //
//////////////////////////////////////////////////////////////////////////////////
nodeMap.remove(10);
outEdges.remove(9);
outEdges.remove(2);
outEdges.remove(5);
assertNull(FindFirstJoinPoint.execute(List.of(2, 3, 4), nodeMap, outEdges));
}
/***************************************************************************
*
***************************************************************************/
private void addEdge(ListingHash<Integer, WorkflowLink> outEdges, Integer fromStepNo, Integer toStepNo)
{
outEdges.add(fromStepNo, new WorkflowLink().withToStepNo(toStepNo));
}
/***************************************************************************
*
***************************************************************************/
private Map<Integer, WorkflowStep> makeNodeMap(int n)
{
Map<Integer, WorkflowStep> nodeMap = new HashMap<>();
for(int i = 1; i <= n; i++)
{
addNode(nodeMap, i);
}
return (nodeMap);
}
/***************************************************************************
*
***************************************************************************/
private void addNode(Map<Integer, WorkflowStep> nodeMap, Integer stepNo)
{
nodeMap.put(stepNo, new WorkflowStep().withStepNo(stepNo));
}
}
@@ -29,8 +29,16 @@ import com.kingsrook.qbits.workflows.BaseTest;
import com.kingsrook.qbits.workflows.TestWorkflowDefinitions;
import com.kingsrook.qbits.workflows.WorkflowsTestDataSource;
import com.kingsrook.qbits.workflows.model.Workflow;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeFunction;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -103,183 +111,312 @@ class WorkflowExecutorTest extends BaseTest
**
*******************************************************************************/
@Test
void testContainers() throws QException
void testSimpleContainer() throws QException
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
{
/////////////////////////////////////
// . 1 //
// . | //
// /-2-\ opens container2 (push) //
// | | | //
// | 3 | //
// | | | //
// \-4-/ last in container2 (pop) //
// . | //
// . 5 //
/////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)),
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 3)),
WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2),
WorkflowsTestDataSource.newLink(2, 3, "push"),
WorkflowsTestDataSource.newLink(3, 4),
WorkflowsTestDataSource.newLink(2, 5, "pop")
));
/////////////////////////////////////
// . 1 //
// . | //
// /-2-\ opens container2 (push) //
// | | | //
// | 3 | //
// | | | //
// \-4-/ last in container2 (pop) //
// . | //
// . 5 //
/////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)),
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 3)),
WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2),
WorkflowsTestDataSource.newLink(2, 3, "push"),
WorkflowsTestDataSource.newLink(3, 4),
WorkflowsTestDataSource.newLink(2, 5, "pop")
));
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(10, output.getContext().getValues().get("sum"));
}
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(10, output.getContext().getValues().get("sum"));
}
{
///////////////////////////////////////////////
// . 1 //
// . | //
// /---2---\ opens outer container2 (push) //
// | | | //
// | 3 | //
// | | | //
// | /-4-\ | opens nested container4 (push) //
// | | | | | //
// | | 5 | | //
// | | | | | //
// | \-6-/ | last in nested container (pop) //
// | | | //
// \---7---/ last in outer container (pop) //
// . | //
// . 8 //
///////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)),
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 3)),
WorkflowsTestDataSource.newStep(6, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4)),
WorkflowsTestDataSource.newStep(7, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 5)),
WorkflowsTestDataSource.newStep(8, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 6))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2),
WorkflowsTestDataSource.newLink(2, 3, "push"),
WorkflowsTestDataSource.newLink(3, 4),
WorkflowsTestDataSource.newLink(4, 5, "push"),
WorkflowsTestDataSource.newLink(5, 6),
WorkflowsTestDataSource.newLink(4, 7, "pop"),
WorkflowsTestDataSource.newLink(2, 8, "pop")
));
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(21, output.getContext().getValues().get("sum"));
}
{
////////////////////////////////////////////////////////////////////////////////////////////////
// /---1---\ opens outer container1 (push) //
// | | | //
// | /-2-\ | opens nested container2 (push) //
// | | | | | //
// .\\-3-// last in nested container2 and outer container1 (but since no next step, no pops) //
////////////////////////////////////////////////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "push"),
WorkflowsTestDataSource.newLink(2, 3, "push")
));
/*******************************************************************************
**
*******************************************************************************/
@Test
void testNestedContainers() throws QException
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(1, output.getContext().getValues().get("sum"));
}
///////////////////////////////////////////////
// . 1 //
// . | //
// /---2---\ opens outer container2 (push) //
// | | | //
// | 3 | //
// | | | //
// | /-4-\ | opens nested container4 (push) //
// | | | | | //
// | | 5 | | //
// | | | | | //
// | \-6-/ | last in nested container (pop) //
// | | | //
// \---7---/ last in outer container (pop) //
// . | //
// . 8 //
///////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)),
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 3)),
WorkflowsTestDataSource.newStep(6, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4)),
WorkflowsTestDataSource.newStep(7, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 5)),
WorkflowsTestDataSource.newStep(8, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 6))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2),
WorkflowsTestDataSource.newLink(2, 3, "push"),
WorkflowsTestDataSource.newLink(3, 4),
WorkflowsTestDataSource.newLink(4, 5, "push"),
WorkflowsTestDataSource.newLink(5, 6),
WorkflowsTestDataSource.newLink(4, 7, "pop"),
WorkflowsTestDataSource.newLink(2, 8, "pop")
));
{
////////////////////////////////////////////////////////////////////////////////
// /---1---\ opens outer container1 (push) //
// | | | //
// | /-2-\ | opens nested container2 (push) //
// | | | | | //
// .\\-3-// last in nested container2 outer container1 (pop (the outermost)) //
// . | //
// . 4 //
////////////////////////////////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)),
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "push"),
WorkflowsTestDataSource.newLink(2, 3, "push"),
WorkflowsTestDataSource.newLink(1, 4, "pop")
));
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(21, output.getContext().getValues().get("sum"));
}
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(3, output.getContext().getValues().get("sum"));
}
{
/////////////////////////////////////////////////////////////////
// /---1---\ opens outer container1 //
// | | | //
// | <-2-> | opens empty container2 and immediately closes it //
// | | | //
// \---3---/ last in outer container1 //
/////////////////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "push"),
WorkflowsTestDataSource.newLink(2, 3, "pop")
));
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(1, output.getContext().getValues().get("sum"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testNestedContainersWithNoNextStep() throws QException
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
{
/////////////////////////////////////////////////////
// . <-1-> opens empty container1 and closes it //
// . | //
// . <-2-> opens empty container2 and closes it //
// . | //
// . <-3-> opens empty container3 and closes it //
// . | //
// . 4 //
/////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "pop"),
WorkflowsTestDataSource.newLink(2, 3, "pop"),
WorkflowsTestDataSource.newLink(3, 4, "pop")
));
////////////////////////////////////////////////////////////////////////////////////////////////
// /---1---\ opens outer container1 (push) //
// | | | //
// | /-2-\ | opens nested container2 (push) //
// | | | | | //
// .\\-3-// last in nested container2 and outer container1 (but since no next step, no pops) //
////////////////////////////////////////////////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "push"),
WorkflowsTestDataSource.newLink(2, 3, "push")
));
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(1, output.getContext().getValues().get("sum"));
}
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(1, output.getContext().getValues().get("sum"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testNestedContainersWithPopToOutermost() throws QException
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
////////////////////////////////////////////////////////////////////////////////
// /---1---\ opens outer container1 (push) //
// | | | //
// | /-2-\ | opens nested container2 (push) //
// | | | | | //
// .\\-3-// last in nested container2 outer container1 (pop (the outermost)) //
// . | //
// . 4 //
////////////////////////////////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)),
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "push"),
WorkflowsTestDataSource.newLink(2, 3, "push"),
WorkflowsTestDataSource.newLink(1, 4, "pop")
));
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(3, output.getContext().getValues().get("sum"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testEmptyNestedContainer() throws QException
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
/////////////////////////////////////////////////////////////////
// /---1---\ opens outer container1 //
// | | | //
// | <-2-> | opens empty container2 and immediately closes it //
// | | | //
// \---3---/ last in outer container1 //
/////////////////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "push"),
WorkflowsTestDataSource.newLink(2, 3, "pop")
));
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(1, output.getContext().getValues().get("sum"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testMultipleSequentialEmptyContainers() throws QException
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
/////////////////////////////////////////////////////
// . <-1-> opens empty container1 and closes it //
// . | //
// . <-2-> opens empty container2 and closes it //
// . | //
// . <-3-> opens empty container3 and closes it //
// . | //
// . 4 //
/////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "pop"),
WorkflowsTestDataSource.newLink(2, 3, "pop"),
WorkflowsTestDataSource.newLink(3, 4, "pop")
));
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0));
assertEquals(1, output.getContext().getValues().get("sum"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testContainerEndingWithConditionalWithOneEmptySide_trueCondition() throws QException
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
////////////////////////////////////////////////////
// . 1 //
// . | //
// /---2---\ opens outer container2 (push) //
// | | | //
// | 3 | if/else style step //
// | / \ | //
// | 4 | | one step in the true, none in false //
// | | | | //
// \--\-/--/ last in outer container (pop) //
// . | //
// . 5 //
////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.BOOLEAN_CONDITIONAL, Map.of()),
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)),
WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2),
WorkflowsTestDataSource.newLink(2, 3, "push"),
WorkflowsTestDataSource.newLink(3, 4, true),
WorkflowsTestDataSource.newLink(3, null, false),
WorkflowsTestDataSource.newLink(2, 5, "pop")
));
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0, "condition", true));
assertEquals(7, output.getContext().getValues().get("sum"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testContainerEndingWithConditionalWithOneEmptySide_falseCondition() throws QException
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
////////////////////////////////////////////////////
// . 1 //
// . | //
// /---2---\ opens outer container2 (push) //
// | | | //
// | 3 | if/else style step //
// | / \ | //
// | 4 | | one step in the true, none in false //
// | | | | //
// \--\-/--/ last in outer container (pop) //
// . | //
// . 5 //
////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.CONTAINER, Map.of()),
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.BOOLEAN_CONDITIONAL, Map.of()),
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)),
WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2),
WorkflowsTestDataSource.newLink(2, 3, "push"),
WorkflowsTestDataSource.newLink(3, 4, true),
WorkflowsTestDataSource.newLink(3, null, false),
WorkflowsTestDataSource.newLink(2, 5, "pop")
));
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0, "condition", false));
assertEquals(5, output.getContext().getValues().get("sum"));
}
@@ -301,6 +438,222 @@ class WorkflowExecutorTest extends BaseTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testMultiForkingStep_individualForks() throws Exception
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
///////////////////////////////////
// build a workflow with 3 forks //
// . 1 //
// ./ | \ //
// 2 3 4 //
///////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)), // "a"
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)), // "b"
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4)) // "c"
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "a"), // += 1
WorkflowsTestDataSource.newLink(1, 3, "b"), // += 2
WorkflowsTestDataSource.newLink(1, 4, "c"), // += 3
WorkflowsTestDataSource.newLink(2, null),
WorkflowsTestDataSource.newLink(3, null),
WorkflowsTestDataSource.newLink(4, null)
));
UnsafeFunction<String, Integer, ?> run = (String name) ->
{
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0, "name", name));
return ValueUtils.getValueAsInteger(output.getContext().getValues().get("sum"));
};
assertEquals(1, run.apply("dave"));
assertEquals(3, run.apply("barry"));
assertEquals(5, run.apply("carl"));
assertEquals(7, run.apply("braces"));
assertEquals(0, run.apply("lou"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testMultiForkingStep_forksConverging() throws Exception
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
///////////////////////////////////
// build a workflow with 3 forks //
// . 1 //
// ./ | \ //
// 2 3 4 //
///////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)), // "a"
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)), // "b"
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4)) // "c"
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "a"), // += 1
WorkflowsTestDataSource.newLink(1, 3, "b"), // += 2
WorkflowsTestDataSource.newLink(1, 4, "c"), // += 3
WorkflowsTestDataSource.newLink(2, null),
WorkflowsTestDataSource.newLink(3, null),
WorkflowsTestDataSource.newLink(4, null)
));
//////////////////////////////////////
// add a step (5) after the 3 forks //
//////////////////////////////////////
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 8))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(2, 5),
WorkflowsTestDataSource.newLink(3, 5),
WorkflowsTestDataSource.newLink(4, 5)
));
//////////////////////////////////
// get rid of the links to null //
//////////////////////////////////
new DeleteAction().execute(new DeleteInput(WorkflowLink.TABLE_NAME).withQueryFilter(new QQueryFilter(new QFilterCriteria("toStepNo", QCriteriaOperator.IS_BLANK))));
UnsafeFunction<String, Integer, ?> run = (String name) ->
{
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0, "name", name));
return ValueUtils.getValueAsInteger(output.getContext().getValues().get("sum"));
};
assertEquals(10, run.apply("bobby"));
assertEquals(15, run.apply("braces"));
assertEquals(8, run.apply("lou"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testNestedMultiForkingStep() throws Exception
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
/////////////////////////////////////////////////////////////////////////////
// build a workflow with 3 top-level forks, one of which has 2 nested ones //
// . 1 //
// ./ | \ //
// 2 3 4 //
// | | | \ //
// | | 5 6 //
// .\ \ / / //
// . 7 //
/////////////////////////////////////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)), // "a"
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)), // "b"
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()),
WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4)), // "c"
WorkflowsTestDataSource.newStep(6, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 8)), // "d"
WorkflowsTestDataSource.newStep(7, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 16))
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "a"), // += 1
WorkflowsTestDataSource.newLink(1, 3, "b"), // += 2
WorkflowsTestDataSource.newLink(1, 4, ""), //
WorkflowsTestDataSource.newLink(4, 5, "c"), // += 4
WorkflowsTestDataSource.newLink(4, 6, "d"), // += 8
WorkflowsTestDataSource.newLink(2, 7), // += 16
WorkflowsTestDataSource.newLink(3, 7), // += 16
WorkflowsTestDataSource.newLink(5, 7), // += 16
WorkflowsTestDataSource.newLink(6, 7) // += 16
));
UnsafeFunction<String, Integer, ?> run = (String name) ->
{
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0, "name", name));
return ValueUtils.getValueAsInteger(output.getContext().getValues().get("sum"));
};
assertEquals(17, run.apply("allen"));
assertEquals(19, run.apply("barry"));
assertEquals(21, run.apply("carl"));
assertEquals(24, run.apply("donny"));
assertEquals(16, run.apply("lou"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testStackedMultiForkingStep() throws Exception
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
/////////////////////////////////////////////
// build a workflow with two 2-level forks //
// . 1 //
// ./ \ //
// 2 3 //
// .\ / //
// . 4 //
// ./ \ //
// 5 6 //
/////////////////////////////////////////////
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(TestWorkflowDefinitions.TEST_WORKFLOW_TYPE, null);
WorkflowsTestDataSource.insertSteps(workflow, List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 1)), // "a"
WorkflowsTestDataSource.newStep(3, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 2)), // "b"
WorkflowsTestDataSource.newStep(4, TestWorkflowDefinitions.SPLIT_BY_LETTERS_IN_NAME, Map.of()),
WorkflowsTestDataSource.newStep(5, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 4)), // "c"
WorkflowsTestDataSource.newStep(6, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("x", 8)) // "d"
));
WorkflowsTestDataSource.insertLinks(workflow, List.of(
WorkflowsTestDataSource.newLink(1, 2, "a"), // += 1
WorkflowsTestDataSource.newLink(1, 3, "b"), // += 2
WorkflowsTestDataSource.newLink(2, 4),
WorkflowsTestDataSource.newLink(3, 4),
WorkflowsTestDataSource.newLink(4, 5, "c"), // += 4
WorkflowsTestDataSource.newLink(4, 6, "d"), // += 8
WorkflowsTestDataSource.newLink(5, null),
WorkflowsTestDataSource.newLink(6, null)
));
UnsafeFunction<String, Integer, ?> run = (String name) ->
{
WorkflowOutput output = executeWorkflow(workflow.getId(), Map.of("seedValue", 0, "name", name));
return ValueUtils.getValueAsInteger(output.getContext().getValues().get("sum"));
};
assertEquals(1, run.apply("allen"));
assertEquals(3, run.apply("barry"));
assertEquals(5, run.apply("carl"));
assertEquals(8, run.apply("donny"));
assertEquals(4, run.apply("connie"));
assertEquals(12, run.apply("doc"));
assertEquals(15, run.apply("abcd"));
assertEquals(0, run.apply("lou"));
}
/***************************************************************************
**
***************************************************************************/
@@ -0,0 +1,172 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.execution.nodes;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.workflows.BaseTest;
import com.kingsrook.qbits.workflows.TestWorkflowDefinitions;
import com.kingsrook.qbits.workflows.WorkflowsTestDataSource;
import com.kingsrook.qbits.workflows.model.WorkflowLink;
import com.kingsrook.qbits.workflows.model.WorkflowStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
/*******************************************************************************
** Unit test for StepNodeGraphBuilder
*******************************************************************************/
class StepNodeGraphBuilderTest extends BaseTest
{
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testSimpleFlat()
{
List<WorkflowStep> steps = new ArrayList<>();
WorkflowStep step1 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null);
WorkflowStep step2 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null);
WorkflowStep step3 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null);
List<WorkflowLink> links = new ArrayList<>();
links.add(WorkflowsTestDataSource.newLink(step1.getStepNo(), step2.getStepNo()));
links.add(WorkflowsTestDataSource.newLink(step2.getStepNo(), step3.getStepNo()));
NodeSequence nodeSequence = new StepNodeGraphBuilder().buildNodeSequence(step1.getStepNo(), steps, links);
assertSame(step1, nodeSequence.get(0).step());
assertSame(step2, nodeSequence.get(1).step());
assertSame(step3, nodeSequence.get(2).step());
}
/*******************************************************************************
* we'll evolve this graph through these 4 versions:
*
* . add1 > add1 > add1 . > (wrap it all in a container)
* . | > | > |
* .branch > branch > branch
* ./ > / \ > / \
* add2 > add2 add3 > add2 add3
* . \ /
* . add4
*******************************************************************************/
@Test
void testSimpleBranch()
{
List<WorkflowStep> steps = new ArrayList<>();
List<WorkflowLink> links = new ArrayList<>();
WorkflowStep add1 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null);
WorkflowStep branch = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.BOOLEAN_CONDITIONAL, null);
WorkflowStep add2 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null);
links.add(WorkflowsTestDataSource.newLink(add1.getStepNo(), branch.getStepNo()));
links.add(WorkflowsTestDataSource.newLink(branch.getStepNo(), add2.getStepNo(), "true"));
{
NodeSequence nodeSequence = StepNodeGraphBuilder.buildNodeSequence(add1.getStepNo(), steps, links);
assertEquals(2, nodeSequence.size());
assertSame(add1, nodeSequence.get(0).step());
assertSame(branch, nodeSequence.get(1).step());
StepNode branchNode = nodeSequence.get(1);
assertEquals(1, branchNode.subSequences().size());
assertEquals(1, branchNode.subSequences().get("true").size());
assertSame(add2, branchNode.subSequences().get("true").get(0).step());
}
////////////////////////////////////
// add a step in the false branch //
////////////////////////////////////
WorkflowStep add3 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null);
links.add(WorkflowsTestDataSource.newLink(branch.getStepNo(), add3.getStepNo(), "false"));
{
NodeSequence nodeSequence = StepNodeGraphBuilder.buildNodeSequence(add1.getStepNo(), steps, links);
assertEquals(2, nodeSequence.size());
assertSame(add1, nodeSequence.get(0).step());
assertSame(branch, nodeSequence.get(1).step());
StepNode branchNode = nodeSequence.get(1);
assertEquals(2, branchNode.subSequences().size());
assertEquals(1, branchNode.subSequences().get("true").size());
assertSame(add2, branchNode.subSequences().get("true").get(0).step());
assertEquals(1, branchNode.subSequences().get("false").size());
assertSame(add3, branchNode.subSequences().get("false").get(0).step());
}
/////////////////////////////////////////////////
// add a step after the branch now, and re-run //
/////////////////////////////////////////////////
WorkflowStep add4 = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, null);
links.add(WorkflowsTestDataSource.newLink(add2.getStepNo(), add4.getStepNo()));
links.add(WorkflowsTestDataSource.newLink(add3.getStepNo(), add4.getStepNo()));
{
NodeSequence nodeSequence = StepNodeGraphBuilder.buildNodeSequence(add1.getStepNo(), steps, links);
assertEquals(3, nodeSequence.size());
assertSame(add1, nodeSequence.get(0).step());
assertSame(branch, nodeSequence.get(1).step());
assertSame(add4, nodeSequence.get(2).step());
}
///////////////////////////////////
// now put it all in a container //
///////////////////////////////////
WorkflowStep container = WorkflowsTestDataSource.newStep(steps, TestWorkflowDefinitions.CONTAINER, null);
links.add(WorkflowsTestDataSource.newLink(container.getStepNo(), add1.getStepNo(), "push"));
{
NodeSequence nodeSequence = StepNodeGraphBuilder.buildNodeSequence(container.getStepNo(), steps, links);
assertEquals(1, nodeSequence.size());
assertSame(container, nodeSequence.get(0).step());
StepNode containerNode = nodeSequence.get(0);
assertEquals(1, containerNode.subSequences().size());
NodeSequence containedSequence = containerNode.subSequences().get("push");
assertEquals(3, containedSequence.size());
assertSame(add1, containedSequence.get(0).step());
assertSame(branch, containedSequence.get(1).step());
assertSame(add4, containedSequence.get(2).step());
}
}
}
@@ -0,0 +1,578 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.implementations.recordworkflows;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.workflows.BaseTest;
import com.kingsrook.qbits.workflows.execution.WorkflowStepOutput;
import com.kingsrook.qbits.workflows.model.Workflow;
import com.kingsrook.qbits.workflows.model.WorkflowRevision;
import com.kingsrook.qbits.workflows.model.WorkflowStep;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.COMPANY_TABLE;
import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.DEPARTMENT_TABLE;
import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.EMPLOYEE_DETAIL_TABLE;
import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.EMPLOYEE_TABLE;
import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.JOB_HISTORY_TABLE;
import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.JOB_TITLE_TABLE;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
/*******************************************************************************
** Unit test for InputRecordFilterStep
*******************************************************************************/
class InputRecordFilterStepTest extends BaseTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void testWithoutJoins() throws QException
{
//////////////////////////////////////////////////
// empty filter matches any record (even empty) //
//////////////////////////////////////////////////
QQueryFilter emptyFilter = new QQueryFilter();
assertOutput(true, EMPLOYEE_TABLE, emptyFilter, new QRecord());
assertOutput(true, EMPLOYEE_TABLE, new QQueryFilter(), new QRecord().withValue("id", 1));
/////////////////////////////////////////////////
// simple single criteria - matches or doesn't //
/////////////////////////////////////////////////
QQueryFilter idEquals1 = new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, 1);
assertOutput(false, EMPLOYEE_TABLE, idEquals1, new QRecord());
assertOutput(false, EMPLOYEE_TABLE, idEquals1, Map.of("id", 2));
assertOutput(true, EMPLOYEE_TABLE, idEquals1, Map.of("id", 1));
}
/*******************************************************************************
** with an api version on the revision, filter fields are api field names -
** v1's shoeCount was replaced by the backend's noOfShoes.
*******************************************************************************/
@Test
void testFilterUsingApiFieldNames() throws QException
{
WorkflowRevision workflowRevisionV1 = new WorkflowRevision().withApiName(API_NAME).withApiVersion(V1);
QQueryFilter shoeCountIs47 = new QQueryFilter().withCriteria("shoeCount", QCriteriaOperator.EQUALS, 47);
assertOutput(true, TABLE_NAME_PERSON, shoeCountIs47, workflowRevisionV1, Map.of("id", 1, "noOfShoes", 47));
assertOutput(false, TABLE_NAME_PERSON, shoeCountIs47, workflowRevisionV1, Map.of("id", 1, "noOfShoes", 3));
}
/*******************************************************************************
** validation checks filter fields against the api version's fields.
*******************************************************************************/
@Test
void testValidateUsingApiFieldNames() throws QException
{
QRecord workflowRevisionV1 = new WorkflowRevision().withApiName(API_NAME).withApiVersion(V1).toQRecord();
QRecord workflow = new Workflow().withTableName(TABLE_NAME_PERSON).toQRecord();
assertThat(runValidate(new QQueryFilter().withCriteria("shoeCount", QCriteriaOperator.EQUALS, 47), workflowRevisionV1, workflow))
.isEmpty();
/////////////////////////////////////////
// noOfShoes only joined the api in v2 //
/////////////////////////////////////////
assertThat(runValidate(new QQueryFilter().withCriteria("noOfShoes", QCriteriaOperator.EQUALS, 47), workflowRevisionV1, workflow))
.containsExactly("Unrecognized criteria field name: noOfShoes.");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testOneToOneJoin() throws Exception
{
new InsertAction().execute(new InsertInput(EMPLOYEE_DETAIL_TABLE).withRecord(new QRecord().withValue("employeeId", 1).withValue("phone", "555-1234")));
QQueryFilter phoneContains555 = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".phone", QCriteriaOperator.CONTAINS, "555");
QQueryFilter phoneContains911 = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".phone", QCriteriaOperator.CONTAINS, "911");
///////////////////////////////////////////////////////////////////////
// employee with detail record with matching phone number is a match //
///////////////////////////////////////////////////////////////////////
assertOutput(true, EMPLOYEE_TABLE, phoneContains555, Map.of("id", 1));
///////////////////////////////////////////////////////////////////////////////
// employee with detail record with non-matching phone number is NOT a match //
///////////////////////////////////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, phoneContains911, Map.of("id", 1));
///////////////////////////////////////////////////
// employee without detail record is NOT a match //
///////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, phoneContains555, Map.of("id", 2));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testManyToOneJoin() throws Exception
{
new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of(
new QRecord().withValue("id", 1).withValue("name", "Engineering"),
new QRecord().withValue("id", 2).withValue("name", "Sales"))));
QQueryFilter departmentIsEngineering = new QQueryFilter().withCriteria(DEPARTMENT_TABLE + ".name", QCriteriaOperator.EQUALS, "Engineering");
//////////////////////////////////////////////////
// employee with matching department is a match //
//////////////////////////////////////////////////
assertOutput(true, EMPLOYEE_TABLE, departmentIsEngineering, Map.of("departmentId", 1));
//////////////////////////////////////////////////////////
// employee with non-matching department is NOT a match //
//////////////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, Map.of("departmentId", 2));
//////////////////////////////////////////////////
// employee without a department is not a match //
//////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, Map.of());
assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, MapBuilder.of("departmentId", null));
assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, Map.of("departmentId", 3));
}
/*******************************************************************************
* employee N:1 department N:1 company
*******************************************************************************/
@Test
void testManyToOneThenManyToOneJoin() throws Exception
{
new InsertAction().execute(new InsertInput(COMPANY_TABLE).withRecords(List.of(
new QRecord().withValue("id", 1).withValue("name", "ACME"),
new QRecord().withValue("id", 2).withValue("name", "EvilCorp"))));
new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of(
new QRecord().withValue("id", 11).withValue("companyId", 1).withValue("name", "Engineering"),
new QRecord().withValue("id", 12).withValue("companyId", 1).withValue("name", "Sales"),
new QRecord().withValue("id", 21).withValue("companyId", 2).withValue("name", "Evil"),
new QRecord().withValue("id", 31).withValue("companyId", null).withValue("name", "Company-less"))));
QQueryFilter companyIsACME = new QQueryFilter().withCriteria(COMPANY_TABLE + ".name", QCriteriaOperator.EQUALS, "ACME");
///////////////////////////////////////////////
// employee with matching company is a match //
///////////////////////////////////////////////
assertOutput(true, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 11));
//////////////////////////////////////////////////////////
// employee with non-matching department is NOT a match //
//////////////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 21));
////////////////////////////////////////////////////////////////////
// employee without a department (thus no company) is not a match //
////////////////////////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of());
assertOutput(false, EMPLOYEE_TABLE, companyIsACME, MapBuilder.of("departmentId", null));
assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 3));
//////////////////////////////////////////////////////////////////////
// employee without a department that has no company is not a match //
//////////////////////////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 31));
}
/*******************************************************************************
** department 1:N employee 1:1 employeeDetail
*******************************************************************************/
@Test
void testOneToManyThenOneToOneJoin() throws Exception
{
//////////////////////////////////////////////////////////////////////////////////////
// this test queries for Departments, but on fields from EmployeeDetail //
// The relationship is Department, one-to-many Employee, one-to-one EmployeeDetail. //
//////////////////////////////////////////////////////////////////////////////////////
new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of(
new QRecord().withValue("id", 1).withValue("departmentId", 11),
new QRecord().withValue("id", 2).withValue("departmentId", 21),
new QRecord().withValue("id", 3).withValue("departmentId", 31))));
new InsertAction().execute(new InsertInput(EMPLOYEE_DETAIL_TABLE).withRecords(List.of(
new QRecord().withValue("id", 101).withValue("employeeId", 1).withValue("salary", new BigDecimal("105000")),
new QRecord().withValue("id", 102).withValue("employeeId", 2).withValue("salary", new BigDecimal("95000")))));
QQueryFilter hasEmployeeOver100K = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".salary", QCriteriaOperator.GREATER_THAN, new BigDecimal("100000"));
///////////////////////////////////////////////////////////
// department with a matching employee detail is a match //
///////////////////////////////////////////////////////////
assertOutput(true, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 11));
/////////////////////////////////////////////////////////////////////////////////////
// department with an employee, who has a detail, but doesn't match is NOT a match //
/////////////////////////////////////////////////////////////////////////////////////
assertOutput(false, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 21));
/////////////////////////////////////////////////////////////////////
// department with no employee, but no employee detail is no match //
/////////////////////////////////////////////////////////////////////
assertOutput(false, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 31));
/////////////////////////////////////////////
// department with no employee is no match //
/////////////////////////////////////////////
assertOutput(false, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 41));
}
/*******************************************************************************
** employee 1:N jobHistory
*******************************************************************************/
@Test
void testOneToManyJoin() throws Exception
{
new InsertAction().execute(new InsertInput(JOB_HISTORY_TABLE).withRecords(List.of(
new QRecord().withValue("id", 101).withValue("employeeId", 1).withValue("salaryAtTime", new BigDecimal("120000")),
new QRecord().withValue("id", 102).withValue("employeeId", 2).withValue("salaryAtTime", new BigDecimal("90000")))));
QQueryFilter hasJobHistoryOver100K = new QQueryFilter().withCriteria(JOB_HISTORY_TABLE + ".salaryAtTime", QCriteriaOperator.GREATER_THAN, new BigDecimal("100000"));
//////////////////////////////////////////////////
// employee with a matching job history matches //
//////////////////////////////////////////////////
assertOutput(true, EMPLOYEE_TABLE, hasJobHistoryOver100K, Map.of("id", 1));
///////////////////////////////////////////////////
// employee with only non-matching rows does not //
///////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, hasJobHistoryOver100K, Map.of("id", 2));
////////////////////////////////////
// employee with no rows does not //
////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, hasJobHistoryOver100K, Map.of("id", 3));
}
/*******************************************************************************
** employee 1:N jobHistory N:1 jobTitle
*******************************************************************************/
@Test
void testOneToManyThenManyToOneJoin() throws Exception
{
new InsertAction().execute(new InsertInput(JOB_TITLE_TABLE).withRecords(List.of(
new QRecord().withValue("id", 11).withValue("name", "Engineer"),
new QRecord().withValue("id", 12).withValue("name", "Intern"))));
new InsertAction().execute(new InsertInput(JOB_HISTORY_TABLE).withRecords(List.of(
new QRecord().withValue("id", 101).withValue("employeeId", 1).withValue("jobTitleId", 11),
new QRecord().withValue("id", 102).withValue("employeeId", 1).withValue("jobTitleId", 12),
new QRecord().withValue("id", 201).withValue("employeeId", 2).withValue("jobTitleId", 12),
new QRecord().withValue("id", 301).withValue("employeeId", 3).withValue("jobTitleId", null))));
QQueryFilter hasHadEngineerTitle = new QQueryFilter().withCriteria(JOB_TITLE_TABLE + ".name", QCriteriaOperator.EQUALS, "Engineer");
QQueryFilter hasHadInternTitle = new QQueryFilter().withCriteria(JOB_TITLE_TABLE + ".name", QCriteriaOperator.EQUALS, "Intern");
////////////////////////////////////////////////////////////
// employee with multiple matching titles through history //
////////////////////////////////////////////////////////////
assertOutput(true, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 1));
assertOutput(true, EMPLOYEE_TABLE, hasHadInternTitle, Map.of("id", 1));
/////////////////////////////////////////////////////////
// employee with single matching title through history //
/////////////////////////////////////////////////////////
assertOutput(true, EMPLOYEE_TABLE, hasHadInternTitle, Map.of("id", 1));
//////////////////////////////////////////////////////
// employee with non-matching title through history //
//////////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 2));
////////////////////////////////////////////////////////
// employee with job history but missing title target //
////////////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 3));
//////////////////////////////////
// employee with no job history //
//////////////////////////////////
assertOutput(false, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 4));
}
/*******************************************************************************
** company 1:N department 1:N employee
*******************************************************************************/
@Test
void testOneToManyThenOneToManyJoin() throws Exception
{
new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of(
new QRecord().withValue("id", 11).withValue("companyId", 1).withValue("name", "Engineering"),
new QRecord().withValue("id", 12).withValue("companyId", 1).withValue("name", "Support"),
new QRecord().withValue("id", 21).withValue("companyId", 2).withValue("name", "Support"),
new QRecord().withValue("id", 31).withValue("companyId", 3).withValue("name", "Safety"),
new QRecord().withValue("id", 41).withValue("companyId", 4).withValue("name", "Empty Department"))));
new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of(
new QRecord().withValue("departmentId", 11).withValue("lastName", "Smith").withValue("firstName", "Ozzie"),
new QRecord().withValue("departmentId", 12).withValue("lastName", "Smith").withValue("firstName", "Will"),
new QRecord().withValue("departmentId", 11).withValue("lastName", "Jones"),
new QRecord().withValue("departmentId", 21).withValue("lastName", "Jones"),
new QRecord().withValue("departmentId", 31).withValue("lastName", "Simpson"),
new QRecord().withValue("departmentId", 21).withValue("lastName", "Taylor"))));
QQueryFilter hasEmployeeNamedSmith = new QQueryFilter().withCriteria(EMPLOYEE_TABLE + ".lastName", QCriteriaOperator.EQUALS, "Smith");
QQueryFilter hasEmployeeNamedSimpson = new QQueryFilter().withCriteria(EMPLOYEE_TABLE + ".lastName", QCriteriaOperator.EQUALS, "Simpson");
/////////////////////////////////////////////////
// company with at least one matching employee //
/////////////////////////////////////////////////
assertOutput(true, COMPANY_TABLE, hasEmployeeNamedSmith, Map.of("id", 1));
assertOutput(true, COMPANY_TABLE, hasEmployeeNamedSimpson, Map.of("id", 3));
//////////////////////////////////////////////
// company with employees but none matching //
//////////////////////////////////////////////
assertOutput(false, COMPANY_TABLE, hasEmployeeNamedSmith, Map.of("id", 2));
///////////////////////////////////////////////
// company with departments but no employees //
///////////////////////////////////////////////
assertOutput(false, COMPANY_TABLE, hasEmployeeNamedSmith, Map.of("id", 4));
}
/*******************************************************************************
** department N:1 manager(employee) 1:N jobHistory
*******************************************************************************/
@Test
void testManyToOneThenOneToManyJoin() throws Exception
{
new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of(
new QRecord().withValue("id", 1).withValue("lastName", "Smith"),
new QRecord().withValue("id", 2).withValue("lastName", "Jones"),
new QRecord().withValue("id", 3).withValue("lastName", "Milton")
)));
new InsertAction().execute(new InsertInput(JOB_HISTORY_TABLE).withRecords(List.of(
new QRecord().withValue("id", 301).withValue("employeeId", 1).withValue("salaryAtTime", new BigDecimal("130000")),
new QRecord().withValue("id", 302).withValue("employeeId", 1).withValue("salaryAtTime", new BigDecimal("120000")),
new QRecord().withValue("id", 303).withValue("employeeId", 2).withValue("salaryAtTime", new BigDecimal("80000")))));
QQueryFilter managerHasHistoryOver100K = new QQueryFilter().withCriteria(JOB_HISTORY_TABLE + ".salaryAtTime", QCriteriaOperator.GREATER_THAN, new BigDecimal("100000"));
//////////////////////////////////////////////////////////
// department whose manager has matching history row(s) //
//////////////////////////////////////////////////////////
assertOutput(true, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 1));
/////////////////////////////////////////////////////////
// department whose manager has only non-matching rows //
/////////////////////////////////////////////////////////
assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 2));
////////////////////////////////////////////////////////////
// department with null or unknown manager does not match //
////////////////////////////////////////////////////////////
assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of());
assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 99));
//////////////////////////////////////////////////
// manager who has no job history doesn't match //
//////////////////////////////////////////////////
assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 3));
}
/*******************************************************************************
** employeeDetail 1:1 employee (reverse direction of employee->employeeDetail)
*******************************************************************************/
@Test
void testOneToOneReverseJoin() throws Exception
{
new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of(
new QRecord().withValue("id", 100).withValue("lastName", "Smith"),
new QRecord().withValue("id", 101).withValue("lastName", "Jones"))));
QQueryFilter employeeLastNameIsSmith = new QQueryFilter().withCriteria(EMPLOYEE_TABLE + ".lastName", QCriteriaOperator.EQUALS, "Smith");
/////////////////////////////////////////////////////
// detail row linked to matching employee is match //
/////////////////////////////////////////////////////
assertOutput(true, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, Map.of("employeeId", 100));
/////////////////////////////////////////////////////////
// detail row linked to non-matching employee no match //
/////////////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, Map.of("employeeId", 101));
////////////////////////////////////////////////////
// detail row with null/unknown employee no match //
////////////////////////////////////////////////////
assertOutput(false, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, MapBuilder.of("employeeId", null));
assertOutput(false, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, Map.of("employeeId", 999));
}
/*******************************************************************************
** company 1:N department N:1 manager(employee) 1:1 employeeDetail
*******************************************************************************/
@Test
void testOneToManyThenManyToOneThenOneToOneJoin() throws Exception
{
new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of(
new QRecord().withValue("id", 11).withValue("lastName", "Smith"),
new QRecord().withValue("id", 21).withValue("lastName", "Jones"),
new QRecord().withValue("id", 31).withValue("lastName", "Milton")
)));
new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of(
new QRecord().withValue("id", 401).withValue("companyId", 1).withValue("managerId", 11).withValue("name", "Engineering"),
new QRecord().withValue("id", 402).withValue("companyId", 2).withValue("managerId", 21).withValue("name", "Support"),
new QRecord().withValue("id", 403).withValue("companyId", 3).withValue("managerId", 31).withValue("name", "Office Space"),
new QRecord().withValue("id", 404).withValue("companyId", 4).withValue("managerId", null).withValue("name", "No Manager"))));
new InsertAction().execute(new InsertInput(EMPLOYEE_DETAIL_TABLE).withRecords(List.of(
new QRecord().withValue("id", 501).withValue("employeeId", 11).withValue("benefitsTier", "PLATINUM"),
new QRecord().withValue("id", 502).withValue("employeeId", 21).withValue("benefitsTier", "BASIC"))));
QQueryFilter hasManagerWithPlatinumTier = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".benefitsTier", QCriteriaOperator.EQUALS, "PLATINUM");
///////////////////////////////////////////////////////////////
// company with department whose manager has matching detail //
///////////////////////////////////////////////////////////////
assertOutput(true, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 1));
///////////////////////////////////////////////////////////////
// company with department whose manager detail is non-match //
///////////////////////////////////////////////////////////////
assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 2));
///////////////////////////////////////////////////
// company with department whose manager is null //
///////////////////////////////////////////////////
assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 4));
//////////////////////////////////////////////////////////
// company with department whose manager has no history //
//////////////////////////////////////////////////////////
assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 3));
/////////////////////////////////////////////
// company with no departments is no match //
/////////////////////////////////////////////
assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 5));
}
/***************************************************************************
*
***************************************************************************/
void assertOutput(boolean expectedOutput, String tableName, QQueryFilter filter, Map<String, Serializable> recordValues) throws QException
{
assertOutput(expectedOutput, tableName, filter, null, recordValues);
}
/***************************************************************************
*
***************************************************************************/
void assertOutput(boolean expectedOutput, String tableName, QQueryFilter filter, WorkflowRevision workflowRevision, Map<String, Serializable> recordValues) throws QException
{
QRecord record = new QRecord();
record.setValues(recordValues);
assertOutput(expectedOutput, tableName, filter, workflowRevision, record);
}
/***************************************************************************
*
***************************************************************************/
void assertOutput(boolean expectedOutput, String tableName, QQueryFilter filter, QRecord record) throws QException
{
assertOutput(expectedOutput, tableName, filter, null, record);
}
/***************************************************************************
*
***************************************************************************/
void assertOutput(boolean expectedOutput, String tableName, QQueryFilter filter, WorkflowRevision workflowRevision, QRecord record) throws QException
{
WorkflowStep step = new WorkflowStep();
Map<String, Serializable> inputValues = Map.of("queryFilterJson", JsonUtils.toJson(filter));
RecordWorkflowContext context = new RecordWorkflowContext();
context.setWorkflow(new Workflow().withTableName(tableName));
context.setWorkflowRevision(workflowRevision);
context.record.set(record);
record.setTableName(tableName);
WorkflowStepOutput stepOutput = new InputRecordFilterStep().execute(step, inputValues, context);
assertEquals(expectedOutput, stepOutput.outputData());
}
/***************************************************************************
*
***************************************************************************/
private List<String> runValidate(QQueryFilter filter, QRecord workflowRevision, QRecord workflow) throws QException
{
List<String> errors = new ArrayList<>();
Map<String, Serializable> inputValues = Map.of("queryFilterJson", JsonUtils.toJson(filter));
new InputRecordFilterStep().validate(new WorkflowStep(), inputValues, workflowRevision, workflow, errors);
return (errors);
}
}
@@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
@@ -209,6 +210,19 @@ class RunRecordWorkflowProcessTest extends BaseTest
/*******************************************************************************
** the extract step must fetch associations (so api records have children).
*******************************************************************************/
@Test
void testExtractStepIncludesAssociations()
{
QueryInput queryInput = new QueryInput(Workflow.TABLE_NAME);
new RunRecordWorkflowExtractStep().customizeInputPreQuery(new RunBackendStepInput(), queryInput);
assertTrue(queryInput.getIncludeAssociations());
}
/***************************************************************************
**
***************************************************************************/
@@ -0,0 +1,124 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.implementations.recordworkflows;
import java.io.Serializable;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.workflows.BaseTest;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendFieldMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.FormAdjusterInput;
import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.FormAdjusterOutput;
import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.RunFormAdjusterProcess;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/*******************************************************************************
** Unit test for UpdateInputRecordFieldMetaDataAdjuster
*******************************************************************************/
class UpdateInputRecordFieldMetaDataAdjusterTest extends BaseTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void testExecute() throws QException
{
Map<String, Serializable> allValues = Map.of(
"workflowValuesJSON", JsonUtils.toJson(Map.of("tableName", TABLE_NAME_PERSON)),
"workflowRevisionValuesJSON", JsonUtils.toJson(Map.of()),
"value", 1
);
///////////////////////
// run for on-change //
///////////////////////
FormAdjusterOutput onChangeOutput = new UpdateInputRecordFieldMetaDataAdjuster().execute(new FormAdjusterInput()
.withFieldName("fieldName")
.withNewValue(TABLE_NAME_PERSON + ".firstName")
.withAllValues(allValues)
.withEvent(RunFormAdjusterProcess.EVENT_ON_CHANGE));
assertEquals(Set.of("value"), onChangeOutput.getFieldsToClear());
assertEquals(1, onChangeOutput.getUpdatedFieldMetaData().size());
assertThat(onChangeOutput.getUpdatedFieldDisplayValues()).isNullOrEmpty();
QFrontendFieldMetaData updatedValueField = onChangeOutput.getUpdatedFieldMetaData().get("value");
assertEquals("Value (First Name)", updatedValueField.getLabel());
/////////////////////
// run for on-load //
/////////////////////
new InsertAction().execute(new InsertInput(TABLE_NAME_SHAPE).withRecord(new QRecord().withValue("id", 1).withValue("name", "Square")));
FormAdjusterOutput onLoadOutput = new UpdateInputRecordFieldMetaDataAdjuster().execute(new FormAdjusterInput()
.withFieldName("fieldName")
.withNewValue(TABLE_NAME_PERSON + ".favoriteShapeId")
.withAllValues(allValues)
.withEvent(RunFormAdjusterProcess.EVENT_ON_LOAD));
assertThat(onLoadOutput.getFieldsToClear()).isNullOrEmpty();
assertEquals(Map.of("value", "Square"), onLoadOutput.getUpdatedFieldDisplayValues());
updatedValueField = onLoadOutput.getUpdatedFieldMetaData().get("value");
assertEquals("Value (Favorite Shape)", updatedValueField.getLabel());
assertEquals(TABLE_NAME_SHAPE, updatedValueField.getPossibleValueSourceName());
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testAdjustPossibleValueSourceFilterCriteriaInputs()
{
QFieldMetaData field = new QFieldMetaData();
UpdateInputRecordFieldMetaDataAdjuster.adjustPossibleValueSourceFilterCriteriaInputs(field.getPossibleValueSourceFilter());
assertNull(field.getPossibleValueSourceFilter());
field.setPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, 1)));
UpdateInputRecordFieldMetaDataAdjuster.adjustPossibleValueSourceFilterCriteriaInputs(field.getPossibleValueSourceFilter());
assertEquals(1, field.getPossibleValueSourceFilter().getCriteria().get(0).getValues().get(0));
field.setPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("someId", QCriteriaOperator.EQUALS, "${input.someId}")));
UpdateInputRecordFieldMetaDataAdjuster.adjustPossibleValueSourceFilterCriteriaInputs(field.getPossibleValueSourceFilter());
assertEquals("${input.someId}??${input.workflow.someId}", field.getPossibleValueSourceFilter().getCriteria().get(0).getValues().get(0));
field.setPossibleValueSourceFilter(new QQueryFilter().withSubFilter(new QQueryFilter(new QFilterCriteria("someId", QCriteriaOperator.EQUALS, "${input.someId}"))));
UpdateInputRecordFieldMetaDataAdjuster.adjustPossibleValueSourceFilterCriteriaInputs(field.getPossibleValueSourceFilter());
assertEquals("${input.someId}??${input.workflow.someId}", field.getPossibleValueSourceFilter().getSubFilters().get(0).getCriteria().get(0).getValues().get(0));
}
}
@@ -0,0 +1,161 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.implementations.recordworkflows;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.workflows.BaseTest;
import com.kingsrook.qbits.workflows.model.WorkflowRevision;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Unit test for UpdateInputRecordFieldStep
*******************************************************************************/
class UpdateInputRecordFieldStepTest extends BaseTest
{
private static WorkflowRevision workflowRevisionV1 = new WorkflowRevision().withApiName(API_NAME).withApiVersion(V1);
private static WorkflowRevision workflowRevisionV2 = new WorkflowRevision().withApiName(API_NAME).withApiVersion(V2);
private static WorkflowRevision workflowRevisionV3 = new WorkflowRevision().withApiName(API_NAME).withApiVersion(V3);
private static WorkflowRevision workflowRevisionWithoutApi = new WorkflowRevision();
/*******************************************************************************
**
*******************************************************************************/
@Test
void testGetApiField() throws QException
{
assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".firstName", TABLE_NAME_PERSON, workflowRevisionV1))
.isPresent().get().hasFieldOrPropertyWithValue("name", "firstName");
assertThat(UpdateInputRecordFieldStep.getApiField("firstName", TABLE_NAME_PERSON, workflowRevisionV1))
.isPresent().get().hasFieldOrPropertyWithValue("name", "firstName");
assertThat(UpdateInputRecordFieldStep.getApiField("noSuchField", TABLE_NAME_PERSON, workflowRevisionV1))
.isEmpty();
assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".noSuchField", TABLE_NAME_PERSON, workflowRevisionV1))
.isEmpty();
/////////////////////////////////////
// this field is excluded from api //
/////////////////////////////////////
assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".debt", TABLE_NAME_PERSON, workflowRevisionV1))
.isEmpty();
///////////////////////////////
// this field is added in V3 //
///////////////////////////////
assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".salary", TABLE_NAME_PERSON, workflowRevisionV1))
.isEmpty();
assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".salary", TABLE_NAME_PERSON, workflowRevisionV3))
.isPresent().get().hasFieldOrPropertyWithValue("name", "salary");
//////////////////////////////////////////
// confirm shoeCount/noOfShoes renaming //
//////////////////////////////////////////
assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".shoeCount", TABLE_NAME_PERSON, workflowRevisionV1))
.isPresent().get().hasFieldOrPropertyWithValue("name", "shoeCount");
assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".shoeCount", TABLE_NAME_PERSON, workflowRevisionV2))
.isEmpty();
assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".noOfShoes", TABLE_NAME_PERSON, workflowRevisionV1))
.isEmpty();
assertThat(UpdateInputRecordFieldStep.getApiField(TABLE_NAME_PERSON + ".noOfShoes", TABLE_NAME_PERSON, workflowRevisionV2))
.isPresent().get().hasFieldOrPropertyWithValue("name", "noOfShoes");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testGetStepSummary() throws QException
{
assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".firstName", "value", "John"), TABLE_NAME_PERSON, false))
.isEqualTo("First Name will be set to 'John'");
assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".firstName", "value", "John"), TABLE_NAME_PERSON, true))
.isEqualTo("First Name was set to 'John'");
assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".firstName", "value", ""), TABLE_NAME_PERSON, false))
.isEqualTo("First Name will be cleared out");
assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".firstName"), TABLE_NAME_PERSON, true))
.isEqualTo("First Name was cleared out");
new InsertAction().execute(new InsertInput(TABLE_NAME_SHAPE).withRecord(new QRecord().withValue("id", 1).withValue("name", "Square")));
assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".favoriteShapeId", "value", "1"), TABLE_NAME_PERSON, false))
.isEqualTo("Favorite Shape will be set to 'Square'");
assertThat(UpdateInputRecordFieldStep.getStepSummary(Map.of("fieldName", TABLE_NAME_PERSON + ".favoriteShapeId", "value", "1"), TABLE_NAME_PERSON, true))
.isEqualTo("Favorite Shape was set to 'Square'");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testValidate() throws QException
{
assertThat(runValidate(Map.of("fieldName", TABLE_NAME_PERSON + ".firstName"), workflowRevisionV1)).isEmpty();
////////////////////////////////////////////////////////////////////////////////////
// debt field - found if not using api, else not found as it is excluded from api //
////////////////////////////////////////////////////////////////////////////////////
assertThat(runValidate(Map.of("fieldName", TABLE_NAME_PERSON + ".debt"), workflowRevisionWithoutApi)).isEmpty();
assertThat(runValidate(Map.of("fieldName", TABLE_NAME_PERSON + ".debt"), workflowRevisionV1)).hasSize(1)
.element(0).asString()
.contains("Could not find field 'person.debt' in table '" + TABLE_NAME_PERSON + "' for API '" + API_NAME + "' version 'v1'");
}
/***************************************************************************
*
***************************************************************************/
private List<String> runValidate(Map<String, Serializable> inputValues, WorkflowRevision workflowRevision) throws QException
{
List<String> errors = new ArrayList<>();
UpdateInputRecordFieldStep.validate(TABLE_NAME_PERSON, inputValues, workflowRevision == null ? null : workflowRevision.toQRecord(), errors);
return (errors);
}
}
@@ -1,106 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.metadata;
import java.io.Serializable;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.workflows.BaseTest;
import com.kingsrook.qbits.workflows.WorkflowsTestDataSource;
import com.kingsrook.qbits.workflows.implementations.recordworkflows.RecordWorkflowsDefinition;
import com.kingsrook.qbits.workflows.model.Workflow;
import com.kingsrook.qbits.workflows.model.WorkflowTestScenario;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.RunFormAdjusterProcess;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Runs the workflows form adjusters through the current Material Dashboard
** form-adjuster process, the same way the UI API's table form-adjuster route
** does (path params + multipart-style form params) - not by calling them directly.
*******************************************************************************/
class WorkflowTestAssertionFormAdjusterTest extends BaseTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void testVariableNameAdjusterThroughMaterialDashboardProcess() throws Exception
{
Workflow workflow = WorkflowsTestDataSource.insertWorkflowAndInitialRevision(RecordWorkflowsDefinition.WORKFLOW_TYPE, TABLE_NAME_PERSON);
Integer scenarioId = new InsertAction().execute(new InsertInput(WorkflowTestScenario.TABLE_NAME).withRecordEntity(
new WorkflowTestScenario().withWorkflowId(workflow.getId()).withName("s").withApiName(API_NAME).withApiVersion(V3).withApiJson("{}"))).getRecords().get(0).getValueInteger("id");
Map<String, Object> output = runAdjuster("WorkflowTestAssertion.variableName", RunFormAdjusterProcess.EVENT_ON_CHANGE,
TABLE_NAME_PERSON + ".firstName", Map.of("workflowTestScenarioId", scenarioId));
@SuppressWarnings("unchecked")
Map<String, Object> expectedValueField = (Map<String, Object>) ((Map<String, Object>) output.get("updatedFieldMetaData")).get("expectedValue");
assertNotNull(expectedValueField);
assertTrue(String.valueOf(expectedValueField.get("label")).startsWith("Expected Value ("), "label: " + expectedValueField.get("label"));
assertEquals(List.of("expectedValue"), output.get("fieldsToClear"));
}
/***************************************************************************
**
***************************************************************************/
private Map<String, Object> runAdjuster(String identifier, String event, String newValue, Map<String, Object> allValues) throws Exception
{
Map<String, List<String>> formParams = new HashMap<>();
formParams.put("event", List.of(event));
formParams.put("fieldName", List.of("variableName"));
formParams.put("newValue", List.of(newValue));
formParams.put("allValues", List.of(JsonUtils.toJson(allValues)));
RunProcessInput input = new RunProcessInput();
input.setProcessName(RunFormAdjusterProcess.NAME);
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
input.addValue("pathParams", new HashMap<>(Map.of("identifier", identifier, "event", event)));
input.addValue("formParams", (Serializable) formParams);
RunProcessOutput processOutput = new RunProcessAction().execute(input);
if(processOutput.getException().isPresent())
{
throw (processOutput.getException().get());
}
@SuppressWarnings("unchecked")
Map<String, Object> result = JsonUtils.toObject(processOutput.getValueString("responseString"), Map.class);
return (result);
}
}
@@ -0,0 +1,102 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qbits.workflows.processes;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.workflows.BaseTest;
import com.kingsrook.qbits.workflows.TestWorkflowDefinitions;
import com.kingsrook.qbits.workflows.WorkflowsTestDataSource;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/*******************************************************************************
** Unit test for StoreNewWorkflowRevisionProcess
*******************************************************************************/
class StoreNewWorkflowRevisionProcessTest extends BaseTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void test() throws QException
{
TestWorkflowDefinitions.registerTestWorkflowTypes();
Integer workflowId = WorkflowsTestDataSource.insertTestWorkflow();
RunBackendStepInput input = new RunBackendStepInput();
RunBackendStepOutput output = new RunBackendStepOutput();
assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output))
.hasMessageContaining("Workflow id is required");
input.addValue("workflowId", -999);
assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output))
.hasMessageContaining("Workflow not found by id: -999");
input.addValue("workflowId", workflowId);
assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output))
.hasMessageContaining("steps input was not provided");
input.addValue("steps", "1, 2, 3");
assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output))
.hasMessageContaining("Error parsing workflow steps");
input.addValue("steps", JsonUtils.toJson(List.of(
WorkflowsTestDataSource.newStep(1, "myStepType", Map.of("foo", 1)),
WorkflowsTestDataSource.newStep(2, "yourStepType", Map.of("bar", true, "baz", false))
)));
assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output))
.hasMessageContaining("links input was not provided");
input.addValue("links", "not json");
assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output))
.hasMessageContaining("Error parsing workflow links");
input.addValue("links", JsonUtils.toJson(List.of(WorkflowsTestDataSource.newLink(1, 2))));
assertThatThrownBy(() -> new StoreNewWorkflowRevisionProcess().run(input, output))
.hasMessageContaining("2 validation errors occurred before the workflow could be saved:")
.hasMessageContaining("Error processing Step 1: Unknown workflow step type myStepType")
.hasMessageContaining("Error processing Step 2: Unknown workflow step type yourStepType");
input.addValue("steps", JsonUtils.toJson(List.of(
WorkflowsTestDataSource.newStep(1, TestWorkflowDefinitions.ADD_X_TO_SUM_ACTION, Map.of("foo", 1)),
WorkflowsTestDataSource.newStep(2, TestWorkflowDefinitions.BOOLEAN_CONDITIONAL, Map.of("bar", true, "baz", false))
)));
new StoreNewWorkflowRevisionProcess().run(input, output);
assertNotNull(output.getValue("workflowRevisionId"));
assertEquals(2, output.getValue("versionNo"));
new StoreNewWorkflowRevisionProcess().run(input, output);
assertEquals(3, output.getValue("versionNo"));
}
}