Compare commits

..

26 Commits

Author SHA1 Message Date
ecc925715b Merged feature/quartz-scheduler into integration/sprint-38 2024-03-20 19:50:17 -05:00
d7dec3e560 CE-936 Add @DisallowConcurrentExecution 2024-03-20 17:42:05 -05:00
0c4dc1d4cb Merged feature/quartz-scheduler into integration/sprint-38 2024-03-20 16:14:00 -05:00
40e8a85977 Add ScheduledJobs doc 2024-03-20 16:13:39 -05:00
30ee8ce3bf Update quartz job runner to log when finished (and to include same pairs in exception) 2024-03-20 16:13:27 -05:00
3354717fd6 Merged feature/quartz-scheduler into integration/sprint-38 2024-03-20 13:49:47 -05:00
0d6538593b CE-936 Add option to log the running of quartz jobs 2024-03-20 13:49:33 -05:00
1019218762 Merged feature/quartz-scheduler into integration/sprint-38 2024-03-19 20:24:08 -05:00
7e6a3c528f CE-936 Update tests 2024-03-19 20:12:36 -05:00
02d068dad7 CE-936 Quartz test updates 2024-03-19 20:05:00 -05:00
c9f921c148 CE-936 add post-actions on scheduledJobParams table, to reschedule jobs 2024-03-19 20:04:43 -05:00
2545d03f20 CE-936 Switch scheduledJobTypes PVS to be based on schedulable types in the instance, not just the enum (e.g., in support of app-defined types) 2024-03-19 20:04:05 -05:00
92b8211f20 CE-936 Add mayUseInScheduledJobsTable to scheduler meta data; check that before including in Schedulers PVS 2024-03-19 20:02:13 -05:00
8afbbfb4da Avoid NPE on null value for custom-type PVS 2024-03-19 20:01:08 -05:00
24b1daa110 CE-936 - Move adding default schedulableTypes in instance to Enricher 2024-03-19 20:00:49 -05:00
17899c3fdc CE-936 - Move per-record pause & resume to run on quartzJob tables, not trigger; update previewMessage 2024-03-19 20:00:24 -05:00
605578d661 add overload of recordsToMap that takes key type 2024-03-19 19:59:34 -05:00
cb382c3f4b Merge branch 'feature/CE-970-mirgration-blocker-link' into integration/sprint-38 2024-03-19 15:59:01 -05:00
f15277f23b CE-970: fixed bug around getting new ApiTableMetaData 2024-03-19 15:58:53 -05:00
af757ea1fd Merge branch 'feature/CE-970-mirgration-blocker-link' into integration/sprint-38 2024-03-19 14:01:59 -05:00
d480027aeb CE-970: added ability to specify including associations when using extractvia query steps 2024-03-19 13:14:29 -05:00
5963a706b0 CE-936 - Add error if cron or repeat-seconds isn't given; add warnings from scheduling. 2024-03-19 11:33:12 -05:00
c8c7051628 CE-936 - Update to send warnings from insert & update back not as an exception, but as a success, with warnings in the record. 2024-03-19 11:32:41 -05:00
7015322bf3 CE-936 - format epoc time values w/ commas and an instant in system time zone 2024-03-19 10:09:28 -05:00
d6edbfa06b CE-936 - Add exposed joins between these tables 2024-03-19 10:08:25 -05:00
a10992226a CE-936 - Cleanup from code review 2024-03-19 10:08:11 -05:00
35 changed files with 1284 additions and 268 deletions

View File

@ -26,6 +26,16 @@ include::metaData/Reports.adoc[leveloffset=+1]
include::metaData/Icons.adoc[leveloffset=+1] include::metaData/Icons.adoc[leveloffset=+1]
include::metaData/PermissionRules.adoc[leveloffset=+1] include::metaData/PermissionRules.adoc[leveloffset=+1]
== Services
include::misc/ScheduledJobs.adoc[leveloffset=+1]
=== Web server (Javalin)
#todo#
=== API server (OpenAPI)
#todo#
== Custom Application Code == Custom Application Code
include::misc/QContext.adoc[leveloffset=+1] include::misc/QContext.adoc[leveloffset=+1]
include::misc/QRecords.adoc[leveloffset=+1] include::misc/QRecords.adoc[leveloffset=+1]

View File

