Compare commits

...
Author SHA1 Message Date
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
james.maes 9e302794f1 chore: bump qfmd version to RC1 to allow for distibution 2025-09-18 14:41:44 -05:00
james.maes 05cc348966 chore(deps): Updated parent pom and qqq-orb to latest published versions 2025-09-18 12:18:14 -05:00
james.maes 983b4ffd59 chore: bump snapshot version after release 2025-09-18 12:10:19 -05:00
james.maes 64a202aa41 fix(pom): Updated parent pom version to 1.4.0 (parent pom fix) 2025-09-18 10:03:12 -05:00
james.maes 0fe3bb9735 Merge branch 'feature/circleci-orb-upgrade' into develop 2025-09-18 09:45:46 -05:00
james.maes 1368c052dd chore(circleci): testing new orb version that will address versions 2025-09-18 09:02:04 -05:00
james.maes 6d669469b0 fix: fixed version not using revision in packaging - bump to 1.3.1 2025-09-17 09:40:14 -05:00
james.maes b45be4221d fix: fixed version not using revision in packaging - bump to 1.2.1 2025-09-17 09:35:17 -05:00
james.maes 85d175edd6 fix: updated to parent pom 1.2.0 - supporting snapshot jars from maven central 2025-09-17 09:31:53 -05:00
james.maes a75ec6f263 Empty commit and push - testing circleci qqq-orb 2025-09-17 09:16:04 -05:00
james.maes e35a1bffa0 chore: Updated to meet requirements for maven central and qqq-orb 2025-09-17 08:37:21 -05:00
51 changed files with 4304 additions and 490 deletions

No files matched your search

+58 -50
View File
@@ -1,60 +1,68 @@
version: 2.1
executors:
java-executor:
docker:
- image: cimg/openjdk:17.0
jobs:
verify:
executor: java-executor
steps:
- checkout
- restore_cache:
keys:
- v1-maven-cache-{{ checksum "pom.xml" }}
- run:
name: Verify with Maven
command: mvn -s .circleci/mvn-settings.xml verify
- store_test_results:
path: target/surefire-reports
- save_cache:
paths:
- ~/.m2/repository
key: v1-maven-cache-{{ checksum "pom.xml" }}
deploy:
executor: java-executor
steps:
- checkout
- restore_cache:
keys:
- v1-maven-cache-{{ checksum "pom.xml" }}
- run:
name: Deploy with Maven
command: mvn -s .circleci/mvn-settings.xml deploy
- store_test_results:
path: target/surefire-reports
- save_cache:
paths:
- ~/.m2/repository
key: v1-maven-cache-{{ checksum "pom.xml" }}
orbs:
qqq-orb: kingsrook/qqq-orb@0.6
workflows:
version: 2
build_and_deploy:
test_only:
jobs:
- verify:
context: qqq-maven-registry-credentials
- qqq-orb/mvn_test_only:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore:
- main
- feature/initial-dev
- deploy:
context: qqq-maven-registry-credentials
ignore: /(develop|main|release/.*|hotfix/.*|integration.*)/
tags:
only: []
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only:
- main
- feature/initial-dev
- develop
publish_feature:
jobs:
- qqq-orb/mvn_publish:
context: [qqq-maven-registry-credentials]
branch_type: feature
filters:
branches:
only:
- feature/.*
tags:
only: [/publish.*/]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only:
- /release\/.*/
publish_release:
jobs:
- qqq-orb/mvn_publish:
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/v.*/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only:
- /hotfix\/.*/
-9
View File
@@ -1,9 +0,0 @@
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers>
<server>
<id>github-qqq-maven-registry</id>
<username>${env.QQQ_MAVEN_REGISTRY_USERNAME}</username>
<password>${env.QQQ_MAVEN_REGISTRY_PASSWORD}</password>
</server>
</servers>
</settings>
+58
View File
@@ -0,0 +1,58 @@
# Changelog
All notable changes to QBit User Role Permissions will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Comprehensive README documentation following QQQ sub repo standards
- Standard GitHub files (LICENSE, CHANGELOG, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT)
- Proper project structure and documentation
### Changed
- Updated README to follow QQQ framework standards
- Improved project documentation and structure
- Enhanced GitHub repository setup
## [0.30.0-SNAPSHOT] - 2024-01-XX
### Added
- Initial QBit implementation for user role permissions
- Database schema for users, roles, and permissions
- Java classes for User, Role, Permission, UserRole, and RolePermission
- Integration with QQQ Backend Core framework
- H2 database support for testing
- JUnit 5 test framework integration
- AssertJ assertions for testing
- Checkstyle configuration for code quality
### Changed
- Initial public release of QBit User Role Permissions
- Established QBit development standards
- Created modular architecture for permission management
---
## 📚 For Detailed Information
**📖 [Complete Documentation Wiki](https://github.com/Kingsrook/qqq/wiki)** - Start here for comprehensive guides
- **[🏠 Home](https://github.com/Kingsrook/qqq/wiki/Home)** - Project overview and quick start
- **[🚀 Release Flow](https://github.com/Kingsrook/qqq/wiki/Release-Flow)** - Detailed release process
- **[🏷️ Changelog & Tagging](https://github.com/Kingsrook/qqq/wiki/Changelog-and-Tagging)** - Commit conventions and release notes
- **[🔧 Developer Onboarding](https://github.com/Kingsrook/qqq/wiki/Developer-Onboarding)** - Setup and contribution guide
## 🔄 Version Compatibility
QBit User Role Permissions follows semantic versioning:
- **MAJOR** versions may contain breaking changes
- **MINOR** versions add new functionality (backward compatible)
- **PATCH** versions contain bug fixes (backward compatible)
For detailed compatibility information, see [QQQ Compatibility Matrix](https://github.com/Kingsrook/qqq/wiki/Compatibility-Matrix).
---
**Thank you for using QBit User Role Permissions!** 🚀
+4
View File
@@ -0,0 +1,4 @@
# Code of Conduct
See Project level [Code of Conduct](https://github.com/Kingsrook/qqq/blob/develop/CODE_OF_CONDUCT.md)
+3
View File
@@ -0,0 +1,3 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/Kingsrook/qqq/blob/develop/CODE_STYLE.md)
+4
View File
@@ -0,0 +1,4 @@
# Contributing to QBit User Role Permissions
* See Project [Contributing](https://github.com/Kingsrook/qqq/blob/develop/CONTRIBUTING.md)
+77 -1
View File
@@ -1,4 +1,8 @@
## QBit: workflows
# QBit: Workflows
[![Version](https://img.shields.io/badge/version-TODO-blue.svg)](https://github.com/Kingsrook/qbit-workflows)
[![License](https://img.shields.io/badge/license-GNU%20Affero%20GPL%20v3-green.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html)
[![Java](https://img.shields.io/badge/java-17+-blue.svg)](https://adoptium.net/)
### Overview
*Note: This is one of the original QBit implementations - so, some of the mechanics of how
@@ -7,6 +11,28 @@ creation... Please excuse any dust or not-quite-round wheels you find here!*
This QBit provides TODO
## 🏗️ Architecture
### Technology Stack
- **Java**: Java 17+ with UTF-8 encoding
- **Maven**: Build system with plugin management
- **QQQ Framework**: Built on the QQQ low-code platform
- **QBit Pattern**: Follows QBit component architecture
### Core Capabilities
- **Workflow Management**: TODO - Define workflow capabilities
- **Process Automation**: TODO - Define automation features
- **Integration**: TODO - Define integration capabilities
## 🚀 Quick Start
### Prerequisites
- **Java 17+** (required for QQQ features)
- **Maven 3.8+** (for build system)
### Usage
#### Pom dependency
@@ -31,3 +57,53 @@ TODO
### Dependencies
TODO
## 📚 Documentation
**📖 [Complete Documentation Wiki](https://github.com/Kingsrook/qqq/wiki)** - Start here for comprehensive guides
- **[🏠 Home](https://github.com/Kingsrook/qqq/wiki/Home)** - Project overview and quick start
- **[🏗️ Architecture](https://github.com/Kingsrook/qqq/wiki/High-Level-Architecture)** - System design and principles
- **[🔧 Development](https://github.com/Kingsrook/qqq/wiki/Developer-Onboarding)** - Setup and contribution guide
- **[📦 Modules](https://github.com/Kingsrook/qqq/wiki/Core-Modules)** - Available components and usage
- **[🚀 Building](https://github.com/Kingsrook/qqq/wiki/Building-Locally)** - Local development workflow
- **[🔧 QBit Development](https://github.com/Kingsrook/qqq/wiki/QBit-Development)** - QBit development guide
## 🤝 Contributing
**Important**: This repository is a component of the QQQ framework. All contributions, issues, and discussions should go through the main QQQ repository.
### Development Workflow
1. **Fork the main QQQ repository**: https://github.com/Kingsrook/qqq
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
3. **Make your changes** (including QBit changes if applicable)
4. **Run tests**: `mvn test`
5. **Commit your changes**: `git commit -m 'Add amazing feature'`
6. **Push to the branch**: `git push origin feature/amazing-feature`
7. **Open a Pull Request** to the main QQQ repository
### Code Standards
- **Maven**: Follow Maven best practices
- **Documentation**: Update relevant documentation
- **Versioning**: Follow semantic versioning
- **QBit Pattern**: Follow QBit component architecture
**First time contributing?** Start with our [Developer Onboarding Guide](https://github.com/Kingsrook/qqq/wiki/Developer-Onboarding) to get your environment set up.
## 🏢 About Kingsrook
QBit Workflows is built by **[Kingsrook](https://qrun.io)** - making engineers more productive through intelligent automation and developer tools.
- **Website**: [https://qrun.io](https://qrun.io)
- **Contact**: [contact@kingsrook.com](mailto:contact@kingsrook.com)
- **GitHub**: [https://github.com/Kingsrook](https://github.com/Kingsrook)
## 📄 License
This project is licensed under the **GNU Affero General Public License v3.0** - see the [LICENSE.txt](LICENSE.txt) file for details.
---
**Ready to build workflows with full control?** [Get started with QQQ today!](https://github.com/Kingsrook/qqq/wiki/Developer-Onboarding)
+3
View File
@@ -0,0 +1,3 @@
# Security Policy
* See Project [Security Policy](https://github.com/Kingsrook/qqq/blob/develop/SECURITY.md)
+46 -47
View File
@@ -21,57 +21,76 @@
-->
<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">
<modelVersion>4.0.0</modelVersion>
<properties>
<revision>0.114.0-SNAPSHOT</revision>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>1.5.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-workflows</artifactId>
<version>0.1.13-SNAPSHOT</version>
<version>${revision}</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.release>17</maven.compiler.release>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<name>QBit Workflows</name>
<description>Workflow management and process automation module for the QBit framework</description>
<url>https://github.com/Kingsrook/qbit-workflows</url>
<licenses>
<license>
<name>GNU Affero General Public License v3.0</name>
<url>https://www.gnu.org/licenses/agpl-3.0.html</url>
<distribution>repo</distribution>
</license>
</licenses>
<modelVersion>4.0.0</modelVersion>
<developers>
<developer>
<id>kingsrook</id>
<name>Kingsrook Team</name>
<email>contact@kingsrook.com</email>
<organization>Kingsrook, LLC</organization>
<organizationUrl>https://kingsrook.com</organizationUrl>
</developer>
</developers>
<scm>
<connection>scm:git:git://github.com/Kingsrook/qbit-workflows.git</connection>
<developerConnection>scm:git:ssh://github.com:Kingsrook/qbit-workflows.git</developerConnection>
<url>https://github.com/Kingsrook/qbit-workflows</url>
<tag>HEAD</tag>
</scm>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>0.27.9</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.10.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>0.40.0-join-enhancements-24cb9791-20260216.220349-1</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-api</artifactId>
<version>0.40.0-join-enhancements-24cb9791-20260216.220349-1</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<version>0.40.0-join-enhancements-24cb9791-20260216.220349-1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-material-dashboard</artifactId>
<version>0.27.0-20250915.210441-5</version>
<version>0.27.0-RC.1</version>
</dependency>
<dependency>
@@ -87,24 +106,4 @@
</dependency>
</dependencies>
<repositories>
<repository>
<id>sonatype-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
</repository>
<repository>
<id>github-qqq-maven-registry</id>
<name>GitHub QQQ Maven Registry</name>
<url>https://maven.pkg.github.com/Kingsrook/qqq-maven-registry</url>
</repository>
</repositories>
<distributionManagement>
<repository>
<id>github-qqq-maven-registry</id>
<name>GitHub QQQ Maven Registry</name>
<url>https://maven.pkg.github.com/Kingsrook/qqq-maven-registry</url>
</repository>
</distributionManagement>
</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;
@@ -54,6 +56,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;
/*******************************************************************************
@@ -106,8 +109,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)
{
@@ -130,26 +135,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;
@@ -157,16 +174,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 //
@@ -185,8 +282,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;
}
@@ -194,18 +291,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));
@@ -83,18 +83,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);
@@ -114,7 +124,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);
}
}
@@ -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,504 @@
/*
* 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.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.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.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));
}
/*******************************************************************************
**
*******************************************************************************/
@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
{
QRecord record = new QRecord();
record.setValues(recordValues);
assertOutput(expectedOutput, tableName, filter, record);
}
/***************************************************************************
*
***************************************************************************/
void assertOutput(boolean expectedOutput, String tableName, QQueryFilter filter, 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.record.set(record);
record.setTableName(tableName);
WorkflowStepOutput stepOutput = new InputRecordFilterStep().execute(step, inputValues, context);
assertEquals(expectedOutput, stepOutput.outputData());
}
}
@@ -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);
}
}
@@ -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"));
}
}