@ -0,0 +1,141 @@
== Schedulers and Scheduled Jobs
include::../variables.adoc[]
QQQ has the ability to automatically run various types of jobs on schedules,
either defined in your instance's meta-data,
or optionally via data in your application, in a `scheduledJob` table.
=== Schedulers and QSchedulerMetaData
2 types of schedulers are included in QQQ by default (though an application can define its own schedulers):
* `SimpleScheduler` - is (as its name suggests) a simple class which uses java's `ScheduledExecutorService`
to run jobs on repeating intervals.
** Cannot run cron schedules - only repeating intervals.
** If multiple servers are running, each will potentially run the same job concurrently
** Has no configurations, e.g., to limit the number of threads.
* `QuartzScheduler` - uses the 3rd party https://www.quartz-scheduler.org/[Quartz Scheduler] library to provide
a much more capable, though admittedly more complex, scheduling solution.
** Can run both cron schedules and repeating intervals.
** By default, will not allow concurrent executions of the same job.
** Supports multiple configurations, e.g., to limit the number of threads.
An application can define its own scheduler by providing a class which implements the `QSchedulerInterface`.
A `QInstance` can work with 0 or more schedulers, as defined by adding `QSchedulerMetaData` objects
to the instance.
This meta-data class is `abstract`, and is extended by the 2 built-in schedulers
(e.g., `SimpleSchedulerMetaData` and `QuartzSchedulerMetaData`). As such,
these concrete subclasses are what you need to instantiate and add to your instance.
To configure a QuartzScheduler, you can add a `Properties` object to the `QuartzSchedulerMetaData` object.
See https://www.quartz-scheduler.org/documentation/[Quartz's documentation] for available configuration properties.
[source,java]
.Defining SchedulerMetaData
----
qInstance.addScheduler(new SimpleSchedulerMetaData().withName("mySimpleScheduler"));
qInstance.addScheduler(new QuartzSchedulerMetaData()
.withName("myQuartzScheduler")
.withProperties(myQuartzProperties);
----
=== SchedulableTypes
The types of jobs which can be scheduled in a QQQ application are defined in the `QInstance` by
instances of the `SchedulableType` meta-data class.
These objects contain a name, along with a `QCodeReference` to the `runner`,
which must be a class that implements the `SchedulableRunner` interface.
By default, (in the `QInstanceEnricher`), QQQ will make 3 `SchedulableType` options available:
* `PROCESS` - Any Process defined in the `QInstance` can be scheduled.
* `QUEUE_PROCESSOR` - A Queue defined in the `QInstance`, which requires polling (e.g., SQS), can be scheduled.
* `TABLE_AUTOMATIONS` - A Table in the `QInstance`, with `AutomationDetails` referring to an
AutomationProvider which requires polling, can be scheduled.
If an application only wants to use a subset of these `SchedulableType` options,
or to add custom `SchedulableType` options,
the `QInstance` will need to have 1 or more `SchedulableType` objects in it before the `QInstanceEnricher` runs.
=== User-defined Scheduled Jobs
To allow users to schedule jobs (rather than using programmer-defined schedules (in meta-data)),
you can add a set of tables to your `QInstance`, using the `ScheduledJobsMetaDataProvider` class:
[source,java]
.Adding the ScheduledJob tables and related meta-data to a QInstance
----
new ScheduledJobsMetaDataProvider().defineAll(
qInstance, backendName, table -> tableEnricher(table));
----
This meta-data provider adds a "scheduledJob" and "scheduledJobParameter" table, along with
some PossibleValueSources.
These tables include post-action customizers, which manage (re-, un-) scheduling jobs based on
changes made to records in this these tables.
Also, when `QScheduleManager` is started, it will query these tables,and will schedule jobs as defined therein.
_You can use a mix of user-defined and meta-data defined scheduled jobs in your instance.
However, if a ScheduledJob record references a process, queue, or table automation with a
meta-data defined schedule, the ScheduledJob will NOT be started by ScheduleManager --
rather, the meta-data definition will "win"._
[source,sql]
.Example of inserting scheduled jobs records directly into an SQL backend
----
-- A process:
INSERT INTO scheduled_job (label, scheduler_name, cron_expression, cron_time_zone_id, repeat_seconds, type, is_active) VALUES
('myProcess', 'QuartzScheduler', null, null, 300, 'PROCESS', 1);
INSERT INTO scheduled_job_parameter (scheduled_job_id, `key`, value) VALUES
((SELECT id FROM scheduled_job WHERE label = 'myProcess'), 'processName', 'myProcess');
-- A table's insert & update automations:
INSERT INTO scheduled_job (label, scheduler_name, cron_expression, cron_time_zone_id, repeat_seconds, type, is_active) VALUES
('myTable.PENDING_INSERT_AUTOMATIONS', 'QuartzScheduler', null, null, 15, 'TABLE_AUTOMATIONS', 1),
('myTable.PENDING_UPDATE_AUTOMATIONS', 'QuartzScheduler', null, null, 15, 'TABLE_AUTOMATIONS', 1);
INSERT INTO scheduled_job_parameter (scheduled_job_id, `key`, value) VALUES
((SELECT id FROM scheduled_job WHERE label = 'myTable.PENDING_INSERT_AUTOMATIONS'), 'tableName', 'myTable'),
((SELECT id FROM scheduled_job WHERE label = 'myTable.PENDING_INSERT_AUTOMATIONS'), 'automationStatus', 'PENDING_INSERT_AUTOMATIONS'),
((SELECT id FROM scheduled_job WHERE label = 'myTable.PENDING_UPDATE_AUTOMATIONS'), 'tableName', 'myTable'),
((SELECT id FROM scheduled_job WHERE label = 'myTable.PENDING_UPDATE_AUTOMATIONS'), 'automationStatus', 'PENDING_UPDATE_AUTOMATIONS');
-- A queue processor:
INSERT INTO scheduled_job (label, scheduler_name, cron_expression, cron_time_zone_id, repeat_seconds, type, is_active) VALUES
('mySqsQueue', 'QuartzScheduler', null, null, 60, 'QUEUE_PROCESSOR', 1);
INSERT INTO scheduled_job_parameter (scheduled_job_id, `key`, value) VALUES
((SELECT id FROM scheduled_job WHERE label = 'mySqsQueue'), 'queueName', 'mySqsQueue');
----
=== Running Scheduled Jobs
In a server running QQQ, if you wish to start running scheduled jobs, you need to initialize
the `QScheduleManger` singleton class, then call its `start()` method.
Note that internally, this class will check for a system property of `qqq.scheduleManager.enabled`
or an environment variable of `QQQ_SCHEDULE_MANAGER_ENABLED`, and if the first value found is
`"false"`, then the scheduler will not actually run its jobs (but, in the case of the `QuartzSchdeuler`,
it will be available for managing scheduled jobs).
The method `QScheduleManager.initInstance` requires 2 parameters: Your `QInstance`, and a
`Supplier<QSession>` lambda, which returns the session that will be used for scheduled jobs when they
are executed.
[source,java]
.Starting the Schedule Manager service
----
QScheduleManager.initInstance(qInstance, () -> systemUserSession).start();
----
=== Examples
[source,java]
.Attach a schedule in meta-data to a Process
----
QProcessMetaData myProcess = new QProcessMetaData()
// ...
.withSchedule(new QScheduleMetaData()
.withSchedulerName("myScheduler")
.withDescription("Run myProcess every five minutes")
.withRepeatSeconds(300))
----

View File

@ -159,7 +159,6 @@ public class AsyncJobManager
private <T extends Serializable> T runAsyncJob(String jobName, AsyncJob<T> asyncJob, UUIDAndTypeStateKey uuidAndTypeStateKey, AsyncJobStatus asyncJobStatus) private <T extends Serializable> T runAsyncJob(String jobName, AsyncJob<T> asyncJob, UUIDAndTypeStateKey uuidAndTypeStateKey, AsyncJobStatus asyncJobStatus)
{ {
String originalThreadName = Thread.currentThread().getName(); String originalThreadName = Thread.currentThread().getName();
// Thread.currentThread().setName("Job:" + jobName + ":" + uuidAndTypeStateKey.getUuid().toString().substring(0, 8));
Thread.currentThread().setName("Job:" + jobName); Thread.currentThread().setName("Job:" + jobName);
try try
{ {

View File

@ -370,6 +370,14 @@ public class QPossibleValueTranslator
*******************************************************************************/ *******************************************************************************/
private String translatePossibleValueCustom(Serializable value, QPossibleValueSource possibleValueSource) private String translatePossibleValueCustom(Serializable value, QPossibleValueSource possibleValueSource)
{ {
/////////////////////////////////
// null input gets null output //
/////////////////////////////////
if(value == null)
{
return (null);
}
try try
{ {
QCustomPossibleValueProvider customPossibleValueProvider = QCodeLoader.getCustomPossibleValueProvider(possibleValueSource); QCustomPossibleValueProvider customPossibleValueProvider = QCodeLoader.getCustomPossibleValueProvider(possibleValueSource);

View File

@ -77,6 +77,7 @@ import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.Bulk
import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.BulkInsertTransformStep; import com.kingsrook.qqq.backend.core.processes.implementations.bulk.insert.BulkInsertTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import com.kingsrook.qqq.backend.core.scheduler.QScheduleManager;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.ListingHash; import com.kingsrook.qqq.backend.core.utils.ListingHash;
import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils;
@ -159,6 +160,18 @@ public class QInstanceEnricher
} }
enrichJoins(); enrichJoins();
//////////////////////////////////////////////////////////////////////////////
// if the instance DOES have 1 or more scheduler, but no schedulable types, //
// then go ahead and add the default set that qqq knows about //
//////////////////////////////////////////////////////////////////////////////
if(CollectionUtils.nullSafeHasContents(qInstance.getSchedulers()))
{
if(CollectionUtils.nullSafeIsEmpty(qInstance.getSchedulableTypes()))
{
QScheduleManager.defineDefaultSchedulableTypesInInstance(qInstance);
}
}
} }

View File

@ -50,6 +50,16 @@ public abstract class QSchedulerMetaData implements TopLevelMetaDataInterface
/*******************************************************************************
**
*******************************************************************************/
public boolean mayUseInScheduledJobsTable()
{
return (true);
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/

View File

@ -68,6 +68,16 @@ public class QuartzSchedulerMetaData extends QSchedulerMetaData
/*******************************************************************************
**
*******************************************************************************/
public boolean mayUseInScheduledJobsTable()
{
return (true);
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/

View File

@ -61,6 +61,16 @@ public class SimpleSchedulerMetaData extends QSchedulerMetaData
/*******************************************************************************
**
*******************************************************************************/
public boolean mayUseInScheduledJobsTable()
{
return (false);
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/

View File

@ -73,7 +73,7 @@ public class ScheduledJob extends QRecordEntity
@QField(displayFormat = DisplayFormat.COMMAS) @QField(displayFormat = DisplayFormat.COMMAS)
private Integer repeatSeconds; private Integer repeatSeconds;
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR, possibleValueSourceName = ScheduledJobType.NAME) @QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR, possibleValueSourceName = ScheduledJobTypePossibleValueSource.NAME)
private String type; private String type;
@QField(isRequired = true) @QField(isRequired = true)

View File

@ -22,102 +22,16 @@
package com.kingsrook.qqq.backend.core.model.scheduledjobs; package com.kingsrook.qqq.backend.core.model.scheduledjobs;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
/******************************************************************************* /*******************************************************************************
** enum of core schedulable types that QQQ schedule manager directly knows about.
** **
** note though, that applications can define their own schedulable types,
** by adding SchedulableType meta-data to the QInstance, and providing classes
** that implement SchedulableRunner.
*******************************************************************************/ *******************************************************************************/
public enum ScheduledJobType implements PossibleValueEnum<String> public enum ScheduledJobType
{ {
PROCESS, PROCESS,
QUEUE_PROCESSOR, QUEUE_PROCESSOR,
TABLE_AUTOMATIONS, TABLE_AUTOMATIONS
// todo - future - USER_REPORT
;
public static final String NAME = "scheduledJobType";
private final String label;
/*******************************************************************************
** Constructor
**
*******************************************************************************/
ScheduledJobType()
{
this.label = QInstanceEnricher.nameToLabel(QInstanceEnricher.inferNameFromBackendName(name()));
}
/*******************************************************************************
** Get instance by id
**
*******************************************************************************/
public static ScheduledJobType getById(String id)
{
if(id == null)
{
return (null);
}
for(ScheduledJobType value : ScheduledJobType.values())
{
if(value.name().equals(id))
{
return (value);
}
}
return (null);
}
/*******************************************************************************
** Getter for id
**
*******************************************************************************/
public String getId()
{
return name();
}
/*******************************************************************************
** Getter for label
**
*******************************************************************************/
public String getLabel()
{
return label;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getPossibleValueId()
{
return name();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getPossibleValueLabel()
{
return (label);
}
} }

View File

@ -0,0 +1,87 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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.qqq.backend.core.model.scheduledjobs;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.scheduler.schedulable.SchedulableType;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
**
*******************************************************************************/
public class ScheduledJobTypePossibleValueSource implements QCustomPossibleValueProvider<String>
{
public static final String NAME = "scheduledJobType";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QPossibleValue<String> getPossibleValue(Serializable idValue)
{
SchedulableType schedulableType = QContext.getQInstance().getSchedulableType(String.valueOf(idValue));
if(schedulableType != null)
{
return schedulableTypeToPossibleValue(schedulableType);
}
return null;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input) throws QException
{
List<QPossibleValue<String>> rs = new ArrayList<>();
for(SchedulableType schedulableType : CollectionUtils.nonNullMap(QContext.getQInstance().getSchedulableTypes()).values())
{
rs.add(schedulableTypeToPossibleValue(schedulableType));
}
return rs;
}
/*******************************************************************************
**
*******************************************************************************/
private static QPossibleValue<String> schedulableTypeToPossibleValue(SchedulableType schedulableType)
{
return new QPossibleValue<>(schedulableType.getName(), schedulableType.getName());
}
}

View File

@ -41,9 +41,11 @@ import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRule
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Association; import com.kingsrook.qqq.backend.core.model.metadata.tables.Association;
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.model.scheduledjobs.customizers.ScheduledJobParameterTableCustomizer;
import com.kingsrook.qqq.backend.core.model.scheduledjobs.customizers.ScheduledJobTableCustomizer; import com.kingsrook.qqq.backend.core.model.scheduledjobs.customizers.ScheduledJobTableCustomizer;
@ -63,7 +65,7 @@ public class ScheduledJobsMetaDataProvider
{ {
defineStandardTables(instance, backendName, backendDetailEnricher); defineStandardTables(instance, backendName, backendDetailEnricher);
instance.addPossibleValueSource(QPossibleValueSource.newForTable(ScheduledJob.TABLE_NAME)); instance.addPossibleValueSource(QPossibleValueSource.newForTable(ScheduledJob.TABLE_NAME));
instance.addPossibleValueSource(QPossibleValueSource.newForEnum(ScheduledJobType.NAME, ScheduledJobType.values())); instance.addPossibleValueSource(defineScheduledJobTypePossibleValueSource());
instance.addPossibleValueSource(defineSchedulersPossibleValueSource()); instance.addPossibleValueSource(defineSchedulersPossibleValueSource());
defineStandardJoins(instance); defineStandardJoins(instance);
defineStandardWidgets(instance); defineStandardWidgets(instance);
@ -183,6 +185,11 @@ public class ScheduledJobsMetaDataProvider
.withAssociatedTableName(ScheduledJobParameter.TABLE_NAME) .withAssociatedTableName(ScheduledJobParameter.TABLE_NAME)
.withJoinName(JOB_PARAMETER_JOIN_NAME)); .withJoinName(JOB_PARAMETER_JOIN_NAME));
tableMetaData.withExposedJoin(new ExposedJoin()
.withJoinTable(ScheduledJobParameter.TABLE_NAME)
.withJoinPath(List.of(JOB_PARAMETER_JOIN_NAME))
.withLabel("Parameters"));
return (tableMetaData); return (tableMetaData);
} }
@ -199,11 +206,35 @@ public class ScheduledJobsMetaDataProvider
.withSection(new QFieldSection("identity", new QIcon().withName("badge"), Tier.T1, List.of("id", "scheduledJobId", "key", "value"))) .withSection(new QFieldSection("identity", new QIcon().withName("badge"), Tier.T1, List.of("id", "scheduledJobId", "key", "value")))
.withSection(new QFieldSection("dates", new QIcon().withName("calendar_month"), Tier.T3, List.of("createDate", "modifyDate"))); .withSection(new QFieldSection("dates", new QIcon().withName("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
QCodeReference customizerReference = new QCodeReference(ScheduledJobParameterTableCustomizer.class);
tableMetaData.withCustomizer(TableCustomizers.POST_INSERT_RECORD, customizerReference);
tableMetaData.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, customizerReference);
tableMetaData.withCustomizer(TableCustomizers.POST_DELETE_RECORD, customizerReference);
tableMetaData.withExposedJoin(new ExposedJoin()
.withJoinTable(ScheduledJob.TABLE_NAME)
.withJoinPath(List.of(JOB_PARAMETER_JOIN_NAME))
.withLabel("Scheduled Job"));
return (tableMetaData); return (tableMetaData);
} }
/*******************************************************************************
**
*******************************************************************************/
private QPossibleValueSource defineScheduledJobTypePossibleValueSource()
{
return (new QPossibleValueSource()
.withName(ScheduledJobTypePossibleValueSource.NAME)
.withType(QPossibleValueSourceType.CUSTOM)
.withCustomCodeReference(new QCodeReference(ScheduledJobTypePossibleValueSource.class)));
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
@ -213,7 +244,6 @@ public class ScheduledJobsMetaDataProvider
.withName(SchedulersPossibleValueSource.NAME) .withName(SchedulersPossibleValueSource.NAME)
.withType(QPossibleValueSourceType.CUSTOM) .withType(QPossibleValueSourceType.CUSTOM)
.withCustomCodeReference(new QCodeReference(SchedulersPossibleValueSource.class))); .withCustomCodeReference(new QCodeReference(SchedulersPossibleValueSource.class)));
} }
} }

View File

@ -68,9 +68,12 @@ public class SchedulersPossibleValueSource implements QCustomPossibleValueProvid
{ {
List<QPossibleValue<String>> rs = new ArrayList<>(); List<QPossibleValue<String>> rs = new ArrayList<>();
for(QSchedulerMetaData scheduler : CollectionUtils.nonNullMap(QContext.getQInstance().getSchedulers()).values()) for(QSchedulerMetaData scheduler : CollectionUtils.nonNullMap(QContext.getQInstance().getSchedulers()).values())
{
if(scheduler.mayUseInScheduledJobsTable())
{ {
rs.add(schedulerToPossibleValue(scheduler)); rs.add(schedulerToPossibleValue(scheduler));
} }
}
return rs; return rs;
} }

View File

@ -0,0 +1,215 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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.qqq.backend.core.model.scheduledjobs.customizers;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob;
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobParameter;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.ListingHash;
/*******************************************************************************
**
*******************************************************************************/
public class ScheduledJobParameterTableCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
{
///////////////////////////////////////////////////////////////////////////////////////
// if we're in this insert as a result of an insert (or update) on a different table //
// (e.g., under a manageAssociations call), then return with noop - assume that the //
// parent table's customizer will do what needed to be done. //
///////////////////////////////////////////////////////////////////////////////////////
if(!isThisAnActionDirectlyOnThisTable())
{
return (records);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else - this was an action directly on this table - so bump all of the parent records, to get them rescheduled //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
bumpParentRecords(records, Optional.empty());
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
private void bumpParentRecords(List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
{
try
{
///////////////////////////////////////////////////////////////////////////////////////////
// (listing) hash up the records by scheduledJobId - we'll use this to have a set of the //
// job ids, and in case we need to add warnings to them later //
///////////////////////////////////////////////////////////////////////////////////////////
ListingHash<Integer, QRecord> recordsByJobId = new ListingHash<>();
for(QRecord record : records)
{
recordsByJobId.add(record.getValueInteger("scheduledJobId"), record);
}
Set<Integer> scheduledJobIds = new HashSet<>(recordsByJobId.keySet());
////////////////////////////////////////////////////////////////////////////////
// if we have an old record list (e.g., is an edit), add any job ids that are //
// in those too, e.g., in case moving a param from one job to another... //
// note, we won't line these up for doing a proper warning on these... //
////////////////////////////////////////////////////////////////////////////////
if(oldRecordList.isPresent())
{
for(QRecord oldRecord : oldRecordList.get())
{
scheduledJobIds.add(oldRecord.getValueInteger("scheduledJobId"));
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// update the modify date on the scheduled jobs - to get their post-actions to run, to reschedule //
////////////////////////////////////////////////////////////////////////////////////////////////////
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(ScheduledJob.TABLE_NAME);
updateInput.setRecords(scheduledJobIds.stream()
.map(id -> new QRecord().withValue("id", id).withValue("modifyDate", Instant.now()))
.toList());
UpdateOutput updateOutput = new UpdateAction().execute(updateInput);
////////////////////////////////////////////////////////////////////////////////////////
// look for warnings on those jobs - and propagate them to the params we just stored. //
////////////////////////////////////////////////////////////////////////////////////////
for(QRecord updatedScheduledJob : updateOutput.getRecords())
{
if(CollectionUtils.nullSafeHasContents(updatedScheduledJob.getWarnings()))
{
for(QRecord paramToWarn : CollectionUtils.nonNullList(recordsByJobId.get(updatedScheduledJob.getValueInteger("id"))))
{
paramToWarn.setWarnings(updatedScheduledJob.getWarnings());
}
}
}
}
catch(Exception e)
{
LOG.warn("Error in scheduledJobParameter post-crud", e);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
{
/////////////////////////////////////////////////////////////////////////////
// if we're in this update as a result of an update on a different table //
// (e.g., under a manageAssociations call), then return with noop - assume //
// that the parent table's customizer will do what needed to be done. //
/////////////////////////////////////////////////////////////////////////////
if(!isThisAnActionDirectlyOnThisTable())
{
return (records);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else - this was an action directly on this table - so bump all of the parent records, to get them rescheduled //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
bumpParentRecords(records, oldRecordList);
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
{
/////////////////////////////////////////////////////////////////////////////
// if we're in this update as a result of an update on a different table //
// (e.g., under a manageAssociations call), then return with noop - assume //
// that the parent table's customizer will do what needed to be done. //
/////////////////////////////////////////////////////////////////////////////
if(!isThisAnActionDirectlyOnThisTable())
{
return (records);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else - this was an action directly on this table - so bump all of the parent records, to get them rescheduled //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
bumpParentRecords(records, Optional.empty());
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean isThisAnActionDirectlyOnThisTable()
{
Optional<AbstractActionInput> firstActionInStack = QContext.getFirstActionInStack();
if(firstActionInStack.isPresent())
{
if(firstActionInStack.get() instanceof AbstractTableActionInput tableActionInput)
{
if(!ScheduledJobParameter.TABLE_NAME.equals(tableActionInput.getTableName()))
{
return (false);
}
}
}
return (true);
}
}

View File

@ -22,10 +22,12 @@
package com.kingsrook.qqq.backend.core.model.scheduledjobs.customizers; package com.kingsrook.qqq.backend.core.model.scheduledjobs.customizers;
import java.io.Serializable; import java.text.ParseException;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.ListIterator; import java.util.ListIterator;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.function.Predicate; import java.util.function.Predicate;
@ -43,9 +45,11 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob; import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage;
import com.kingsrook.qqq.backend.core.scheduler.QScheduleManager; import com.kingsrook.qqq.backend.core.scheduler.QScheduleManager;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.quartz.CronScheduleBuilder;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@ -61,7 +65,7 @@ public class ScheduledJobTableCustomizer implements TableCustomizerInterface
@Override @Override
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
{ {
validateConditionalFields(records); validateConditionalFields(records, Collections.emptyMap());
return (records); return (records);
} }
@ -85,7 +89,9 @@ public class ScheduledJobTableCustomizer implements TableCustomizerInterface
@Override @Override
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{ {
validateConditionalFields(records); Map<Integer, QRecord> freshOldRecordsWithAssociationsMap = CollectionUtils.recordsToMap(freshlyQueryForRecordsWithAssociations(oldRecordList.get()), "id", Integer.class);
validateConditionalFields(records, freshOldRecordsWithAssociationsMap);
if(isPreview || oldRecordList.isEmpty()) if(isPreview || oldRecordList.isEmpty())
{ {
@ -95,7 +101,6 @@ public class ScheduledJobTableCustomizer implements TableCustomizerInterface
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// refresh the old-records w/ versions that have associations - so we can use those in the post-update to property unschedule things // // refresh the old-records w/ versions that have associations - so we can use those in the post-update to property unschedule things //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
Map<Serializable, QRecord> freshOldRecordsWithAssociationsMap = CollectionUtils.recordsToMap(freshlyQueryForRecordsWithAssociations(oldRecordList.get()), "id");
ListIterator<QRecord> iterator = oldRecordList.get().listIterator(); ListIterator<QRecord> iterator = oldRecordList.get().listIterator();
while(iterator.hasNext()) while(iterator.hasNext())
{ {
@ -115,15 +120,42 @@ public class ScheduledJobTableCustomizer implements TableCustomizerInterface
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
private static void validateConditionalFields(List<QRecord> records) private static void validateConditionalFields(List<QRecord> records, Map<Integer, QRecord> freshOldRecordsWithAssociationsMap)
{ {
QRecord blankRecord = new QRecord();
for(QRecord record : records) for(QRecord record : records)
{ {
if(StringUtils.hasContent(record.getValueString("cronExpression"))) QRecord oldRecord = Objects.requireNonNullElse(freshOldRecordsWithAssociationsMap.get(record.getValueInteger("id")), blankRecord);
String cronExpression = record.getValues().containsKey("cronExpression") ? record.getValueString("cronExpression") : oldRecord.getValueString("cronExpression");
String cronTimeZoneId = record.getValues().containsKey("cronTimeZoneId") ? record.getValueString("cronTimeZoneId") : oldRecord.getValueString("cronTimeZoneId");
String repeatSeconds = record.getValues().containsKey("repeatSeconds") ? record.getValueString("repeatSeconds") : oldRecord.getValueString("repeatSeconds");
if(StringUtils.hasContent(cronExpression))
{ {
if(!StringUtils.hasContent(record.getValueString("cronTimeZoneId"))) if(StringUtils.hasContent(repeatSeconds))
{ {
record.addError(new BadInputStatusMessage("If a Cron Expression is given, then a Cron Time Zone Id is required.")); record.addError(new BadInputStatusMessage("Cron Expression and Repeat Seconds may not both be given."));
}
try
{
CronScheduleBuilder.cronScheduleNonvalidatedExpression(cronExpression);
}
catch(ParseException e)
{
record.addError(new BadInputStatusMessage("Cron Expression [" + cronExpression + "] is not valid: " + e.getMessage()));
}
if(!StringUtils.hasContent(cronTimeZoneId))
{
record.addError(new BadInputStatusMessage("If a Cron Expression is given, then a Cron Time Zone is required."));
}
}
else
{
if(!StringUtils.hasContent(repeatSeconds))
{
record.addError(new BadInputStatusMessage("Either Cron Expression or Repeat Seconds must be given."));
} }
} }
} }
@ -189,6 +221,7 @@ public class ScheduledJobTableCustomizer implements TableCustomizerInterface
try try
{ {
Map<Integer, QRecord> originalRecordMap = recordsWithoutErrors.stream().collect(Collectors.toMap(r -> r.getValueInteger("id"), r -> r));
List<QRecord> freshRecordListWithAssociations = freshlyQueryForRecordsWithAssociations(recordsWithoutErrors); List<QRecord> freshRecordListWithAssociations = freshlyQueryForRecordsWithAssociations(recordsWithoutErrors);
QScheduleManager scheduleManager = QScheduleManager.getInstance(); QScheduleManager scheduleManager = QScheduleManager.getInstance();
@ -200,7 +233,11 @@ public class ScheduledJobTableCustomizer implements TableCustomizerInterface
} }
catch(Exception e) catch(Exception e)
{ {
LOG.info("Caught exception while scheduling a job in post-action", e, logPair("id", record.getValue("id"))); LOG.warn("Caught exception while scheduling a job in post-action", e, logPair("id", record.getValue("id")));
if(originalRecordMap.containsKey(record.getValueInteger("id")))
{
originalRecordMap.get(record.getValueInteger("id")).addWarning(new QWarningMessage("Error scheduling job: " + e.getMessage()));
}
} }
} }
} }
@ -265,7 +302,7 @@ public class ScheduledJobTableCustomizer implements TableCustomizerInterface
} }
catch(Exception e) catch(Exception e)
{ {
LOG.info("Caught exception while scheduling a job in post-action", e, logPair("id", record.getValue("id"))); LOG.warn("Caught exception while un-scheduling a job in post-action", e, logPair("id", record.getValue("id")));
} }
} }
} }

View File

@ -113,6 +113,10 @@ public class ExtractViaQueryStep extends AbstractExtractStep
{ {
queryInput.setShouldFetchHeavyFields(true); queryInput.setShouldFetchHeavyFields(true);
} }
if(runBackendStepInput.getValuePrimitiveBoolean(StreamedETLWithFrontendProcess.FIELD_INCLUDE_ASSOCIATIONS))
{
queryInput.setIncludeAssociations(true);
}
customizeInputPreQuery(queryInput); customizeInputPreQuery(queryInput);

View File

@ -84,6 +84,7 @@ public class StreamedETLWithFrontendProcess
public static final String FIELD_RECORD_COUNT = "recordCount"; // Integer public static final String FIELD_RECORD_COUNT = "recordCount"; // Integer
public static final String FIELD_DEFAULT_QUERY_FILTER = "defaultQueryFilter"; // QQueryFilter or String (json, of q QQueryFilter) public static final String FIELD_DEFAULT_QUERY_FILTER = "defaultQueryFilter"; // QQueryFilter or String (json, of q QQueryFilter)
public static final String FIELD_FETCH_HEAVY_FIELDS = "fetchHeavyFields"; // Boolean public static final String FIELD_FETCH_HEAVY_FIELDS = "fetchHeavyFields"; // Boolean
public static final String FIELD_INCLUDE_ASSOCIATIONS = "includeAssociations"; // Boolean
public static final String FIELD_SUPPORTS_FULL_VALIDATION = "supportsFullValidation"; // Boolean public static final String FIELD_SUPPORTS_FULL_VALIDATION = "supportsFullValidation"; // Boolean
public static final String FIELD_DO_FULL_VALIDATION = "doFullValidation"; // Boolean public static final String FIELD_DO_FULL_VALIDATION = "doFullValidation"; // Boolean
@ -145,6 +146,7 @@ public class StreamedETLWithFrontendProcess
.withCode(new QCodeReference(StreamedETLPreviewStep.class)) .withCode(new QCodeReference(StreamedETLPreviewStep.class))
.withInputData(new QFunctionInputMetaData() .withInputData(new QFunctionInputMetaData()
.withField(new QFieldMetaData(FIELD_SOURCE_TABLE, QFieldType.STRING).withDefaultValue(defaultFieldValues.get(FIELD_SOURCE_TABLE))) .withField(new QFieldMetaData(FIELD_SOURCE_TABLE, QFieldType.STRING).withDefaultValue(defaultFieldValues.get(FIELD_SOURCE_TABLE)))
.withField(new QFieldMetaData(FIELD_INCLUDE_ASSOCIATIONS, QFieldType.BOOLEAN).withDefaultValue(defaultFieldValues.getOrDefault(FIELD_INCLUDE_ASSOCIATIONS, false)))
.withField(new QFieldMetaData(FIELD_FETCH_HEAVY_FIELDS, QFieldType.BOOLEAN).withDefaultValue(defaultFieldValues.getOrDefault(FIELD_FETCH_HEAVY_FIELDS, false))) .withField(new QFieldMetaData(FIELD_FETCH_HEAVY_FIELDS, QFieldType.BOOLEAN).withDefaultValue(defaultFieldValues.getOrDefault(FIELD_FETCH_HEAVY_FIELDS, false)))
.withField(new QFieldMetaData(FIELD_DESTINATION_TABLE, QFieldType.STRING).withDefaultValue(defaultFieldValues.get(FIELD_DESTINATION_TABLE))) .withField(new QFieldMetaData(FIELD_DESTINATION_TABLE, QFieldType.STRING).withDefaultValue(defaultFieldValues.get(FIELD_DESTINATION_TABLE)))
.withField(new QFieldMetaData(FIELD_SUPPORTS_FULL_VALIDATION, QFieldType.BOOLEAN).withDefaultValue(defaultFieldValues.getOrDefault(FIELD_SUPPORTS_FULL_VALIDATION, true))) .withField(new QFieldMetaData(FIELD_SUPPORTS_FULL_VALIDATION, QFieldType.BOOLEAN).withDefaultValue(defaultFieldValues.getOrDefault(FIELD_SUPPORTS_FULL_VALIDATION, true)))

View File

@ -102,15 +102,6 @@ public class QScheduleManager
{ {
qScheduleManager = new QScheduleManager(qInstance, systemUserSessionSupplier); qScheduleManager = new QScheduleManager(qInstance, systemUserSessionSupplier);
/////////////////////////////////////////////////////////////////
// if the instance doesn't have any schedulable types defined, //
// then go ahead and add the default set that qqq knows about //
/////////////////////////////////////////////////////////////////
if(CollectionUtils.nullSafeIsEmpty(qInstance.getSchedulableTypes()))
{
defineDefaultSchedulableTypesInInstance(qInstance);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// initialize the scheduler(s) we're configured to use // // initialize the scheduler(s) we're configured to use //
// do this, even if we won't start them - so, for example, a web server can still be aware of schedules in the application // // do this, even if we won't start them - so, for example, a web server can still be aware of schedules in the application //
@ -131,9 +122,9 @@ public class QScheduleManager
*******************************************************************************/ *******************************************************************************/
public static void defineDefaultSchedulableTypesInInstance(QInstance qInstance) public static void defineDefaultSchedulableTypesInInstance(QInstance qInstance)
{ {
qInstance.addSchedulableType(new SchedulableType().withName(ScheduledJobType.PROCESS.getId()).withRunner(new QCodeReference(SchedulableProcessRunner.class))); qInstance.addSchedulableType(new SchedulableType().withName(ScheduledJobType.PROCESS.name()).withRunner(new QCodeReference(SchedulableProcessRunner.class)));
qInstance.addSchedulableType(new SchedulableType().withName(ScheduledJobType.QUEUE_PROCESSOR.getId()).withRunner(new QCodeReference(SchedulableSQSQueueRunner.class))); qInstance.addSchedulableType(new SchedulableType().withName(ScheduledJobType.QUEUE_PROCESSOR.name()).withRunner(new QCodeReference(SchedulableSQSQueueRunner.class)));
qInstance.addSchedulableType(new SchedulableType().withName(ScheduledJobType.TABLE_AUTOMATIONS.getId()).withRunner(new QCodeReference(SchedulableTableAutomationsRunner.class))); qInstance.addSchedulableType(new SchedulableType().withName(ScheduledJobType.TABLE_AUTOMATIONS.name()).withRunner(new QCodeReference(SchedulableTableAutomationsRunner.class)));
} }
@ -330,8 +321,8 @@ public class QScheduleManager
throw (new QException("Missing a type " + exceptionSuffix)); throw (new QException("Missing a type " + exceptionSuffix));
} }
ScheduledJobType scheduledJobType = ScheduledJobType.getById(scheduledJob.getType()); SchedulableType schedulableType = qInstance.getSchedulableType(scheduledJob.getType());
if(scheduledJobType == null) if(schedulableType == null)
{ {
throw (new QException("Unrecognized type [" + scheduledJob.getType() + "] " + exceptionSuffix)); throw (new QException("Unrecognized type [" + scheduledJob.getType() + "] " + exceptionSuffix));
} }
@ -339,8 +330,6 @@ public class QScheduleManager
QSchedulerInterface scheduler = getScheduler(scheduledJob.getSchedulerName()); QSchedulerInterface scheduler = getScheduler(scheduledJob.getSchedulerName());
Map<String, Serializable> paramMap = new HashMap<>(scheduledJob.getJobParametersMap()); Map<String, Serializable> paramMap = new HashMap<>(scheduledJob.getJobParametersMap());
SchedulableType schedulableType = qInstance.getSchedulableType(scheduledJob.getType());
SchedulableRunner runner = QCodeLoader.getAdHoc(SchedulableRunner.class, schedulableType.getRunner()); SchedulableRunner runner = QCodeLoader.getAdHoc(SchedulableRunner.class, schedulableType.getRunner());
runner.validateParams(schedulableIdentity, new HashMap<>(paramMap)); runner.validateParams(schedulableIdentity, new HashMap<>(paramMap));
@ -396,7 +385,7 @@ public class QScheduleManager
Map<String, String> paramMap = new HashMap<>(); Map<String, String> paramMap = new HashMap<>();
paramMap.put("processName", process.getName()); paramMap.put("processName", process.getName());
SchedulableType schedulableType = qInstance.getSchedulableType(ScheduledJobType.PROCESS.getId()); SchedulableType schedulableType = qInstance.getSchedulableType(ScheduledJobType.PROCESS.name());
if(process.getVariantBackend() == null || VariantRunStrategy.SERIAL.equals(process.getVariantRunStrategy())) if(process.getVariantBackend() == null || VariantRunStrategy.SERIAL.equals(process.getVariantRunStrategy()))
{ {
@ -446,7 +435,7 @@ public class QScheduleManager
*******************************************************************************/ *******************************************************************************/
private void setupTableAutomations(QTableMetaData table) throws QException private void setupTableAutomations(QTableMetaData table) throws QException
{ {
SchedulableType schedulableType = qInstance.getSchedulableType(ScheduledJobType.TABLE_AUTOMATIONS.getId()); SchedulableType schedulableType = qInstance.getSchedulableType(ScheduledJobType.TABLE_AUTOMATIONS.name());
QTableAutomationDetails automationDetails = table.getAutomationDetails(); QTableAutomationDetails automationDetails = table.getAutomationDetails();
QSchedulerInterface scheduler = getScheduler(automationDetails.getSchedule().getSchedulerName()); QSchedulerInterface scheduler = getScheduler(automationDetails.getSchedule().getSchedulerName());
@ -475,7 +464,7 @@ public class QScheduleManager
{ {
SchedulableIdentity schedulableIdentity = SchedulableIdentityFactory.of(queue); SchedulableIdentity schedulableIdentity = SchedulableIdentityFactory.of(queue);
QSchedulerInterface scheduler = getScheduler(queue.getSchedule().getSchedulerName()); QSchedulerInterface scheduler = getScheduler(queue.getSchedule().getSchedulerName());
SchedulableType schedulableType = qInstance.getSchedulableType(ScheduledJobType.QUEUE_PROCESSOR.getId()); SchedulableType schedulableType = qInstance.getSchedulableType(ScheduledJobType.QUEUE_PROCESSOR.name());
boolean allowedToStart = SchedulerUtils.allowedToStart(queue.getName()); boolean allowedToStart = SchedulerUtils.allowedToStart(queue.getName());
Map<String, String> paramMap = new HashMap<>(); Map<String, String> paramMap = new HashMap<>();

View File

@ -30,6 +30,8 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.scheduler.schedulable.SchedulableType; import com.kingsrook.qqq.backend.core.scheduler.schedulable.SchedulableType;
import com.kingsrook.qqq.backend.core.scheduler.schedulable.runner.SchedulableRunner; import com.kingsrook.qqq.backend.core.scheduler.schedulable.runner.SchedulableRunner;
import org.apache.logging.log4j.Level;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job; import org.quartz.Job;
import org.quartz.JobExecutionContext; import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException; import org.quartz.JobExecutionException;
@ -39,10 +41,13 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
@DisallowConcurrentExecution
public class QuartzJobRunner implements Job public class QuartzJobRunner implements Job
{ {
private static final QLogger LOG = QLogger.getLogger(QuartzJobRunner.class); private static final QLogger LOG = QLogger.getLogger(QuartzJobRunner.class);
private static Level logLevel = null;
/******************************************************************************* /*******************************************************************************
@ -52,21 +57,38 @@ public class QuartzJobRunner implements Job
public void execute(JobExecutionContext context) throws JobExecutionException public void execute(JobExecutionContext context) throws JobExecutionException
{ {
CapturedContext capturedContext = QContext.capture(); CapturedContext capturedContext = QContext.capture();
String name = null;
SchedulableType schedulableType = null;
Map<String, Object> params = null;
try try
{ {
name = context.getJobDetail().getKey().getName();
QuartzScheduler quartzScheduler = QuartzScheduler.getInstance(); QuartzScheduler quartzScheduler = QuartzScheduler.getInstance();
QInstance qInstance = quartzScheduler.getQInstance(); QInstance qInstance = quartzScheduler.getQInstance();
QContext.init(qInstance, quartzScheduler.getSessionSupplier().get()); QContext.init(qInstance, quartzScheduler.getSessionSupplier().get());
SchedulableType schedulableType = qInstance.getSchedulableType(context.getJobDetail().getJobDataMap().getString("type")); schedulableType = qInstance.getSchedulableType(context.getJobDetail().getJobDataMap().getString("type"));
Map<String, Object> params = (Map<String, Object>) context.getJobDetail().getJobDataMap().get("params"); params = (Map<String, Object>) context.getJobDetail().getJobDataMap().get("params");
SchedulableRunner schedulableRunner = QCodeLoader.getAdHoc(SchedulableRunner.class, schedulableType.getRunner()); SchedulableRunner schedulableRunner = QCodeLoader.getAdHoc(SchedulableRunner.class, schedulableType.getRunner());
if(logLevel != null)
{
LOG.log(logLevel, "Running QuartzJob", null, logPair("name", name), logPair("type", schedulableType.getName()), logPair("params", params));
}
schedulableRunner.run(params); schedulableRunner.run(params);
if(logLevel != null)
{
LOG.log(logLevel, "Finished QuartzJob", null, logPair("name", name), logPair("type", schedulableType.getName()), logPair("params", params));
}
} }
catch(Exception e) catch(Exception e)
{ {
LOG.warn("Error running QuartzJob", e, logPair("jobContext", context)); LOG.warn("Error running QuartzJob", e, logPair("name", name), logPair("type", schedulableType == null ? null : schedulableType.getName()), logPair("params", params));
} }
finally finally
{ {
@ -74,4 +96,14 @@ public class QuartzJobRunner implements Job
} }
} }
/*******************************************************************************
**
*******************************************************************************/
public static void setLogLevel(Level level)
{
logLevel = level;
}
} }

View File

@ -52,11 +52,12 @@ public class PauseQuartzJobsProcess extends AbstractLoadStep implements MetaData
@Override @Override
public QProcessMetaData produce(QInstance qInstance) throws QException public QProcessMetaData produce(QInstance qInstance) throws QException
{ {
String tableName = "quartzTriggers"; String tableName = "quartzJobDetails";
return StreamedETLWithFrontendProcess.processMetaDataBuilder() return StreamedETLWithFrontendProcess.processMetaDataBuilder()
.withName(getClass().getSimpleName()) .withName(getClass().getSimpleName())
.withLabel("Pause Quartz Jobs") .withLabel("Pause Quartz Jobs")
.withPreviewMessage("This is a preview of the jobs that will be paused.")
.withTableName(tableName) .withTableName(tableName)
.withSourceTable(tableName) .withSourceTable(tableName)
.withDestinationTable(tableName) .withDestinationTable(tableName)

View File

@ -52,11 +52,12 @@ public class ResumeQuartzJobsProcess extends AbstractLoadStep implements MetaDat
@Override @Override
public QProcessMetaData produce(QInstance qInstance) throws QException public QProcessMetaData produce(QInstance qInstance) throws QException
{ {
String tableName = "quartzTriggers"; String tableName = "quartzJobDetails";
return StreamedETLWithFrontendProcess.processMetaDataBuilder() return StreamedETLWithFrontendProcess.processMetaDataBuilder()
.withName(getClass().getSimpleName()) .withName(getClass().getSimpleName())
.withLabel("Resume Quartz Jobs") .withLabel("Resume Quartz Jobs")
.withPreviewMessage("This is a preview of the jobs that will be resumed.")
.withTableName(tableName) .withTableName(tableName)
.withSourceTable(tableName) .withSourceTable(tableName)
.withDestinationTable(tableName) .withDestinationTable(tableName)

View File

@ -22,12 +22,17 @@
package com.kingsrook.qqq.backend.core.scheduler.quartz.tables; package com.kingsrook.qqq.backend.core.scheduler.quartz.tables;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.apache.commons.lang3.SerializationUtils; import org.apache.commons.lang3.SerializationUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/******************************************************************************* /*******************************************************************************
@ -67,8 +72,35 @@ public class QuartzJobDataPostQueryCustomizer extends AbstractPostQueryCustomize
LOG.info("Error deserializing quartz job data", e); LOG.info("Error deserializing quartz job data", e);
} }
} }
formatEpochTime(record, "nextFireTime");
formatEpochTime(record, "prevFireTime");
formatEpochTime(record, "startTime");
} }
return (records); return (records);
} }
/*******************************************************************************
**
*******************************************************************************/
private static void formatEpochTime(QRecord record, String fieldName)
{
Long value = record.getValueLong(fieldName);
try
{
if(value != null && value > 0)
{
Instant instant = Instant.ofEpochMilli(value);
record.setDisplayValue(fieldName, String.format("%,d", value) + " (" + QValueFormatter.formatDateTimeWithZone(instant.atZone(ZoneId.of(QContext.getQInstance().getDefaultTimeZoneId()))) + ")");
}
}
catch(Exception e)
{
LOG.info("Error formatting an epoc time value", e, logPair("fieldName", fieldName), logPair("value", value));
}
}
} }

View File

@ -29,7 +29,6 @@ import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.queues.QQueueMetaData; import com.kingsrook.qqq.backend.core.model.metadata.queues.QQueueMetaData;
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob; import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob;
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobType;
import com.kingsrook.qqq.backend.core.scheduler.schedulable.SchedulableType; import com.kingsrook.qqq.backend.core.scheduler.schedulable.SchedulableType;
import com.kingsrook.qqq.backend.core.scheduler.schedulable.runner.SchedulableRunner; import com.kingsrook.qqq.backend.core.scheduler.schedulable.runner.SchedulableRunner;
@ -46,18 +45,17 @@ public class SchedulableIdentityFactory
public static BasicSchedulableIdentity of(ScheduledJob scheduledJob) public static BasicSchedulableIdentity of(ScheduledJob scheduledJob)
{ {
String description = ""; String description = "";
ScheduledJobType scheduledJobType = ScheduledJobType.getById(scheduledJob.getType()); SchedulableType schedulableType = QContext.getQInstance().getSchedulableType(scheduledJob.getType());
if(scheduledJobType != null) if(schedulableType != null)
{ {
try try
{ {
SchedulableType schedulableType = QContext.getQInstance().getSchedulableType(scheduledJob.getType());
SchedulableRunner runner = QCodeLoader.getAdHoc(SchedulableRunner.class, schedulableType.getRunner()); SchedulableRunner runner = QCodeLoader.getAdHoc(SchedulableRunner.class, schedulableType.getRunner());
description = runner.getDescription(new HashMap<>(scheduledJob.getJobParametersMap())); description = runner.getDescription(new HashMap<>(scheduledJob.getJobParametersMap()));
} }
catch(Exception e) catch(Exception e)
{ {
description = "type: " + scheduledJobType; description = "type: " + schedulableType.getName();
} }
} }

View File

@ -75,10 +75,6 @@ public class SchedulableTableAutomationsRunner implements SchedulableRunner
AutomationStatus automationStatus = AutomationStatus.valueOf(ValueUtils.getValueAsString(params.get("automationStatus"))); AutomationStatus automationStatus = AutomationStatus.valueOf(ValueUtils.getValueAsString(params.get("automationStatus")));
/////////////
// run it. //
/////////////
LOG.debug("Running table automations", logPair("tableName", tableName), logPair(""));
QTableAutomationDetails automationDetails = table.getAutomationDetails(); QTableAutomationDetails automationDetails = table.getAutomationDetails();
if(automationDetails == null) if(automationDetails == null)
{ {

View File

@ -518,6 +518,24 @@ public class CollectionUtils
/*******************************************************************************
** Convert a collection of QRecords to a map, from one field's values out of
** those records, to the records themselves.
*******************************************************************************/
public static <T extends Serializable> Map<T, QRecord> recordsToMap(Collection<QRecord> records, String keyFieldName, Class<T> type)
{
Map<T, QRecord> rs = new HashMap<>();
for(QRecord record : nonNullCollection(records))
{
rs.put(ValueUtils.getValueAsType(type, record.getValue(keyFieldName)), record);
}
return (rs);
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/

View File

@ -1,64 +0,0 @@
#
# QQQ - Low-code Application Framework for Engineers.
# Copyright (C) 2021-2023. 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/>.
#
#org.quartz.scheduler.instanceName = MyScheduler
#org.quartz.threadPool.threadCount = 3
#org.quartz.jobStore.class = org.quartz.simpl.RAMJobStore
#
#============================================================================
# Configure Main Scheduler Properties
#============================================================================
org.quartz.scheduler.instanceName = MyClusteredScheduler
org.quartz.scheduler.instanceId = AUTO
#============================================================================
# Configure ThreadPool
#============================================================================
org.quartz.threadPool.class = org.quartz.simpl.SimpleThreadPool
org.quartz.threadPool.threadCount = 5
org.quartz.threadPool.threadPriority = 5
#============================================================================
# Configure JobStore
#============================================================================
org.quartz.jobStore.misfireThreshold = 60000
org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.StdJDBCDelegate
org.quartz.jobStore.useProperties = false
org.quartz.jobStore.dataSource = myDS
org.quartz.jobStore.tablePrefix = QUARTZ_
org.quartz.jobStore.isClustered = true
org.quartz.jobStore.clusterCheckinInterval = 20000
#============================================================================
# Configure Datasources
#============================================================================
org.quartz.dataSource.myDS.driver = com.mysql.cj.jdbc.Driver
org.quartz.dataSource.myDS.URL = jdbc:mysql://localhost:3306/nutrifresh_one
org.quartz.dataSource.myDS.user = root
org.quartz.dataSource.myDS.password = BXca6Bubxf!ECt7sua6L
org.quartz.dataSource.myDS.maxConnections = 5
org.quartz.dataSource.myDS.validationQuery=select 1

View File

@ -0,0 +1,383 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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.qqq.backend.core.model.scheduledjobs.customizers;
import java.time.Instant;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import com.kingsrook.qqq.backend.core.BaseTest;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
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.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
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.scheduledjobs.ScheduledJob;
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobParameter;
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobType;
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.scheduler.QScheduleManager;
import com.kingsrook.qqq.backend.core.scheduler.quartz.QuartzJobAndTriggerWrapper;
import com.kingsrook.qqq.backend.core.scheduler.quartz.QuartzTestUtils;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.TestUtils;
import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeFunction;
import org.assertj.core.api.AbstractStringAssert;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.quartz.CronTrigger;
import org.quartz.SchedulerException;
import org.quartz.SimpleTrigger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Unit test for ScheduledJobTableCustomizer
*******************************************************************************/
class ScheduledJobTableCustomizerTest extends BaseTest
{
private static final String GOOD_CRON = "0 * * * * ?";
private static final String GOOD_CRON_2 = "* * * * * ?";
private static final String BAD_CRON = "* * * * * *";
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
QInstance qInstance = QContext.getQInstance();
QuartzTestUtils.setupInstanceForQuartzTests();
QSession qSession = QContext.getQSession();
QScheduleManager qScheduleManager = QScheduleManager.initInstance(qInstance, () -> qSession);
qScheduleManager.start();
new ScheduledJobsMetaDataProvider().defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, null);
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QuartzTestUtils.afterEach();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPreInsertAssertValidationErrors() throws QException
{
UnsafeFunction<Consumer<ScheduledJob>, QRecord, QException> tryToInsert = consumer ->
{
ScheduledJob scheduledJob = new ScheduledJob()
.withLabel("Test")
.withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)
.withType(ScheduledJobType.PROCESS.name())
.withIsActive(true);
consumer.accept(scheduledJob);
InsertOutput insertOutput = new InsertAction().execute(new InsertInput(ScheduledJob.TABLE_NAME).withRecordEntity(scheduledJob));
return (insertOutput.getRecords().get(0));
};
/////////////////////////////////////////////////////////
// lambdas to run a test and assert about no of errors //
/////////////////////////////////////////////////////////
Function<QRecord, AbstractStringAssert<?>> assertOneErrorExtractingMessage = qRecord -> assertThat(qRecord.getErrors()).hasSize(1).first().extracting("message").asString();
Consumer<QRecord> assertNoErrors = qRecord -> assertThat(qRecord.getErrors()).hasSize(0);
assertOneErrorExtractingMessage.apply(tryToInsert.apply(sj -> sj.setId(null)))
.contains("Either Cron Expression or Repeat Seconds must be given");
assertOneErrorExtractingMessage.apply(tryToInsert.apply(sj -> sj.withRepeatSeconds(1).withCronExpression(GOOD_CRON).withCronTimeZoneId("UTC")))
.contains("Cron Expression and Repeat Seconds may not both be given");
assertOneErrorExtractingMessage.apply(tryToInsert.apply(sj -> sj.withRepeatSeconds(null).withCronExpression(GOOD_CRON)))
.contains("If a Cron Expression is given, then a Cron Time Zone is required");
assertOneErrorExtractingMessage.apply(tryToInsert.apply(sj -> sj.withRepeatSeconds(null).withCronExpression(BAD_CRON).withCronTimeZoneId("UTC")))
.contains("Support for specifying both a day-of-week AND a day-of-month parameter is not implemented");
///////////////////
// success cases //
///////////////////
assertNoErrors.accept(tryToInsert.apply(sj -> sj.withCronExpression(GOOD_CRON).withCronTimeZoneId("UTC")));
assertNoErrors.accept(tryToInsert.apply(sj -> sj.withRepeatSeconds(1)));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPostInsertActionSchedulesJob() throws QException, SchedulerException
{
List<QuartzJobAndTriggerWrapper> wrappers = QuartzTestUtils.queryQuartz();
assertEquals(0, wrappers.size());
InsertOutput insertOutput = new InsertAction().execute(new InsertInput(ScheduledJob.TABLE_NAME).withRecordEntity(new ScheduledJob()
.withLabel("Test")
.withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)
.withType(ScheduledJobType.PROCESS.name())
.withIsActive(true)
.withRepeatSeconds(1)
.withJobParameters(List.of(
new ScheduledJobParameter().withKey("processName").withValue(TestUtils.PROCESS_NAME_BASEPULL)))));
assertTrue(CollectionUtils.nullSafeIsEmpty(insertOutput.getRecords().get(0).getErrors()));
assertTrue(CollectionUtils.nullSafeIsEmpty(insertOutput.getRecords().get(0).getWarnings()));
wrappers = QuartzTestUtils.queryQuartz();
assertEquals(1, wrappers.size());
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPostInsertActionIssuesWarnings() throws QException, SchedulerException
{
List<QuartzJobAndTriggerWrapper> wrappers = QuartzTestUtils.queryQuartz();
assertEquals(0, wrappers.size());
InsertOutput insertOutput = new InsertAction().execute(new InsertInput(ScheduledJob.TABLE_NAME).withRecordEntity(new ScheduledJob()
.withLabel("Test")
.withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)
.withType(ScheduledJobType.PROCESS.name())
.withIsActive(true)
.withRepeatSeconds(1)
.withJobParameters(List.of(
new ScheduledJobParameter().withKey("processName").withValue("notAProcess")))));
assertTrue(CollectionUtils.nullSafeIsEmpty(insertOutput.getRecords().get(0).getErrors()));
assertThat(insertOutput.getRecords().get(0).getWarnings())
.hasSize(1).first().extracting("message").asString()
.contains("Error scheduling job: Unrecognized processName");
wrappers = QuartzTestUtils.queryQuartz();
assertEquals(0, wrappers.size());
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPreUpdateAssertValidationErrors() throws QException
{
new InsertAction().execute(new InsertInput(ScheduledJob.TABLE_NAME).withRecordEntity(new ScheduledJob()
.withLabel("Test")
.withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)
.withType(ScheduledJobType.PROCESS.name())
.withIsActive(true)
.withRepeatSeconds(1)));
UnsafeFunction<Consumer<QRecord>, QRecord, QException> tryToUpdate = consumer ->
{
QRecord record = new QRecord().withValue("id", 1);
consumer.accept(record);
UpdateOutput updateOutput = new UpdateAction().execute(new UpdateInput(ScheduledJob.TABLE_NAME).withRecord(record));
return (updateOutput.getRecords().get(0));
};
/////////////////////////////////////////////////////////
// lambdas to run a test and assert about no of errors //
/////////////////////////////////////////////////////////
Function<QRecord, AbstractStringAssert<?>> assertOneErrorExtractingMessage = qRecord -> assertThat(qRecord.getErrors()).hasSize(1).first().extracting("message").asString();
Consumer<QRecord> assertNoErrors = qRecord -> assertThat(qRecord.getErrors()).hasSize(0);
assertOneErrorExtractingMessage.apply(tryToUpdate.apply(r -> r.withValue("repeatSeconds", null)))
.contains("Either Cron Expression or Repeat Seconds must be given");
assertOneErrorExtractingMessage.apply(tryToUpdate.apply(r -> r.withValue("cronExpression", GOOD_CRON).withValue("cronTimeZoneId", "UTC")))
.contains("Cron Expression and Repeat Seconds may not both be given");
assertOneErrorExtractingMessage.apply(tryToUpdate.apply(r -> r.withValue("repeatSeconds", null).withValue("cronExpression", GOOD_CRON)))
.contains("If a Cron Expression is given, then a Cron Time Zone is required");
assertOneErrorExtractingMessage.apply(tryToUpdate.apply(r -> r.withValue("repeatSeconds", null).withValue("cronExpression", BAD_CRON).withValue("cronTimeZoneId", "UTC")))
.contains("Support for specifying both a day-of-week AND a day-of-month parameter is not implemented");
///////////////////
// success cases //
///////////////////
assertNoErrors.accept(tryToUpdate.apply(r -> r.withValue("modifyDate", Instant.now())));
assertNoErrors.accept(tryToUpdate.apply(r -> r.withValue("repeatSeconds", null).withValue("cronExpression", GOOD_CRON).withValue("cronTimeZoneId", "UTC")));
assertNoErrors.accept(tryToUpdate.apply(r -> r.withValue("repeatSeconds", null).withValue("cronExpression", GOOD_CRON_2).withValue("cronTimeZoneId", "UTC")));
assertNoErrors.accept(tryToUpdate.apply(r -> r.withValue("repeatSeconds", null).withValue("cronExpression", GOOD_CRON).withValue("cronTimeZoneId", "America/Chicago")));
assertNoErrors.accept(tryToUpdate.apply(r -> r.withValue("repeatSeconds", 1).withValue("cronExpression", null).withValue("cronTimeZoneId", null)));
assertNoErrors.accept(tryToUpdate.apply(r -> r.withValue("repeatSeconds", 2)));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPostUpdateActionReSchedulesJob() throws QException, SchedulerException
{
List<QuartzJobAndTriggerWrapper> wrappers = QuartzTestUtils.queryQuartz();
assertEquals(0, wrappers.size());
//////////////////////////////////////////////////
// do an insert - this will originally schedule //
//////////////////////////////////////////////////
new InsertAction().execute(new InsertInput(ScheduledJob.TABLE_NAME).withRecordEntity(new ScheduledJob()
.withLabel("Test")
.withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)
.withType(ScheduledJobType.PROCESS.name())
.withIsActive(true)
.withRepeatSeconds(1)
.withJobParameters(List.of(new ScheduledJobParameter().withKey("processName").withValue(TestUtils.PROCESS_NAME_BASEPULL)))));
wrappers = QuartzTestUtils.queryQuartz();
assertEquals(1, wrappers.size());
assertThat(wrappers.get(0).trigger()).isInstanceOf(SimpleTrigger.class);
//////////////////////////////////////
// now do an update, to re-schedule //
//////////////////////////////////////
new UpdateAction().execute(new UpdateInput(ScheduledJob.TABLE_NAME).withRecordEntity(new ScheduledJob()
.withId(1)
.withLabel("Test")
.withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)
.withType(ScheduledJobType.PROCESS.name())
.withIsActive(true)
.withRepeatSeconds(null)
.withCronExpression(GOOD_CRON)
.withCronTimeZoneId("UTC")));
wrappers = QuartzTestUtils.queryQuartz();
assertEquals(1, wrappers.size());
assertThat(wrappers.get(0).trigger()).isInstanceOf(CronTrigger.class);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPostUpdateActionIssuesWarnings() throws QException, SchedulerException
{
List<QuartzJobAndTriggerWrapper> wrappers = QuartzTestUtils.queryQuartz();
assertEquals(0, wrappers.size());
//////////////////////////////////////////////////
// do an insert - this will originally schedule //
//////////////////////////////////////////////////
new InsertAction().execute(new InsertInput(ScheduledJob.TABLE_NAME).withRecordEntity(new ScheduledJob()
.withLabel("Test")
.withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)
.withType(ScheduledJobType.PROCESS.name())
.withIsActive(true)
.withRepeatSeconds(1)
.withJobParameters(List.of(new ScheduledJobParameter().withKey("processName").withValue(TestUtils.PROCESS_NAME_BASEPULL)))));
wrappers = QuartzTestUtils.queryQuartz();
assertEquals(1, wrappers.size());
assertThat(wrappers.get(0).trigger()).isInstanceOf(SimpleTrigger.class);
//////////////////////////////////////
// now do an update, to re-schedule //
//////////////////////////////////////
UpdateOutput updateOutput = new UpdateAction().execute(new UpdateInput(ScheduledJob.TABLE_NAME).withRecordEntity(new ScheduledJob()
.withId(1)
.withLabel("Test")
.withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)
.withType(ScheduledJobType.PROCESS.name())
.withIsActive(true)
.withRepeatSeconds(null)
.withCronExpression(GOOD_CRON)
.withCronTimeZoneId("UTC")
.withJobParameters(List.of(new ScheduledJobParameter().withKey("process").withValue("not")))));
assertTrue(CollectionUtils.nullSafeIsEmpty(updateOutput.getRecords().get(0).getErrors()));
assertThat(updateOutput.getRecords().get(0).getWarnings())
.hasSize(1).first().extracting("message").asString()
.contains("Missing scheduledJobParameter with key [processName]");
wrappers = QuartzTestUtils.queryQuartz();
assertEquals(0, wrappers.size());
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testPostDeleteUnschedules() throws QException, SchedulerException
{
List<QuartzJobAndTriggerWrapper> wrappers = QuartzTestUtils.queryQuartz();
assertEquals(0, wrappers.size());
//////////////////////////////////////////////////
// do an insert - this will originally schedule //
//////////////////////////////////////////////////
new InsertAction().execute(new InsertInput(ScheduledJob.TABLE_NAME).withRecordEntity(new ScheduledJob()
.withLabel("Test")
.withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)
.withType(ScheduledJobType.PROCESS.name())
.withIsActive(true)
.withRepeatSeconds(1)
.withJobParameters(List.of(new ScheduledJobParameter().withKey("processName").withValue(TestUtils.PROCESS_NAME_BASEPULL)))));
wrappers = QuartzTestUtils.queryQuartz();
assertEquals(1, wrappers.size());
////////////////////////////////////
// now do a delete, to unschedule //
////////////////////////////////////
new DeleteAction().execute(new DeleteInput(ScheduledJob.TABLE_NAME).withPrimaryKeys(List.of(1)));
wrappers = QuartzTestUtils.queryQuartz();
assertEquals(0, wrappers.size());
}
}

View File

@ -92,7 +92,7 @@ class QScheduleManagerTest extends BaseTest
.withId(1) .withId(1)
.withIsActive(true) .withIsActive(true)
.withSchedulerName(TestUtils.SIMPLE_SCHEDULER_NAME) .withSchedulerName(TestUtils.SIMPLE_SCHEDULER_NAME)
.withType(type.getId()) .withType(type.name())
.withRepeatSeconds(1) .withRepeatSeconds(1)
.withJobParameters(new ArrayList<>()); .withJobParameters(new ArrayList<>());

View File

@ -62,27 +62,7 @@ class QuartzSchedulerTest extends BaseTest
@AfterEach @AfterEach
void afterEach() void afterEach()
{ {
try QuartzTestUtils.afterEach();
{
QScheduleManager.getInstance().unInit();
}
catch(IllegalStateException ise)
{
/////////////////////////////////////////////////////////////////
// ok, might just mean that this test didn't init the instance //
/////////////////////////////////////////////////////////////////
}
try
{
QuartzScheduler.getInstance().unInit();
}
catch(IllegalStateException ise)
{
/////////////////////////////////////////////////////////////////
// ok, might just mean that this test didn't init the instance //
/////////////////////////////////////////////////////////////////
}
} }
@ -120,7 +100,7 @@ class QuartzSchedulerTest extends BaseTest
////////////////////////////////////////////////// //////////////////////////////////////////////////
// give a moment for the job to run a few times // // give a moment for the job to run a few times //
////////////////////////////////////////////////// //////////////////////////////////////////////////
SleepUtils.sleep(50, TimeUnit.MILLISECONDS); SleepUtils.sleep(150, TimeUnit.MILLISECONDS);
qScheduleManager.stopAsync(); qScheduleManager.stopAsync();
System.out.println("Ran: " + BasicStep.counter + " times"); System.out.println("Ran: " + BasicStep.counter + " times");
@ -156,7 +136,6 @@ class QuartzSchedulerTest extends BaseTest
void testRemovingNoLongerNeededJobsDuringSetupSchedules() throws SchedulerException void testRemovingNoLongerNeededJobsDuringSetupSchedules() throws SchedulerException
{ {
QInstance qInstance = QContext.getQInstance(); QInstance qInstance = QContext.getQInstance();
QScheduleManager.defineDefaultSchedulableTypesInInstance(qInstance);
QuartzTestUtils.setupInstanceForQuartzTests(); QuartzTestUtils.setupInstanceForQuartzTests();
//////////////////////////// ////////////////////////////
@ -167,7 +146,7 @@ class QuartzSchedulerTest extends BaseTest
qInstance.addProcess(test1); qInstance.addProcess(test1);
qInstance.addProcess(test2); qInstance.addProcess(test2);
SchedulableType schedulableType = qInstance.getSchedulableType(ScheduledJobType.PROCESS.getId()); SchedulableType schedulableType = qInstance.getSchedulableType(ScheduledJobType.PROCESS.name());
QuartzScheduler quartzScheduler = QuartzScheduler.initInstance(qInstance, QuartzTestUtils.QUARTZ_SCHEDULER_NAME, QuartzTestUtils.getQuartzProperties(), () -> QContext.getQSession()); QuartzScheduler quartzScheduler = QuartzScheduler.initInstance(qInstance, QuartzTestUtils.QUARTZ_SCHEDULER_NAME, QuartzTestUtils.getQuartzProperties(), () -> QContext.getQSession());
quartzScheduler.start(); quartzScheduler.start();

View File

@ -27,6 +27,7 @@ import java.util.Properties;
import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.quartz.QuartzSchedulerMetaData; import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.quartz.QuartzSchedulerMetaData;
import com.kingsrook.qqq.backend.core.scheduler.QScheduleManager;
import org.quartz.SchedulerException; import org.quartz.SchedulerException;
@ -102,4 +103,35 @@ public class QuartzTestUtils
{ {
return QuartzScheduler.getInstance().queryQuartz(); return QuartzScheduler.getInstance().queryQuartz();
} }
/*******************************************************************************
**
*******************************************************************************/
public static void afterEach()
{
try
{
QScheduleManager.getInstance().stop();
QScheduleManager.getInstance().unInit();
}
catch(IllegalStateException ise)
{
/////////////////////////////////////////////////////////////////
// ok, might just mean that this test didn't init the instance //
/////////////////////////////////////////////////////////////////
}
try
{
QuartzScheduler.getInstance().unInit();
}
catch(IllegalStateException ise)
{
/////////////////////////////////////////////////////////////////
// ok, might just mean that this test didn't init the instance //
/////////////////////////////////////////////////////////////////
}
}
} }

View File

@ -67,7 +67,7 @@ class QuartzJobsProcessTest extends BaseTest
{ {
QInstance qInstance = QContext.getQInstance(); QInstance qInstance = QContext.getQInstance();
qInstance.addTable(new QTableMetaData() qInstance.addTable(new QTableMetaData()
.withName("quartzTriggers") .withName("quartzJobDetails")
.withBackendName(TestUtils.MEMORY_BACKEND_NAME) .withBackendName(TestUtils.MEMORY_BACKEND_NAME)
.withPrimaryKeyField("id") .withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.LONG))); .withField(new QFieldMetaData("id", QFieldType.LONG)));
@ -92,28 +92,7 @@ class QuartzJobsProcessTest extends BaseTest
@AfterEach @AfterEach
void afterEach() void afterEach()
{ {
try QuartzTestUtils.afterEach();
{
QScheduleManager.getInstance().stop();
QScheduleManager.getInstance().unInit();
}
catch(IllegalStateException ise)
{
/////////////////////////////////////////////////////////////////
// ok, might just mean that this test didn't init the instance //
/////////////////////////////////////////////////////////////////
}
try
{
QuartzScheduler.getInstance().unInit();
}
catch(IllegalStateException ise)
{
/////////////////////////////////////////////////////////////////
// ok, might just mean that this test didn't init the instance //
/////////////////////////////////////////////////////////////////
}
} }
@ -183,7 +162,7 @@ class QuartzJobsProcessTest extends BaseTest
// pause just one // // pause just one //
//////////////////// ////////////////////
List<QuartzJobAndTriggerWrapper> quartzJobAndTriggerWrappers = QuartzTestUtils.queryQuartz(); List<QuartzJobAndTriggerWrapper> quartzJobAndTriggerWrappers = QuartzTestUtils.queryQuartz();
new InsertAction().execute(new InsertInput("quartzTriggers").withRecord(new QRecord() new InsertAction().execute(new InsertInput("quartzJobDetails").withRecord(new QRecord()
.withValue("jobName", quartzJobAndTriggerWrappers.get(0).jobDetail().getKey().getName()) .withValue("jobName", quartzJobAndTriggerWrappers.get(0).jobDetail().getKey().getName())
.withValue("jobGroup", quartzJobAndTriggerWrappers.get(0).jobDetail().getKey().getGroup()) .withValue("jobGroup", quartzJobAndTriggerWrappers.get(0).jobDetail().getKey().getGroup())
)); ));
@ -231,7 +210,7 @@ class QuartzJobsProcessTest extends BaseTest
// pause just one // // pause just one //
//////////////////// ////////////////////
List<QuartzJobAndTriggerWrapper> quartzJobAndTriggerWrappers = QuartzTestUtils.queryQuartz(); List<QuartzJobAndTriggerWrapper> quartzJobAndTriggerWrappers = QuartzTestUtils.queryQuartz();
new InsertAction().execute(new InsertInput("quartzTriggers").withRecord(new QRecord() new InsertAction().execute(new InsertInput("quartzJobDetails").withRecord(new QRecord()
.withValue("jobName", quartzJobAndTriggerWrappers.get(0).jobDetail().getKey().getName()) .withValue("jobName", quartzJobAndTriggerWrappers.get(0).jobDetail().getKey().getName())
.withValue("jobGroup", quartzJobAndTriggerWrappers.get(0).jobDetail().getKey().getGroup()) .withValue("jobGroup", quartzJobAndTriggerWrappers.get(0).jobDetail().getKey().getGroup())
)); ));

View File

@ -1403,7 +1403,7 @@ public class GenerateOpenApiSpecAction extends AbstractQActionFunction<GenerateO
{ {
String associatedTableName = association.getAssociatedTableName(); String associatedTableName = association.getAssociatedTableName();
QTableMetaData associatedTable = QContext.getQInstance().getTable(associatedTableName); QTableMetaData associatedTable = QContext.getQInstance().getTable(associatedTableName);
ApiTableMetaData associatedApiTableMetaData = ObjectUtils.tryElse(() -> ApiTableMetaDataContainer.of(associatedTable).getApiTableMetaData(apiName), new ApiTableMetaData()); ApiTableMetaData associatedApiTableMetaData = ObjectUtils.tryAndRequireNonNullElse(() -> ApiTableMetaDataContainer.of(associatedTable).getApiTableMetaData(apiName), new ApiTableMetaData());
String associatedTableApiName = StringUtils.hasContent(associatedApiTableMetaData.getApiTableName()) ? associatedApiTableMetaData.getApiTableName() : associatedTableName; String associatedTableApiName = StringUtils.hasContent(associatedApiTableMetaData.getApiTableName()) ? associatedApiTableMetaData.getApiTableName() : associatedTableName;
ApiAssociationMetaData apiAssociationMetaData = thisApiTableMetaData.getApiAssociationMetaData().get(association.getName()); ApiAssociationMetaData apiAssociationMetaData = thisApiTableMetaData.getApiAssociationMetaData().get(association.getName());

View File

@ -710,10 +710,15 @@ public class QJavalinImplementation
{ {
throw (new QUserFacingException("Error updating " + tableMetaData.getLabel() + ": " + joinErrorsWithCommasAndAnd(outputRecord.getErrors()))); throw (new QUserFacingException("Error updating " + tableMetaData.getLabel() + ": " + joinErrorsWithCommasAndAnd(outputRecord.getErrors())));
} }
if(CollectionUtils.nullSafeHasContents(outputRecord.getWarnings()))
{ ////////////////////////////////////////////////////////////////////////////////////////////////////////
throw (new QUserFacingException("Warning updating " + tableMetaData.getLabel() + ": " + joinErrorsWithCommasAndAnd(outputRecord.getWarnings()))); // at one time, we threw upon warning - but //
} // on insert we need to return the record (e.g., to get a generated id), so, make update do the same. //
////////////////////////////////////////////////////////////////////////////////////////////////////////
// if(CollectionUtils.nullSafeHasContents(outputRecord.getWarnings()))
// {
// throw (new QUserFacingException("Warning updating " + tableMetaData.getLabel() + ": " + joinErrorsWithCommasAndAnd(outputRecord.getWarnings())));
// }
QJavalinAccessLogger.logEndSuccess(); QJavalinAccessLogger.logEndSuccess();
context.result(JsonUtils.toJson(updateOutput)); context.result(JsonUtils.toJson(updateOutput));
@ -902,10 +907,16 @@ public class QJavalinImplementation
{ {
throw (new QUserFacingException("Error inserting " + table.getLabel() + ": " + joinErrorsWithCommasAndAnd(outputRecord.getErrors()))); throw (new QUserFacingException("Error inserting " + table.getLabel() + ": " + joinErrorsWithCommasAndAnd(outputRecord.getErrors())));
} }
if(CollectionUtils.nullSafeHasContents(outputRecord.getWarnings()))
{ ///////////////////////////////////////////////////////////////////////////////////////////////////////////
throw (new QUserFacingException("Warning inserting " + table.getLabel() + ": " + joinErrorsWithCommasAndAnd(outputRecord.getWarnings()))); // at one time, we threw upon warning - but //
} // our use-case is, the frontend, it wants to get the record, and show a success (with the generated id) //
// and then to also show a warning message - so - let it all be returned and handled on the frontend. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////
// if(CollectionUtils.nullSafeHasContents(outputRecord.getWarnings()))
// {
// throw (new QUserFacingException("Warning inserting " + table.getLabel() + ": " + joinErrorsWithCommasAndAnd(outputRecord.getWarnings())));
// }
QJavalinAccessLogger.logEndSuccess(logPair("primaryKey", () -> (outputRecord.getValue(table.getPrimaryKeyField())))); QJavalinAccessLogger.logEndSuccess(logPair("primaryKey", () -> (outputRecord.getValue(table.getPrimaryKeyField()))));
context.result(JsonUtils.toJson(insertOutput)); context.result(JsonUtils.toJson(insertOutput));

View File

@ -514,6 +514,42 @@ class QJavalinImplementationTest extends QJavalinTestBase
/*******************************************************************************
** test an insert that returns a warning
**
*******************************************************************************/
@Test
public void test_dataInsertWithWarning()
{
Map<String, Serializable> body = new HashMap<>();
body.put("firstName", "Warning");
body.put("lastName", "Kelkhoff");
body.put("email", "warning@kelkhoff.com");
HttpResponse<String> response = Unirest.post(BASE_URL + "/data/person")
.header("Content-Type", "application/json")
.body(body)
.asString();
assertEquals(200, response.getStatus());
JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
assertTrue(jsonObject.has("records"));
JSONArray records = jsonObject.getJSONArray("records");
assertEquals(1, records.length());
JSONObject record0 = records.getJSONObject(0);
assertTrue(record0.has("values"));
JSONObject values0 = record0.getJSONObject("values");
assertTrue(values0.has("id"));
assertEquals(7, values0.getInt("id"));
assertTrue(record0.has("warnings"));
JSONArray warnings = record0.getJSONArray("warnings");
assertEquals(1, warnings.length());
assertTrue(warnings.getJSONObject(0).has("message"));
}
/******************************************************************************* /*******************************************************************************
** test an insert - posting a multipart form. ** test an insert - posting a multipart form.
** **
@ -594,6 +630,52 @@ class QJavalinImplementationTest extends QJavalinTestBase
/*******************************************************************************
** test an update - with a warning returned
**
*******************************************************************************/
@Test
public void test_dataUpdateWithWarning()
{
Map<String, Serializable> body = new HashMap<>();
body.put("firstName", "Warning");
body.put("birthDate", "");
HttpResponse<String> response = Unirest.patch(BASE_URL + "/data/person/4")
.header("Content-Type", "application/json")
.body(body)
.asString();
assertEquals(200, response.getStatus());
JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody());
assertTrue(jsonObject.has("records"));
JSONArray records = jsonObject.getJSONArray("records");
assertEquals(1, records.length());
JSONObject record0 = records.getJSONObject(0);
assertTrue(record0.has("values"));
assertEquals("person", record0.getString("tableName"));
JSONObject values0 = record0.getJSONObject("values");
assertEquals(4, values0.getInt("id"));
assertEquals("Warning", values0.getString("firstName"));
assertTrue(record0.has("warnings"));
JSONArray warnings = record0.getJSONArray("warnings");
assertEquals(1, warnings.length());
assertTrue(warnings.getJSONObject(0).has("message"));
///////////////////////////////////////////////////////////////////
// re-GET the record, and validate that birthDate was nulled out //
///////////////////////////////////////////////////////////////////
response = Unirest.get(BASE_URL + "/data/person/4").asString();
assertEquals(200, response.getStatus());
jsonObject = JsonUtils.toJSONObject(response.getBody());
assertTrue(jsonObject.has("values"));
JSONObject values = jsonObject.getJSONObject("values");
assertFalse(values.has("birthDate"));
}
/******************************************************************************* /*******************************************************************************
** test an update - posting the data as a multipart form ** test an update - posting the data as a multipart form
** **

View File

@ -25,6 +25,10 @@ package com.kingsrook.qqq.backend.javalin;
import java.io.InputStream; import java.io.InputStream;
import java.sql.Connection; import java.sql.Connection;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QException;
@ -35,6 +39,7 @@ 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.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; 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.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
@ -68,6 +73,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.AssociatedScript;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.savedviews.SavedViewsMetaDataProvider; import com.kingsrook.qqq.backend.core.model.savedviews.SavedViewsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.scripts.ScriptsMetaDataProvider; import com.kingsrook.qqq.backend.core.model.scripts.ScriptsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage;
import com.kingsrook.qqq.backend.core.processes.implementations.mock.MockBackendStep; import com.kingsrook.qqq.backend.core.processes.implementations.mock.MockBackendStep;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager; import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager;
@ -255,6 +261,9 @@ public class TestUtils
.withScriptTypeId(1) .withScriptTypeId(1)
.withScriptTester(new QCodeReference(TestScriptAction.class))); .withScriptTester(new QCodeReference(TestScriptAction.class)));
qTableMetaData.withCustomizer(TableCustomizers.POST_INSERT_RECORD, new QCodeReference(PersonTableCustomizer.class));
qTableMetaData.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, new QCodeReference(PersonTableCustomizer.class));
qTableMetaData.getField("photo") qTableMetaData.getField("photo")
.withIsHeavy(true) .withIsHeavy(true)
.withFieldAdornment(new FieldAdornment(AdornmentType.FILE_DOWNLOAD) .withFieldAdornment(new FieldAdornment(AdornmentType.FILE_DOWNLOAD)
@ -265,6 +274,51 @@ public class TestUtils
} }
/*******************************************************************************
**
*******************************************************************************/
public static class PersonTableCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
{
return warnPostInsertOrUpdate(records);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
{
return warnPostInsertOrUpdate(records);
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> warnPostInsertOrUpdate(List<QRecord> records)
{
for(QRecord record : records)
{
if(Objects.requireNonNullElse(record.getValueString("firstName"), "").toLowerCase().contains("warn"))
{
record.addWarning(new QWarningMessage("Warning in firstName."));
}
}
return records;
}
}
/******************************************************************************* /*******************************************************************************
** **