mirror of
https://github.com/Kingsrook/qqq.git
synced 2025-07-20 14:10:44 +00:00
Compare commits
274 Commits
snapshot-f
...
snapshot-f
Author | SHA1 | Date | |
---|---|---|---|
dc9a2f8698 | |||
0641ac41d6 | |||
cf0c905dc6 | |||
3440d45060 | |||
c32d9110ce | |||
b55631a767 | |||
9ab5a8b305 | |||
ff9add437d | |||
a6bf474448 | |||
f409d0bd19 | |||
b608bda83a | |||
8f503945cd | |||
512d73af34 | |||
e8b4368acc | |||
1e7e7cdd64 | |||
a784e59c50 | |||
4e6460c469 | |||
98fc34fb27 | |||
df6bed2453 | |||
0759085431 | |||
9257519462 | |||
e0134450f8 | |||
fc915a6d65 | |||
d7dec3e560 | |||
40e8a85977 | |||
30ee8ce3bf | |||
0d6538593b | |||
7e6a3c528f | |||
02d068dad7 | |||
c9f921c148 | |||
2545d03f20 | |||
92b8211f20 | |||
8afbbfb4da | |||
24b1daa110 | |||
17899c3fdc | |||
605578d661 | |||
f15277f23b | |||
d480027aeb | |||
5963a706b0 | |||
c8c7051628 | |||
7015322bf3 | |||
d6edbfa06b | |||
a10992226a | |||
04103281af | |||
2088c5dab3 | |||
59a4ad7de6 | |||
7b457b4936 | |||
d1e4091eb4 | |||
753c224196 | |||
0130e34112 | |||
5ed21d1fed | |||
c5e381abdb | |||
1bffd4d46e | |||
c093c680c0 | |||
aa69f0e7d7 | |||
2fec4891d3 | |||
60ffac4646 | |||
2a97598309 | |||
1a1ebcbe02 | |||
5f72978528 | |||
3265d6d842 | |||
246984892a | |||
03b93658d5 | |||
b093ff5ece | |||
7181643abb | |||
178b902665 | |||
d6d0e910ec | |||
d09b12ca5b | |||
2c4fc6a0d4 | |||
11d33c6d06 | |||
3684101259 | |||
c054bf5ddd | |||
7155180a76 | |||
dabaafa482 | |||
a4cdbc429d | |||
03f1fc1436 | |||
d551ad71a6 | |||
891c567a8d | |||
3e604f4b6f | |||
5564e94ad7 | |||
58c15e6eaa | |||
f448cff5dd | |||
2e1bf399f9 | |||
621997efd9 | |||
87ef20aff4 | |||
12eb1804ad | |||
51945aa844 | |||
1ee4f67286 | |||
3c2a34291a | |||
949d9cd088 | |||
acc7abf5c4 | |||
5d24780c47 | |||
cf92e20315 | |||
4f0cba88e6 | |||
2b31c7957f | |||
35d6e23157 | |||
9de1f6924a | |||
865c1cae20 | |||
21b2e5ffc0 | |||
7285efe656 | |||
1a23f5f7e9 | |||
5f0fe9ce27 | |||
7edcb61b4d | |||
7b9f659afa | |||
44b75b6437 | |||
b8330bd2f0 | |||
83ba490eb9 | |||
3d9a6fa249 | |||
e47b2c9497 | |||
fdc948f96c | |||
59ca1e3a1c | |||
52d7d0e8ae | |||
d1792dde11 | |||
b0aaf61e99 | |||
66f9d1b500 | |||
dd103d323d | |||
238521aa57 | |||
8056e49e24 | |||
2bdb5911e7 | |||
9680812828 | |||
049f5a6b42 | |||
15c0d6a45a | |||
6919d9d810 | |||
cdf59e8f2b | |||
815f160a94 | |||
0aba833af3 | |||
2522bdcf1b | |||
e27ef7b835 | |||
45899400ad | |||
7d25fc7390 | |||
878f374cb5 | |||
29a54f5293 | |||
a035bbe18f | |||
edf942a01b | |||
ed6319ff53 | |||
55b4e2154c | |||
aabe9e315e | |||
217acc954a | |||
557824c572 | |||
85ac2c10e7 | |||
b506150842 | |||
3b8eef0f9c | |||
c07d9a779a | |||
4609ff8976 | |||
bf11ea5185 | |||
01d96cc8ae | |||
a6d84c66b7 | |||
69015be2f6 | |||
0f16446f69 | |||
9464a308f2 | |||
51f021193d | |||
0090601166 | |||
cf78f1cb94 | |||
0b1b570f28 | |||
73e54dd56f | |||
e936468f29 | |||
73ac3448dd | |||
d54010e89d | |||
dae23c2a04 | |||
6e3ef2254a | |||
b03de8ec0f | |||
f18ddcf188 | |||
a7dfad5b28 | |||
2f83b7ba7d | |||
62560ab82c | |||
718701aaff | |||
b8f9469477 | |||
4bf29807e3 | |||
b28000932b | |||
ee1c20b1c6 | |||
e3ce1cdbc3 | |||
a9999ee8ce | |||
f50e6d1a94 | |||
72735607c6 | |||
bbba43ef80 | |||
efa84d03e6 | |||
8572c5cb7e | |||
871d133a37 | |||
61c9f1fe75 | |||
c77e37d6dc | |||
aef42a4a5e | |||
c0b5d11a09 | |||
8e8d3b5d2b | |||
4ca9c9dcaf | |||
c6a58ac68f | |||
bb69dddb81 | |||
052cb9fab9 | |||
2681d66b32 | |||
612370fc13 | |||
74d66d0fa5 | |||
2a68478405 | |||
459510bba4 | |||
18e1852ce4 | |||
0dd7f5e1d2 | |||
601c66ddff | |||
08e14ac346 | |||
0dd26b8f31 | |||
7e0b7ddbf9 | |||
fb69c60e10 | |||
e7e93a6ab2 | |||
e1ca85c746 | |||
1baade0449 | |||
0eb8356759 | |||
6098e64934 | |||
dfb1e637a3 | |||
ce28ce2e02 | |||
7109cffa27 | |||
6f4d44df10 | |||
84eaa09932 | |||
911978c74b | |||
00a5b72bf3 | |||
a845ead466 | |||
3ebc567299 | |||
494f0242ac | |||
e6e7e3f9a7 | |||
6dc7a8dde9 | |||
dccbed87a7 | |||
7b141abcec | |||
252c92913c | |||
f0150a3543 | |||
16f0a8c3a7 | |||
e5c35e90a6 | |||
4286001b4d | |||
c1ce933d6c | |||
f78d9a11b2 | |||
d0233e839b | |||
e4d7797bbe | |||
f66f2d622a | |||
624a723b54 | |||
b64883f34f | |||
4b1bdebe44 | |||
8d668d12ec | |||
c27a2a986a | |||
fed8cbbb45 | |||
5147a022fa | |||
615ff6fce5 | |||
f5c4c12388 | |||
68911190fa | |||
56a2949911 | |||
96013878bc | |||
f879575b32 | |||
bab3c7b374 | |||
1c69784897 | |||
8822c1bb99 | |||
a5420bff4c | |||
06259041f8 | |||
a00d4f3cbd | |||
8473e11444 | |||
bc3f462d13 | |||
56a2099515 | |||
93dcee9f61 | |||
92b052fe59 | |||
3f431b39b9 | |||
688e221f9a | |||
959f8c8041 | |||
6e1ea5c8f1 | |||
872dec3177 | |||
78d9ec87a2 | |||
01c78534ef | |||
cfab10c8e8 | |||
2da6878e70 | |||
345d8022c1 | |||
0dd97d9dc1 | |||
b1e68017cc | |||
a37a0b489d | |||
940c5ca8de | |||
84093dfde5 | |||
9c7d94f764 | |||
346443996b | |||
2fc513891f | |||
7426aa36a5 | |||
9d8e8a74e2 | |||
455ab69104 | |||
d624a42dac |
@ -1,7 +1,7 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
localstack: localstack/platform@1.0
|
||||
localstack: localstack/platform@2.1
|
||||
|
||||
commands:
|
||||
store_jacoco_site:
|
||||
@ -98,6 +98,31 @@ commands:
|
||||
- ~/.m2
|
||||
key: v1-dependencies-{{ checksum "pom.xml" }}
|
||||
|
||||
install_asciidoctor:
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Install asciidoctor
|
||||
command: |
|
||||
sudo apt-get update
|
||||
sudo apt install -y asciidoctor
|
||||
|
||||
run_asciidoctor:
|
||||
steps:
|
||||
- run:
|
||||
name: Run asciidoctor
|
||||
command: |
|
||||
cd docs
|
||||
asciidoctor -a docinfo=shared index.adoc
|
||||
|
||||
upload_docs_site:
|
||||
steps:
|
||||
- run:
|
||||
name: scp html to justinsgotskinnylegs.com
|
||||
command: |
|
||||
cd docs
|
||||
scp index.html dkelkhoff@45.79.44.221:/mnt/first-volume/dkelkhoff/nginx/html/justinsgotskinnylegs.com/qqq-docs.html
|
||||
|
||||
jobs:
|
||||
mvn_test:
|
||||
executor: localstack/default
|
||||
@ -114,6 +139,13 @@ jobs:
|
||||
- mvn_verify
|
||||
- mvn_jar_deploy
|
||||
|
||||
publish_asciidoc:
|
||||
executor: localstack/default
|
||||
steps:
|
||||
- install_asciidoctor
|
||||
- run_asciidoctor
|
||||
- upload_docs_site
|
||||
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
@ -134,4 +166,7 @@ workflows:
|
||||
only: /dev/
|
||||
tags:
|
||||
only: /(version|snapshot)-.*/
|
||||
|
||||
- publish_asciidoc:
|
||||
filters:
|
||||
branches:
|
||||
only: /dev/
|
||||
|
1
docs/.gitignore
vendored
Normal file
1
docs/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.html
|
@ -1,8 +1,176 @@
|
||||
= Introduction
|
||||
include::variables.adoc[]
|
||||
|
||||
QQQ is ...
|
||||
QQQ is a Low-code Application Framework for Engineers.
|
||||
Its purpose is to provide the basic structural elements of an application - things that every application needs - so that the engineers building an application on top of QQQ don't need to worry about those pieces, and can instead focus on the unique needs of the application that they are building.
|
||||
|
||||
- Framework
|
||||
- Declarative
|
||||
- Easy thing easy; Hard thing possible
|
||||
- Customizable
|
||||
== What makes QQQ special?
|
||||
|
||||
The scope of what QQQ provides is far-reaching, and most likely goes beyond what you may initially be thinking.
|
||||
That is to say, QQQ includes code all the way from the backend of an application, through its middleware layer, and including its frontend.
|
||||
For example, a common set of modules deployed in a QQQ application will provide:
|
||||
|
||||
* Backend RDBMS/Database connectivity and access.
|
||||
* Frontend web UI (e.g., a React application)
|
||||
* A java web server acting as middleware between the frontend web UI and the backend
|
||||
|
||||
That is to say - as an engineer deploying a QQQ application - you do not need to write a single line of code that is concerned with any of those things.
|
||||
|
||||
* You do not need to write code to connect to your database.
|
||||
* You do not write any web UI code.
|
||||
* You do not write any middleware code to tie together the frontend and backend.
|
||||
|
||||
Instead, QQQ includes *all* of these pieces.
|
||||
QQQ knows how to connect to databases (and actually, several other kinds of backend systems - but ignore that for now).
|
||||
Plus it knows how to do most of what an application needs to do with a database (single-record lookups, complex queries, joins, aggregates, bulk inserts, updates, deletes).
|
||||
QQQ also knows how to present the data from a database - in table views, or single-record views, or exports, or reports or widgets.
|
||||
And it knows how to present a powerful ad-hoc query interface to users, and how to show screens where users can create, update, and delete records.
|
||||
It also provides the connective tissue (middleware) between those backend layers where data is stored, and frontend layers were users interact with data.
|
||||
|
||||
== What makes your application special?
|
||||
|
||||
I've said a lot about what does QQQ knows - but let's dig a little deeper.
|
||||
What does QQQ know, and what does it not know?
|
||||
Well - what it doesn't know is, it doesn't know the special or unique aspects of the application that you are building.
|
||||
|
||||
So, what makes your application unique?
|
||||
|
||||
Is your application unique because it needs to have screens where users can search for records in a table?
|
||||
|
||||
No!
|
||||
|
||||
QQQ assumes (as does the author of this document) that all applications (at least of the nature that QQQ supports) need what we call Query Screens.
|
||||
So QQQ gives you a Query Screen for all of your tables - with zero code from you.
|
||||
|
||||
Is your application unique because you want users to be able to view, create, edit, and delete records from tables?
|
||||
|
||||
No!
|
||||
|
||||
QQQ assumes that all applications need these basic https://en.wikipedia.org/wiki/Create,_read,_update_and_delete[CRUD] capabilities.
|
||||
So QQQ provides all of these UI screens - for view, create, edit, delete - along with the supporting middleware and backend code - all the way down to the SQL that selects & manages the data.
|
||||
You get it all for free - zero code.
|
||||
|
||||
Is your application unique because you have a `fiz_bar` table, with 47 columns, and a `whoz_zat` table, with 42 columns of its own, that joins to `fiz_bar` on the `zizzy_ziz_id` field?
|
||||
|
||||
Yes!
|
||||
|
||||
OK - we found some of it - what makes your application unique is the data that you're working with.
|
||||
Your tables - their fields - the connection info for your database.
|
||||
QQQ doesn't know those details.
|
||||
So - that's your first job as a QQQ application engineer - to describe your data to QQQ.
|
||||
|
||||
For the example above - you need to tell QQQ that you have a `fiz_bar` table, and that you have a `whoz_zat` table - and you need to tell QQQ what the fields or columns in thsoe tables are.
|
||||
You can even tell QQQ how to join those tables (on that `zizzy_ziz_id` field).
|
||||
And then that's it.
|
||||
Once QQQ has this {link-table} meta data, it can then provide its Query screens, and full CRUD screens to your tables, in your database, with your fields.
|
||||
|
||||
And at the risk of repeating myself - you can do this (get a full Query & CRUD application) with zero lines of actual procedural code.
|
||||
You only need to supply meta-data (which, at the time of this writing, is done in Java, but it's just creating objects - and in the future could be done in YAML files, for example).
|
||||
|
||||
== Beyond Basics
|
||||
Going beyond the basic wiring as described above, QQQ also provides some of the more advanced elements needed in a modern data-driven web application, including:
|
||||
|
||||
* Authentication & Authorization
|
||||
* ad-hoc Query engine for access to data tables
|
||||
* Full CRUD (Create, Read, Update, Delete) capabilities
|
||||
* Multistep custom workflows ("Processes" in QQQ parlance)
|
||||
* Scheduled jobs
|
||||
* Enterprise Service Bus
|
||||
|
||||
So what do we mean by all of this?
|
||||
We said that basically every application needs, for example, Authentication & Authorization.
|
||||
Login screens.
|
||||
User & Role tables.
|
||||
Permissions.
|
||||
So, when it's time for you to build a new application for your _Big Tall Floor Lamp_ manufacturing business, do you need to start by writing a login screen?
|
||||
And a Permissions scheme?
|
||||
And throwing HTTP 401 errors?
|
||||
And managing user-role relationships?
|
||||
And then having a bug in the check permission logic on the _Light Bulb Inventory Edit_ screen, so Jim is always keying in bad quantities, even though he isn't supposed to have permission there?
|
||||
|
||||
No!
|
||||
|
||||
All of the (really important, even though application developers hate doing it) aspects of security - you don't need to write ANY code for dealing with that.
|
||||
Just tell QQQ what Authentication provider you want to use (e.g., https://auth0.com/[Auth0]), and - to paraphrase the old https://www.youtube.com/watch?v=YHzM4avGrKI[iMac ad] - there's no step 2.
|
||||
QQQ just does it.
|
||||
|
||||
''''
|
||||
|
||||
QQQ can provide this type of application using a variety and/or combination of backend data storage types.
|
||||
And, whichever type of backend is used, QQQ gives a common interface (both user-facing and programmer-facing).
|
||||
Backend types include:
|
||||
|
||||
* Relational Databases (RDBMS)
|
||||
* File Systems
|
||||
* Web APIs
|
||||
* NoSQL/Document Databases (_Future_)
|
||||
|
||||
In addition, out-of-the-box, QQQ also goes beyond these basics, delivering:
|
||||
|
||||
* Bulk versions of all CRUD operations.
|
||||
* Automatically generated JSON APIs.
|
||||
* Auditing of data changes.
|
||||
* End-user (e.g., non-engineer) customization via dynamic scripting capabilities
|
||||
|
||||
#todo say much more#
|
||||
|
||||
== QQQ Architecture
|
||||
|
||||
Like a house!
|
||||
|
||||
== Developing a QQQ Application
|
||||
In developing an application with QQQ, engineers will generally have to define two types of code:
|
||||
|
||||
. *Meta Data* - This is the code that you use to tell QQQ the shape of your application - your unique Tables, Processes, Apps, Reports, Widgets, etc.
|
||||
In general, this code is 100% declarative in nature (as opposed to procedural or functional).
|
||||
That is to say - it has no logic.
|
||||
It is just a definition of what exists - but it has no instructions, algorithms, or business logic.
|
||||
|
||||
* _In a future version of QQQ, we anticipate being able to define meta-data in a format such as YAML or JSON, or to even load it from a relational or document database.
|
||||
This speaks to the fact that this "code" is not executable code - but rather is a simple declaration of (meta) data._
|
||||
* A key function of QQQ then is to drive all of its layers of functionality - frontend UIs, middleware, and core backend actions (e.g., ORM operations) - based on this meta-data.
|
||||
** For example:
|
||||
... You define the meta-data for a table in your application - including its fields and their data types, as well as what backend system the table exists within.
|
||||
... Then, the QQQ Frontend Material Dashboard UI's Query Screen loads that table's meta-data, and uses it to control the screen that is presented. Including:
|
||||
**** The data grid shown on the screen will have columns for each field in the table.
|
||||
**** The Filter button in the Query Screen will present a menu listing all fields from the table for the user to build ad-hoc queries against the table.
|
||||
The data-types specified for the fields (in the meta-data) dictate what operators QQQ allows the user to use against fields (e.g., Strings offer "contains" vs Numbers offer "greater than").
|
||||
**** Values for records from the table will be formatted for presentation based on the meta-data (such as a numeric field being shown with commas if it represents a quantity, or formatted as currency).
|
||||
...
|
||||
|
||||
[start=2]
|
||||
. *Meta Data* - declarative code - java object instances (potentially which could be read from `.yaml` files or other data sources in a future version of QQQ), which tell QQQ about the backend systems, tables, processes, reports, widgets, etc, that make up the application.
|
||||
For example:
|
||||
* Details about the database you are using, and how to connect to it.
|
||||
* A database table's name, fields, their types, its keys, and basic business rules (required fields, read-only fields, field lengths).
|
||||
* The description of web API - its URL and authentication mechanism.
|
||||
* A table/path within a web API, and the fields returned in the JSON at that endpoint.
|
||||
* The specification of a custom workflow (process), including what screens are needed, with input & output values, and references to the custom application code for processing the data.
|
||||
* Details about a chart that summarizes data from a table for presentation as a dashboard widget.
|
||||
// the section below is kinda dumb. like, it says you have to write application code, but
|
||||
// then it just talks about how your app code gets for-free the same shit that QQQ does.
|
||||
// it should instead say more about what your custom app code is or does.
|
||||
// 2. *Application code* - to customize beyond what the framework does out-of-the box, and to provide application-specific business-logic.
|
||||
// QQQ provides its programmers the same classes that it internally uses for record access, resulting in a unified application model.
|
||||
// For example:
|
||||
// * The same record-security model that is enforced for ad-hoc user queries through the frontend is applied to custom application code.
|
||||
// ** So if your table has a security key defined, which says that users can only see Order records that are associated with the user's assigned Store, then QQQ's order Query Screen will enforce that rule.
|
||||
// ** And at the same time - any custom processes ran by a user will have the same security applied to any queries that they run against the Order table.
|
||||
// ** And any custom dashboard widgets - will only include data that the user is allowed to see.
|
||||
// * Record audits are performed in custom code the same as they are in framework-driven actions.
|
||||
// ** So if a custom process edits a record, details of the changed fields show up in the record's audit, the same as if the record was edited using the standard QQQ edit action.
|
||||
// * Changed records are sent through the ESB automatically regardless of whether they are updated by custom application code or standard framework code.
|
||||
// ** Meaning record automations are triggered regardless of how a record is created or edited - without you, as an application engineering, needing to send records through the bus.
|
||||
// * The multi-threaded, paged producer/consumer pattern used in standard framework actions is how all custom application actions are also invoked.
|
||||
// ** For example, the standard QQQ Bulk Edit action uses the same streamed-ETL process that custom application processes can use.
|
||||
// Meaning your custom processes can take full advantage of the same complex frontend, middleware, and backend structural pieces, and you can just focus on your unique busines logic needs.
|
||||
2. *Application code* - to customize beyond what the QQQ framework does out-of-the box, and to provide application-specific business-logic.
|
||||
QQQ provides its programmers the same classes that it internally uses for record access, resulting in a unified application model.
|
||||
For example:
|
||||
|
||||
== Lifecycle?
|
||||
* define meta data
|
||||
** enrichment
|
||||
** validation
|
||||
* start application
|
||||
* for dev - hotSwap
|
||||
|
2104
docs/Reports.pdf
2104
docs/Reports.pdf
File diff suppressed because it is too large
Load Diff
64
docs/actions/GetAction.adoc
Normal file
64
docs/actions/GetAction.adoc
Normal file
@ -0,0 +1,64 @@
|
||||
== GetAction
|
||||
include::../variables.adoc[]
|
||||
|
||||
The `*GetAction*` is essentially a subset of the <<QueryAction>>, only specifically meant to get just a single record from a {link-table}.
|
||||
In SQL/RDBMS terms, it is analogous to a `SELECT` statement, where a single record may be found - or - it may not be found.
|
||||
|
||||
For all tables, `GetAction` can do a lookup by primary key.
|
||||
In addition, for tables that define a `UniqueKey`, name/value pairs (in the form of a `Map`) can be used as input for `GetAction`.
|
||||
|
||||
=== Examples
|
||||
[source,java]
|
||||
.Basic form - by primary key
|
||||
----
|
||||
GetInput input = new GetInput();
|
||||
input.setTableName("orders");
|
||||
input.setPrimaryKey(1);
|
||||
GetOutput output = new GetAction.execute(input);
|
||||
QRecord record = output.getRecord();
|
||||
----
|
||||
|
||||
[source,java]
|
||||
.Secondary form - by Unique Key
|
||||
----
|
||||
GetInput input = new GetInput();
|
||||
input.setTableName("products");
|
||||
input.setUniqueKey(Map.of("storeId", 1701, "sku", "ABCD"));
|
||||
GetOutput output = new GetAction.execute(input);
|
||||
QRecord record = output.getRecord();
|
||||
----
|
||||
|
||||
=== GetInput
|
||||
* `table` - *String, Required* - Name of the table being queried against.
|
||||
* `primaryKey` - *Serializable, Conditional* - Value for the primary key field of the table being queried.
|
||||
Type should match the table's primary key field's type.
|
||||
If a `primaryKey` is not given, then a `uniqueKey` must be given.
|
||||
* `uniqueKey` - *Map of String -> Serializable, Conditional* - Map of name-value pairs that define the record to be fetcheed.
|
||||
Keys in the map must be field names from the table being queried.
|
||||
Values in the map should should be of types that correspond to the fields.
|
||||
If a `primaryKey` is not given, then a `uniqueKey` must be given.
|
||||
If both `primaryKey` and `uniqueKey` are given, then `uniqueKey` is ignored.
|
||||
* `transaction` - *QBackendTransaction object* - Optional transaction object.
|
||||
** Behavior for this object is backend-dependant.
|
||||
In an RDBMS backend, this object is generally needed if you want your query to see data that may have been modified within the same transaction.
|
||||
|
||||
* `shouldTranslatePossibleValues` - *boolean, default: false* - Controls whether any fields in the table with a *possibleValueSource* assigned to them should have those possible values looked up
|
||||
(e.g., to provide text translations in the generated records' `displayValues` map).
|
||||
** For example, if getting a record to present to a user, this would generally need to be *true*.
|
||||
But if getting a record as part of a process, then this can generally be left as *false*.
|
||||
* `shouldGenerateDisplayValues` - *boolean, default: false* - Controls whether field level *displayFormats* should be used to populate the generated records' `displayValues` map.
|
||||
** For example, if getting a record to present to a user, this would generally need to be *true*.
|
||||
But if getting a record as part of a process, then this can generally be left as *false*.
|
||||
* `shouldFetchHeavyFields` - *boolean, default: true* - Controls whether or not fields marked as `isHeavy` should be fetched & returned or not.
|
||||
* `shouldOmitHiddenFields` - *boolean, default: true* - Controls whether or not fields marked as `isHidden` should be included in the result or not.
|
||||
* `shouldMaskPassword` - *boolean, default: true* - Controls whether or not fields with `type` = `PASSWORD` should be masked, or if their actual values should be returned.
|
||||
* `queryJoins` - *List of <<QueryJoin>> objects* - Optional list of tables to be joined with the main table being queried.
|
||||
See QueryJoin under <<QueryAction>> for further details.
|
||||
* `includeAssociations` - *boolean, default: false* - Control whether or not records from tables defined as `associations` under the table being queried should be included in the result or not.
|
||||
* `associationNamesToInclude` - *Collection of String* - If `includeAssociations` is true, then this field can be used to limit which associated tables are included.
|
||||
If this field is null, then all associated tables are included.
|
||||
Otherwise, a table is only included if its name is in this collection.
|
||||
|
||||
=== GetOutput
|
||||
* `record` - *QRecord* - The record that was specified by the input `primaryKey` or `uniqueKey`.
|
||||
Will be null if the record was not found.
|
86
docs/actions/InsertAction.adoc
Normal file
86
docs/actions/InsertAction.adoc
Normal file
@ -0,0 +1,86 @@
|
||||
== InsertAction
|
||||
include::../variables.adoc[]
|
||||
|
||||
To insert (add, create) new records into any {link-table}, the `*InsertAction*` is used.
|
||||
In SQL/RDBMS terms, it is analogous to a `INSERT` statement, where one or more records can be provided as input.
|
||||
|
||||
=== Examples
|
||||
[source,java]
|
||||
.Canonical InsertAction invocation
|
||||
----
|
||||
InsertInput insertInput = new InsertInput();
|
||||
insertInput.setTableName("person");
|
||||
insertInput.setRecords(personRecordList);
|
||||
InsertOutput insertOutput = new InsertAction().execute(insertInput);
|
||||
List<QRecord> insertedPersonRecords = insertOutput.getRecords();
|
||||
----
|
||||
|
||||
=== Details
|
||||
`InsertAction` does several things beyond just inserting records into the specified table.
|
||||
A high-level flow of its internal logic is:
|
||||
|
||||
. For tables using an automation status field, set its value to `PENDING_INSERT_AUTOMATIONS` for all `records` that are going to be inserted.
|
||||
. Perform the following validations, which include running the table's `PRE_INSERT_CUSTOMIZER`, if one is defined, at the time that is specified by the customizer's `WhenToRun` property (default is `AFTER_ALL_VALIDATIONS`):
|
||||
.. Ensure that default values specified in the table's fields are present if needed.
|
||||
.. Apply per-field behaviors, as defined in {link-field} meta-data, such as truncating strings that are longer than their specified max.
|
||||
.. Check for unique key violations (done here instead of in the backend, to provide better error messaging, and to allow a subset of records to be stored while some fail).
|
||||
_We might want to make an input control in the future to specify that either the full input set should succeed or fail..._
|
||||
.. Validate that required fields (again, per {link-field} meta-data) are set, generating per-record errors if they are not.
|
||||
.. Validate any security fields in the records - e.g., ensure that the user has permission to insert records with the values they are attempting to insert.
|
||||
. Send the records to the table's backend module to actually insert them into the backend storage.
|
||||
. If the table has any associations defined, and if associated records are present, then recursively run `InsertAction` on the associated records.
|
||||
.. In particular, before these recursive `InsertAction` calls are made, values that were generated by the original insert may need to be propagated down into the associated records.
|
||||
*** For example, if inserting `order` and `lineItem` records, where a {link-join} exists between the two tables on `order.id` and `lineItem.orderId`, and `order.id` values were generated in the first `InsertAction`, then those values are propagated down into the associated `lineItem.orderId` fields.
|
||||
. If the {link-instance} has an `audit` table, then based on the {link-table}'s audit rules, audits about the inserted records are created.
|
||||
. If the table has a `POST_INSERT_CUSTOMIZER`, it is executed.
|
||||
|
||||
=== Overloads
|
||||
|
||||
`InsertAction` can be called in a few alternate forms, mostly just for convenience:
|
||||
|
||||
[source,java]
|
||||
.If inserting a single record, get that record back instead of the InsertOutput:
|
||||
----
|
||||
InsertInput insertInput = new InsertInput();
|
||||
insertInput.setTableName("person");
|
||||
insertInput.setRecords(List.of(personRecord));
|
||||
QRecord insertedRecord = new InsertAction().executeForRecord(insertInput);
|
||||
|
||||
// or more compactly, using InsertInput.withRecord (instead of withRecords)
|
||||
QRecord insertedRecord = new InsertAction()
|
||||
.executeForRecord(new InsertInput("person").withRecord(personRecord));
|
||||
----
|
||||
|
||||
[source,java]
|
||||
.Taking QRecordEntity objects as inputs instead of QRecords:
|
||||
----
|
||||
// insert a list of person entities:
|
||||
InsertInput insertInput = new InsertInput("person").withRecordEntities(personList);
|
||||
InsertOutput insertOutput = new InsertAction().execute(insertInput);
|
||||
|
||||
// or for a single person entity (also mapping the output record back to an entity):
|
||||
Person insertedPerson = new Person(new InsertAction()
|
||||
.executeForRecord(new InsertInput("person").withRecordEntity(person)));
|
||||
|
||||
----
|
||||
|
||||
=== InsertInput
|
||||
* `table` - *String, Required* - Name of the table that records are to be inserted into.
|
||||
* `records` - *List of QRecord, Required* - List of records to be inserted into the table.
|
||||
If the list is empty, the insert action does a `noop`.
|
||||
|
||||
.Less common options
|
||||
* `inputSource` - *InputSource object, default: QInputSource.SYSTEM* - an indicator of what the source of the action is - generally, a `SYSTEM` triggered action, or a `USER` triggered action.
|
||||
** `InsertAction` will call the `shouldValidateRequiredFields()` method on this object to determine if it should validate that required fields on the records have values.
|
||||
Both `QInputSource.SYSTEM` and `QInputSource.USER` return `true` from this method, but application can define their own `InputSource` objects with different behavior.
|
||||
** In addition, this field can be used in pre- and post-insert customizers to drive further custom logic.
|
||||
* `skipUniqueKeyCheck` - *boolean, default: false* - control whether or not `InsertAction` should check for unique key violations before attempting to insert its records.
|
||||
** In a context where one has previously done this validation, or is okay letting the backend provide such checks, they may wish to avoid re-doing this work, and thus may set this property to `true`.
|
||||
* `omitDmlAudit` - *boolean, default: false* - control if the automatic DML audit that `InsertAction` generally performs should be omitted.
|
||||
* `auditContext` - *String* - optional message which can be included in DML audit messages, to give users more context about why the insert occurred.
|
||||
|
||||
|
||||
=== InsertOutput
|
||||
* `records` - *List of QRecord* - Copy of the input list of records, with details added based on the results of the input action.
|
||||
** If there were warnings or errors, the corresponding field (`warnings` or `errors`) will be set in the records.
|
||||
** If the insert action generated any values (such as a serial id or a default value), those values will be in the record's `fields` map.
|
@ -1,34 +1,45 @@
|
||||
== QueryAction
|
||||
include::../variables.adoc[]
|
||||
|
||||
The `*QueryAction*` is the basic action that is used to get records from a {link-table}.
|
||||
The `*QueryAction*` is the basic action that is used to get records from a {link-table}, generally according to a <<QQueryFilter,Filter>>.
|
||||
In SQL/RDBMS terms, it is analogous to a `SELECT` statement, where 0 or more records may be found and returned.
|
||||
|
||||
=== Examples
|
||||
==== Basic Form
|
||||
[source,java]
|
||||
----
|
||||
QueryInput input = new QueryInput(qInstance);
|
||||
input.setSession(session);
|
||||
QueryInput input = new QueryInput();
|
||||
input.setTableName("orders");
|
||||
input.setFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("total", GREATER_THAN, new BigDecimal("3.50")))
|
||||
.withOrderBy(new QFilterOrderBy("orderDate", false))
|
||||
);
|
||||
.withOrderBy(new QFilterOrderBy("orderDate", false)));
|
||||
QueryOutput output = new QueryAction.execute(input);
|
||||
List<QRecord> records = output.getRecords();
|
||||
----
|
||||
|
||||
=== Details
|
||||
`QueryAction`, in general, can be called in two different modes:
|
||||
|
||||
. The most common use-case case, and default, fetches all records synchronously, does any post-processing (as requested in the <<QueryInput>>), and returns all records as a list in the <<QueryOutput>>).
|
||||
. The alternative use-case is meant for larger operations, where one wouldn't want all records matching a query in-memory.
|
||||
For this scenario, a `RecordPipe` object can be passed in to the <<QueryInput>>.
|
||||
This causes `QueryAction` to run its post-processing action on records as they are placed into the pipe, and to potentially block (per the pipe's settings).
|
||||
This method of usage needs to be done on a separate thread from another thread which would be consuming records from the pipe.
|
||||
QQQ's `AsyncRecordPipeLoop` class provides an implementation of doing such a dual-threaded job.
|
||||
|
||||
If the {link-table} has a `POST_QUERY_CUSTOMIZER` defined, then after records are fetched from the backend, that code is executed on the records before they leave the `QueryAction` (either through its `QueryOutput` or `RecordPipe`).
|
||||
|
||||
=== QueryInput
|
||||
* `table` - *String, Required* - Name of the table being queried against.
|
||||
* `filter` - *QQueryFilter object* - Specification for what records should be returned, based on *QFilterCriteria* objects, and how they should be sorted, based on *QFilterOrderBy* objects.
|
||||
* `filter` - *<<QQueryFilter>> object* - Specification for what records should be returned, based on *<<QFilterCriteria>>* objects, and how they should be sorted, based on *<<QFilterOrderBy>>* objects.
|
||||
If a `filter` is not given, then all rows in the table will be returned by the query.
|
||||
* `skip` - *Integer* - Optional number of records to be skipped at the beginning of the result set.
|
||||
e.g., for implementing pagination.
|
||||
* `limit` - *Integer* - Optional maximum number of records to be returned by the query.
|
||||
* `transaction` - *QBackendTransaction object* - Optional transaction object.
|
||||
** Behavior for this object is backend-dependant.
|
||||
In an RDBMS backend, this object is generally needed if you want your query to see data that may have been modified within the same transaction.
|
||||
* `recordPipe` - *RecordPipe object* - Optional object that records are placed into, for asynchronous processing.
|
||||
* `recordPipe` - *RecordPipe object* - Optional pipe object that records are placed into, for asynchronous processing.
|
||||
** If a *recordPipe* is used, then records cannot be retrieved from the *QueryOutput*.
|
||||
Rather, such records must be read from the pipe's `consumeAvailableRecords()` method.
|
||||
** A *recordPipe* should only be used when a *QueryAction* is running in a separate Thread from the record's consumer.
|
||||
@ -36,18 +47,21 @@ Rather, such records must be read from the pipe's `consumeAvailableRecords()` me
|
||||
(e.g., to provide text translations in the generated records' `displayValues` map).
|
||||
** For example, if running a query to present results to a user, this would generally need to be *true*.
|
||||
But if running a query to provide data as part of a process, then this can generally be left as *false*.
|
||||
* `shouldGenerateDisplayValues` - *boolean, default: false* - Controls whether if field level *displayFormats* should be used to populate the generated records' `displayValues` map.
|
||||
* `shouldGenerateDisplayValues` - *boolean, default: false* - Controls whether field level *displayFormats* should be used to populate the generated records' `displayValues` map.
|
||||
** For example, if running a query to present results to a user, this would generally need to be *true*.
|
||||
But if running a query to provide data as part of a process, then this can generally be left as *false*.
|
||||
* `queryJoins` - *List of QueryJoin objects* - Optional list of tables to be joined with the main *table* specified in the *QueryInput*.
|
||||
* `shouldFetchHeavyFields` - *boolean, default: true* - Controls whether or not fields marked as `isHeavy` should be fetched & returned or not.
|
||||
* `shouldOmitHiddenFields` - *boolean, default: true* - Controls whether or not fields marked as `isHidden` should be included in the result or not.
|
||||
* `shouldMaskPassword` - *boolean, default: true* - Controls whether or not fields with `type` = `PASSWORD` should be masked, or if their actual values should be returned.
|
||||
* `queryJoins` - *List of <<QueryJoin>> objects* - Optional list of tables to be joined with the main table being queried.
|
||||
See QueryJoin below for further details.
|
||||
|
||||
==== QQueryFilter
|
||||
A key component of *QueryInput*, a *QQueryFilter* defines both what records should be included in a query's results (e.g., an SQL `WHERE`), as well as how those results should be sorted (SQL `ORDER BY`).
|
||||
A key component of *<<QueryInput>>*, a *QQueryFilter* defines both what records should be included in a query's results (e.g., an SQL `WHERE`), as well as how those results should be sorted (SQL `ORDER BY`).
|
||||
|
||||
* `criteria` - *List of QFilterCriteria* - Individual conditions or clauses to filter records.
|
||||
* `criteria` - *List of <<QFilterCriteria>>* - Individual conditions or clauses to filter records.
|
||||
They are combined using the *booleanOperator* specified in the *QQueryFilter*. See below for further details.
|
||||
* `orderBys` - *List of QFilterOrderBy* - List of fields (and directions) to control the sorting of query results.
|
||||
* `orderBys` - *List of <<QFilterOrderBy>>* - List of fields (and directions) to control the sorting of query results.
|
||||
In general, multiple *orderBys* can be given (depending on backend implementations).
|
||||
* `booleanOperator` - *Enum of AND, OR, default: AND* - Specifies the logical joining operator used among individual criteria.
|
||||
* `subFilters` - *List of QQueryFilter* - To build arbitrarily complex queries, with nested boolean logic, 0 or more *subFilters* may be provided.
|
||||
@ -69,7 +83,7 @@ In general, multiple *orderBys* can be given (depending on backend implementatio
|
||||
)));
|
||||
|
||||
// which would generate the following WHERE clause in an RDBMS backend:
|
||||
WHERE (first_name='James' AND last_name='Maes') OR (first_name='Darin' AND last_name='Kelkhoff')
|
||||
// WHERE (first_name='James' AND last_name='Maes') OR (first_name='Darin' AND last_name='Kelkhoff')
|
||||
----
|
||||
|
||||
===== QFilterCriteria
|
||||
@ -79,19 +93,19 @@ In general, multiple *orderBys* can be given (depending on backend implementatio
|
||||
* `operator` - *Enum of QCriteriaOperator, required* - Comparison operation to be applied to the field specified as *fieldName* and the *values* or *otherFieldName*.
|
||||
** e.g., `EQUALS`, `NOT_IN`, `GREATER_THAN`, `BETWEEN`, `IS_BLANK`, etc.
|
||||
* `values` - *List of values, conditional* - Provides the value(s) that the field is compared against.
|
||||
The number of values (0, 1, 2, or more) be driven based on the *operator* being used.
|
||||
The number of values (0, 1, 2, or more) required are based on the *operator* being used.
|
||||
If an *otherFieldName* is given, and the *operator* expects 1 value, then *values* is ignored, and *otherFieldName* is used.
|
||||
* `otherFieldName` - *String, conditional* - Specifies that the *fieldName* should be compared against another field in the records, rather than the values in the *values* property.
|
||||
Only used for *operators* that expect 1 value (e.g., `EQUALS` or `LESS_THAN_OR_EQUALS` - not `IS_NOT_BLANK` or `IN`).
|
||||
|
||||
QFilterCriteria definition examples:
|
||||
[source,java]
|
||||
.QFilterCriteria definition examples:
|
||||
----
|
||||
// one-liners, via constructors that take (List<Serializable> values) or (Serializable... values) in 3rd position
|
||||
new QFilterCriteria("id", IN, List.of(1, 2, 3))
|
||||
// in-line, via constructors that take (List<Serializable> values) or (Serializable... values) as 3rd arg
|
||||
new QFilterCriteria("id", IN, 1, 2, 3)
|
||||
new QFilterCriteria("name", IS_BLANK)
|
||||
new QFilterCriteria("orderNo", IN, orderNoList)
|
||||
new QFilterCriteria("state", EQUALS, "MO");
|
||||
new QFilterCriteria("state", EQUALS, "MO")
|
||||
|
||||
// long-form, with fluent setters
|
||||
new QFilterCriteria()
|
||||
@ -105,7 +119,7 @@ new QFilterCriteria()
|
||||
.withOpeartor(QCriteriaOperator.EQUALS)
|
||||
.withOtherFieldName("lastName");
|
||||
|
||||
// using otherFieldName to build a criterion that looks at two fields from join tables
|
||||
// using otherFieldName to build a criterion that looks at two fields from two different join tables
|
||||
new QFilterCriteria()
|
||||
.withFieldName("billToCustomer.lastName")
|
||||
.withOpeartor(QCriteriaOperator.NOT_EQUALS)
|
||||
@ -118,9 +132,9 @@ new QFilterCriteria()
|
||||
** Or, in the case of a query with *queryJoins*, a qualified name of a field from a join-table (where the qualifier would be the joined table's name or alias, followed by a dot)
|
||||
* `isAscending` - *boolean, default: true* - Specify if the sort is ascending or descending.
|
||||
|
||||
QFilterCriteria definition examples:
|
||||
|
||||
[source,java]
|
||||
.QFilterOrderBy definition examples:
|
||||
----
|
||||
// short-form, via constructors
|
||||
new QFilterOrderBy("id") // isAscending defaults to true.
|
||||
@ -129,7 +143,7 @@ new QFilterOrderBy("name", false)
|
||||
// long-form, with fluent setters
|
||||
new QFilterOrderBy()
|
||||
.withFieldName("birthDate")
|
||||
.withIsAscending(true);
|
||||
.withIsAscending(false);
|
||||
----
|
||||
|
||||
==== QueryJoin
|
||||
@ -147,9 +161,8 @@ If given, must be used as the part before the dot in field name specifications t
|
||||
If *true*, then the `QRecord` objects returned by this query will have values with corresponding to the (table-or-alias `.` field-name) form.
|
||||
* `type` - *Enum of INNER, LEFT, RIGHT, FULL, default: INNER* - specifies the SQL-style type of join being performed.
|
||||
|
||||
QueryJoin definition examples:
|
||||
|
||||
[source,java]
|
||||
.QueryJoin definition examples:
|
||||
----
|
||||
// selecting from an "orderLine" table - then join to its corresponding "order" table
|
||||
queryInput.withTableName("orderLine");
|
||||
|
@ -1,27 +1,6 @@
|
||||
<!--
|
||||
~ QQQ - Low-code Application Framework for Engineers.
|
||||
~ Copyright (C) 2021-2022. 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/>.
|
||||
-->
|
||||
|
||||
<style>
|
||||
pre
|
||||
.sect2 + .sect2
|
||||
{
|
||||
white-space: pre;
|
||||
border-top: 1px solid #e7e7e9;
|
||||
}
|
||||
</style>
|
406
docs/implementations/TableSync.adoc
Normal file
406
docs/implementations/TableSync.adoc
Normal file
@ -0,0 +1,406 @@
|
||||
== TableSyncProcess
|
||||
include::../variables.adoc[]
|
||||
|
||||
The `TableSyncProcess` is designed to help solve the common use-case where you have a set of records in one table,
|
||||
you want to apply a transformation to them, and then you want them to result in records in a different table.
|
||||
|
||||
=== Sample Scenario
|
||||
For example, you may be receiving an import file feed from a partner - say, a CSV file of order records
|
||||
- that you need to synchronize into your local database's orders table.
|
||||
|
||||
=== High Level Steps
|
||||
At a high-level, the steps of this task are:
|
||||
|
||||
1. Get the records from the partner's feed.
|
||||
2. Decide Insert/Update - e.g., if you already have any of these orders in your database table, in which case, they need updated, versus ones
|
||||
you don't have, which need inserted.
|
||||
3. Map fields from the partner's order record to your own fields.
|
||||
4. Store the necessary records in your database (insert or update).
|
||||
|
||||
=== What you need to tell QQQ
|
||||
`TableSyncProcess`, as defined by QQQ, knows how to do everything for a job like this, other than the part that's unique to your business.
|
||||
Those unique parts that you need to tell QQQ are:
|
||||
|
||||
* What are the source & destination tables?
|
||||
* What are the criteria to identify source records to be processed?
|
||||
* What are the key fields are that "link" together records from the source & destination tables?
|
||||
** For the example above, imagine you have a "partnerOrderNo" field in your database's order table - then you'd need
|
||||
to tell QQQ what field in the partner's table provides the value for that field in your table.
|
||||
* How do fields / values from the source table map to fields & values in the destination table?
|
||||
|
||||
=== Detailed Steps
|
||||
To get more specific, in a QQQ `TableSyncProcess`, here's what happens for each of the high-level steps given above:
|
||||
|
||||
* Getting records from the partner's feed (done by QQQ):
|
||||
** Records from the source will be fetched via an Extract step, which runs a query to find the records that need processing.
|
||||
** Depending on your use-case, you may use an `ExtractViaQueryStep` (maybe with a Table Automation) or `ExtractViaBasepullQueryStep`
|
||||
(e.g., if you are polling a remote data source).
|
||||
* Deciding Insert/Update (done by QQQ):
|
||||
** Given a set of records from the source table (e.g., output of the Extract step mentioned above), get values from the "key" field in that table.
|
||||
** Do a lookup in the destination table, where its corresponding "key" field has the values extracted from the source records.
|
||||
** For each source record, if its "key" was found in the destination table, then plan an update to that existing corresponding
|
||||
destination record; else, plan to insert a new record in the destination table.
|
||||
* Mapping values (done by your custom Application code):
|
||||
** Specifically, mapping is done in a subclass of `AbstractTableSyncTransformStep`, in the `populateRecordToStore` method.
|
||||
Of particular interest are these two parameters to that method:
|
||||
*** `QRecord destinationRecord` - as determined by the insert/update logic above, this will either be a
|
||||
new empty record (e.g., for inserting), or a fully populated record from the destination table (for updating).
|
||||
*** `QRecord sourceRecord` - this is the record being processed, from the source table.
|
||||
** This method is responsible for setting values in the `destinationRecord`, and returning that record
|
||||
(unless it has decided that for some reason the record should _not_ be stored, in which case it may return `null`).
|
||||
* Storing the records (done by QQQ):
|
||||
** This is typically done with the `LoadViaInsertOrUpdateStep`, though it is customizable if additional work is needed (e.g., via a
|
||||
subclass of `LoadViaInsertOrUpdateStep`, or a more custom subclass of `AbstractLoadStep`).
|
||||
|
||||
=== Bare-bones Example
|
||||
For this example, let's assume we're setting up a partner-order-feed as described above, with the following details:
|
||||
|
||||
* We have records from a partner in a table named `"partnerOrderImport"` (let's assume the records may have been created
|
||||
using the QQQ `FilesystemImporter` process).
|
||||
These records have the following fields:
|
||||
** `orderNo, date, city, state, postal, whseNo`
|
||||
* We need to synchronize those records with table in our database named `"order"`, with the following fields corresponding to those from the partner:
|
||||
** `partnerOrderNo, orderDate, shipToCity, shipToState, shipToZipCode, warehouseId`
|
||||
* The same conceptual order may appear in the `"partnerOrderImport"` multiple times, e.g., if they update some data on the order and re-transmit it to us.
|
||||
Meaning, we need to update our `"order"` records when we receive a new version an existing order.
|
||||
|
||||
To use `*TableSyncProcess*` for solving this use-case, we'll need to create 2 things:
|
||||
|
||||
1. A `QProcessMetaData` object, which we can create using the builder object provided by class `TableSyncProcess`.
|
||||
Note that this type of process is specialization of the standard QQQ `StreamedETLWithFrontendProcess`, as described elsewhere in this documentation.
|
||||
2. A subclass of `AbstractTableSyncTransformStep`, where we implement our mapping logic.
|
||||
Again, note that `AbstractTableSyncTransformStep` is a subclass of `AbstractTransformStep`, as used by `StreamedETLWithFrontendProcess`.
|
||||
|
||||
And to be good programmers, we'll actually create a 3rd thing:
|
||||
|
||||
[start=3]
|
||||
. A unit test for our Transform step.
|
||||
|
||||
Here are examples of these pieces of code:
|
||||
|
||||
[source,java]
|
||||
.Example of building process using the TableSyncProcess builder:
|
||||
----
|
||||
// the false argument below tells the build we are not a basepull-style process
|
||||
QProcessMetaData processMetaData = TableSyncProcess.processMetaDataBuilder(false)
|
||||
|
||||
// give our process a unique name within our QInstance
|
||||
.withName("partnerOrderToLocalOrderProcess")
|
||||
|
||||
// tell the process to what class to use for transforming records from source to destination
|
||||
.withSyncTransformStepClass(PartnerOrderToOrderTransformStep.class)
|
||||
|
||||
.getProcessMetaData();
|
||||
----
|
||||
|
||||
[source,java]
|
||||
.Example implementation of an AbstractTableSyncTransformStep
|
||||
----
|
||||
public class PartnerOrderToOrderTransformStep extends AbstractTableSyncTransformStep
|
||||
{
|
||||
@Override
|
||||
protected SyncProcessConfig getSyncProcessConfig()
|
||||
{
|
||||
return (new SyncProcessConfig(
|
||||
"partnerOrderImport", // source tableName
|
||||
"orderNo", // source table key fieldName
|
||||
"order", // destination tableName
|
||||
"partnerOrderNo" // destination table foreign key fieldName
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QRecord populateRecordToStore(RunBackendStepInput runBackendStepInput, QRecord destinationRecord, QRecord sourceRecord) throws QException
|
||||
{
|
||||
// map simple values from source table to destination table
|
||||
destinationRecord.setValue("orderDate", sourceRecord.get("date"));
|
||||
destinationRecord.setValue("shipToAddressCity", sourceRecord.get("city"));
|
||||
destinationRecord.setValue("shipToAddressState", sourceRecord.get("state"));
|
||||
destinationRecord.setValue("shipToAddressZipCode", sourceRecord.get("postal"));
|
||||
return (destinationRecord);
|
||||
}
|
||||
|
||||
}
|
||||
----
|
||||
|
||||
[source,java]
|
||||
.Example Unit Test for a transform step
|
||||
----
|
||||
@Test
|
||||
void testTransformStep()
|
||||
{
|
||||
// insert 1 test order, that will be updated by the transform step
|
||||
Integer existingId = new InsertAction().execute(new InsertInput("order").withRecords(List.of(
|
||||
new QRecord().withValue("partnerOrderNo", 101).withValue("shipToState", "IL")
|
||||
))).getRecords().get(0).getValueInteger("id");
|
||||
|
||||
// set up input for the step - a list of 2 of the partner's orders
|
||||
RunBackendStepInput input = new RunBackendStepInput();
|
||||
input.setRecords(List.of(
|
||||
new QRecord().withValue("orderNo", 101).withValue("state", "NY"), // will update the order above
|
||||
new QRecord().withValue("orderNo", 102).withValue("state", "CA") // will insert a new order
|
||||
));
|
||||
RunBackendStepOutput output = new RunBackendStepOutput();
|
||||
|
||||
// run the code under test - our transform step
|
||||
new PartnerOrderToOrderTransformStep().run(input, output);
|
||||
|
||||
// Note that by just running the transform step, no records have been stored.
|
||||
// We can assert against the output of this step.
|
||||
|
||||
assertEquals(existingId, output.getRecords().get(0).getValue("id"));
|
||||
assertEquals(101, output.getRecords().get(0).getValue("partnerOrderNo"));
|
||||
assertEquals("NY", output.getRecords().get(0).getValue("shipToState"));
|
||||
|
||||
assertNull(output.getRecords().get(1).getValue("id"));
|
||||
assertEquals(102, output.getRecords().get(1).getValue("partnerOrderNo"));
|
||||
assertEquals("CA", output.getRecords().get(1).getValue("shipToState"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFullProcess()
|
||||
{
|
||||
// todo! :)
|
||||
}
|
||||
----
|
||||
|
||||
=== Pseudocode process flow
|
||||
Now that we've seen the bare-bones example, let's see an even more detailed breakdown of how a full `TableSyncProcess` works,
|
||||
by looking at its 3 "ETL" steps in pseudocode:
|
||||
|
||||
==== ExtractStep (Producer Thread)
|
||||
|
||||
* Queries source table for records
|
||||
** Often based on Table Automations (e.g., for all newly inserted records) or Basepull pattern (polling for new/updated records).
|
||||
|
||||
==== TransformStep (Consumer Thread)
|
||||
|
||||
* Receives pages of records from `ExtractStep` in the `run` method.
|
||||
* Makes `sourceKeyList` by getting `sourceTableKeyField` values from the records.
|
||||
* Calls `initializeRecordLookupHelper(runBackendStepInput, sourceRecordList)`
|
||||
** Calls `getLookupsToPreLoad` to control which lookups are performed.
|
||||
* Calls `getExistingRecordsByForeignKey(runBackendStepInput, destinationTableForeignKeyField, destinationTableName, sourceKeyList);`
|
||||
** Calls `getExistingRecordQueryFilter(runBackendStepInput, sourceKeyList)` as part of querying the `destinationTable`
|
||||
** Returns the output of `buildExistingRecordsMap(destinationTableForeignKeyField, queryOutput.getRecords())`
|
||||
* foreach input record (from `sourceTable`):
|
||||
** Calls `getExistingRecord(existingRecordsByForeignKey, destinationForeignKeyField, sourceKeyValue)`
|
||||
** if an existing record was returned (and if the syncConfig says `performUpdates`), this record is set as `recordToStore`
|
||||
** else if no existing record was returned (and if the syncConfig says `performInserts`), a new record is set as `recordToStore`
|
||||
** else continue the foreach.
|
||||
** call `populateRecordToStore(runBackendStepInput, recordToStore, sourceRecord)`
|
||||
** if a record is returned it is added to the process step output (to be stored in the LoadStep)
|
||||
|
||||
==== LoadStep (Consumer Thread)
|
||||
|
||||
* Receives records from the output of the `TransformStep`.
|
||||
* Inserts and/or Updates `destinationTable`, with records returned by `populateRecordToStore`
|
||||
|
||||
=== Additional Process Configuration Examples
|
||||
The following examples show how to use additional settings in the `TableSyncProcess` builder.
|
||||
|
||||
==== UI
|
||||
While a `TableSyncProcess` will often run via a schedule and/or automation, we may also want to allow users
|
||||
to manually run it in a UI.
|
||||
|
||||
[source,java]
|
||||
.Making our process available for a UI
|
||||
----
|
||||
QProcessMetaData processMetaData = TableSyncProcess.processMetaDataBuilder(true)
|
||||
.withName("partnerOrderToLocalOrderProcess")
|
||||
.withSyncTransformStepClass(PartnerOrderToOrderTransformStep.class)
|
||||
|
||||
// attach our process to its source table, to show up in UI
|
||||
.withTableName("partnerOrderImport")
|
||||
|
||||
// add some fields to display on the review screen, in UI
|
||||
.withReviewStepRecordFields(List.of(
|
||||
new QFieldMetaData("clientId", QFieldType.STRING).withLabel("Client"),
|
||||
new QFieldMetaData("warehouseId", QFieldType.STRING).withLabel("Warehouse"),
|
||||
new QFieldMetaData("partnerOrderNo", QFieldType.STRING)))
|
||||
.getProcessMetaData();
|
||||
----
|
||||
|
||||
==== Basepull
|
||||
The previous example would work as a Table Automation (e.g., where the list of records identified in the
|
||||
Extract step were determined by the Automation system).
|
||||
However, a second common pattern is to use `Basepull` (e.g., if polling for updated records from a partner API endpoint).
|
||||
|
||||
[source,java]
|
||||
.Configuring our process as a Basepull
|
||||
----
|
||||
// the true argument below tells the build we ARE a basepull-style process
|
||||
// this changes the default extract-step.
|
||||
QProcessMetaData processMetaData = TableSyncProcess.processMetaDataBuilder(false)
|
||||
.withName("partnerOrderToLocalOrderProcess")
|
||||
.withSyncTransformStepClass(PartnerOrderToOrderTransformStep.class)
|
||||
|
||||
// See Basepull documentation for details
|
||||
.withBasepullConfiguration(new BasepullConfiguration())
|
||||
|
||||
// schedule our process to run automatically every minute
|
||||
.withSchedule(new QScheduleMetaData().withRepeatSeconds(60))
|
||||
|
||||
.getProcessMetaData();
|
||||
----
|
||||
|
||||
=== Additional Options in the Transform Step
|
||||
|
||||
==== Specifying to not perform Inserts or not perform Updates
|
||||
We may have a scenario where we want our sync process to never update records if the key is already found in the destination table.
|
||||
We can configure this with an additional optional parameter to the `SyncProcessConfig` constructor:
|
||||
|
||||
[source,java]
|
||||
.Specifying to not do updates in a TableSyncProcess
|
||||
----
|
||||
@Override
|
||||
protected SyncProcessConfig getSyncProcessConfig()
|
||||
{
|
||||
return (new SyncProcessConfig("partnerOrderImport", "orderNo", "order", "partnerOrderNo",
|
||||
true, // performInserts
|
||||
false // performUpdates
|
||||
));
|
||||
}
|
||||
----
|
||||
|
||||
Similarly, we may want to disallow inserts from a particular sync process.
|
||||
The `performInserts` argument to the `SyncProcessConfig` constructor lets us do that:
|
||||
|
||||
[source,java]
|
||||
.Specifying to not do inserts in a TableSyncProcess
|
||||
----
|
||||
@Override
|
||||
protected SyncProcessConfig getSyncProcessConfig()
|
||||
{
|
||||
return (new SyncProcessConfig("partnerOrderImport", "orderNo", "order", "partnerOrderNo",
|
||||
false, // performInserts
|
||||
true // performUpdates
|
||||
));
|
||||
}
|
||||
----
|
||||
|
||||
==== Customizing the query for existing records
|
||||
|
||||
In some cases, a specific Table Sync process may need to refine the query filter that is used
|
||||
to lookup existing records in the destination table (e.g. for determining insert vs. update).
|
||||
|
||||
For example, in our orders-from-a-partner scenario, if we have more than 1 partner sending us orders,
|
||||
where there could be overlapping orderNo values among them - we may have an additional field in our
|
||||
orders table to identify which partner an order came from.
|
||||
So then when we're looking up orders by `partnerOrderNo`, we would need to also include the `partnerId` field
|
||||
in our query, so that we only update orders from the specific partner that we're dealing with.
|
||||
|
||||
To do this (to customize the existing record query filter), we need can just override the method `getExistingRecordQueryFilter`.
|
||||
Generally we would start by calling the `super` version of the method, and then add to it additional criteria.
|
||||
|
||||
[source,java]
|
||||
.Customizing the query filter used to look for existing records
|
||||
----
|
||||
/*******************************************************************************
|
||||
** Define the query filter to find existing records. e.g., for determining
|
||||
** insert vs. update. Subclasses may override this to customize the behavior,
|
||||
** e.g., in case an additional field is needed in the query.
|
||||
*******************************************************************************/
|
||||
protected QQueryFilter getExistingRecordQueryFilter(RunBackendStepInput runBackendStepInput, List<Serializable> sourceKeyList)
|
||||
{
|
||||
QQueryFilter filter = super.getExistingRecordQueryFilter(runBackendStepInput, sourceKeyList);
|
||||
filter.addCriteria(new QFilterCriteria("partnerId", EQUALS, PARTNER_ID));
|
||||
return (filter);
|
||||
}
|
||||
----
|
||||
|
||||
==== More efficient additional record lookups
|
||||
|
||||
It is a common use-case to need to map various ids from a partner's system to ids in your own system.
|
||||
For the orders example, we might need to know what warehouse the order is shipping from.
|
||||
The customer may send their identifier for the warehouse, and we may need to map those identifiers to our own warehouse ids.
|
||||
|
||||
The QQQ-provided class `RecordLookupHelper` exists to help with performing lookups like this,
|
||||
and in particular, it can be used to execute one query to fetch a full table, storing records
|
||||
by a key field, then returning those records without performing additional queries.
|
||||
|
||||
`AbstractTableSyncTransformStep` has a protected `recordLookupHelper` member.
|
||||
If we override the method `getLookupsToPreLoad()`, then this object is
|
||||
populated by calling its `preloadRecords` method with each specified pair of tableNames and fieldNames.
|
||||
|
||||
[source,java]
|
||||
.Specifying tables to pre-load using a RecordLookupHelper
|
||||
----
|
||||
/*******************************************************************************
|
||||
** Specify a list of tableName/keyColumnName pairs to run through
|
||||
** the preloadRecords method of the recordLookupHelper.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected List<Pair<String, String>> getLookupsToPreLoad()
|
||||
{
|
||||
return (List.of(
|
||||
Pair.of("warehouse", "partnerWarehouseNo")
|
||||
));
|
||||
}
|
||||
----
|
||||
|
||||
If we have preloaded some lookups, we can then use them in our `populateRecordToStore` method as follows:
|
||||
[source,java]
|
||||
.Using the recordLookupHelper in populateRecordToStore
|
||||
----
|
||||
// lookup warehouse with partnerWarehouseNo=whseNo from partner, and use our id in destination record
|
||||
String partnerWarehouseNo = sourceRecord.getValue("whseNo");
|
||||
Integer warehouseId = recordLookupHelper.getRecordId("warehouse", "partnerWarehouseNo", whseNo, Integer.class);
|
||||
destinationRecord.setValue("warehouseId", warehouseId);
|
||||
----
|
||||
|
||||
==== Additional override points
|
||||
|
||||
There are more methods which can be overridden in your `AbstractTableSyncTransformStep` subclass,
|
||||
to provide further customizations of behaviors, specifically in the area of dealing with existing
|
||||
records (e.g., the insert/update use-case).
|
||||
[source,java]
|
||||
.Additional AbstractTableSyncTransformStep overrides
|
||||
----
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the existingRecordQueryFilter - to look in the destinationTable for
|
||||
** any records that may need an update (rather than an insert).
|
||||
**
|
||||
** Generally returns a Map, keyed by a Pair of the destinationTableForeignKeyField
|
||||
** and the value in that field. But, for more complex use-cases, one can override
|
||||
** the buildExistingRecordsMap method, to make different keys (e.g., if there are
|
||||
** two possible destinationTableForeignKeyFields).
|
||||
*******************************************************************************/
|
||||
protected Map<Pair<String, Serializable>, QRecord> getExistingRecordsByForeignKey
|
||||
(
|
||||
RunBackendStepInput runBackendStepInput,
|
||||
String destinationTableForeignKeyField,
|
||||
String destinationTableName,
|
||||
List<Serializable> sourceKeyList
|
||||
) throws QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Overridable point where you can, for example, keys in the existingRecordsMap
|
||||
** with different fieldNames from the destinationTable.
|
||||
**
|
||||
** Note, if you're overriding this method, you'll likely also want & need to
|
||||
** override getExistingRecord.
|
||||
*******************************************************************************/
|
||||
protected Map<Pair<String, Serializable>, QRecord> buildExistingRecordsMap
|
||||
(
|
||||
String destinationTableForeignKeyField,
|
||||
List<QRecord> existingRecordList
|
||||
);
|
||||
|
||||
/*******************************************************************************
|
||||
** Given the map of existingRecordsByForeignKey (as built by
|
||||
** getExistingRecordsByForeignKey which calls buildExistingRecordsMap),
|
||||
** get one record from that map, for a given key-value from a source record.
|
||||
**
|
||||
** The destinationForeignKeyField is given as advice if needed (e.g., to see its type)
|
||||
*******************************************************************************/
|
||||
protected QRecord getExistingRecord
|
||||
(
|
||||
Map<Pair<String, Serializable>, QRecord> existingRecordsByForeignKey,
|
||||
QFieldMetaData destinationForeignKeyField,
|
||||
Serializable sourceKeyValue
|
||||
);
|
||||
|
||||
----
|
||||
|
@ -1,17 +1,76 @@
|
||||
= QQQ
|
||||
:doctype: book
|
||||
:doctype: article
|
||||
:toc: left
|
||||
:toclevels: 2
|
||||
:source-highlighter: coderay
|
||||
|
||||
include::Introduction.adoc[leveloffset=+1]
|
||||
|
||||
== Meta Data
|
||||
include::metaData/Tables.adoc[leveloffset=+1]
|
||||
''''
|
||||
include::metaData/Reports.adoc[leveloffset=+1]
|
||||
// Organizational units
|
||||
include::metaData/QInstance.adoc[leveloffset=+1]
|
||||
include::metaData/Backends.adoc[leveloffset=+1]
|
||||
include::metaData/Apps.adoc[leveloffset=+1]
|
||||
|
||||
== Actions
|
||||
// Primary meta-data types
|
||||
include::metaData/Tables.adoc[leveloffset=+1]
|
||||
include::metaData/Processes.adoc[leveloffset=+1]
|
||||
include::metaData/Widgets.adoc[leveloffset=+1]
|
||||
|
||||
// Helper meta-data types
|
||||
include::metaData/Fields.adoc[leveloffset=+1]
|
||||
include::metaData/PossibleValueSources.adoc[leveloffset=+1]
|
||||
include::metaData/Joins.adoc[leveloffset=+1]
|
||||
include::metaData/SecurtiyKeyTypes.adoc[leveloffset=+1]
|
||||
include::metaData/Reports.adoc[leveloffset=+1]
|
||||
include::metaData/Icons.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
|
||||
include::misc/QContext.adoc[leveloffset=+1]
|
||||
include::misc/QRecords.adoc[leveloffset=+1]
|
||||
include::misc/QRecordEntities.adoc[leveloffset=+1]
|
||||
include::misc/ProcessBackendSteps.adoc[leveloffset=+1]
|
||||
include::misc/RenderingWidgets.adoc[leveloffset=+1]
|
||||
|
||||
=== Table Customizers
|
||||
#todo#
|
||||
|
||||
== QQQ Core Actions
|
||||
include::actions/QueryAction.adoc[leveloffset=+1]
|
||||
''''
|
||||
include::actions/RenderTemplateAction.adoc[leveloffset=+1]
|
||||
''''
|
||||
|
||||
include::actions/GetAction.adoc[leveloffset=+1]
|
||||
|
||||
=== CountAction
|
||||
#todo#
|
||||
|
||||
=== AggregateAction
|
||||
#todo#
|
||||
|
||||
include::actions/InsertAction.adoc[leveloffset=+1]
|
||||
|
||||
=== UpdateAction
|
||||
#todo#
|
||||
|
||||
=== DeleteAction
|
||||
#todo#
|
||||
|
||||
=== AuditAction
|
||||
#todo#
|
||||
|
||||
== QQQ Default Implementations
|
||||
include::implementations/TableSync.adoc[leveloffset=+1]
|
||||
// later... include::actions/RenderTemplateAction.adoc[leveloffset=+1]
|
||||
|
||||
== QQQ Utility Classes
|
||||
include::utilities/RecordLookupHelper.adoc[leveloffset=+1]
|
||||
|
1396
docs/index.html
1396
docs/index.html
File diff suppressed because it is too large
Load Diff
12863
docs/index.pdf
12863
docs/index.pdf
File diff suppressed because it is too large
Load Diff
12
docs/justfile
Normal file
12
docs/justfile
Normal file
@ -0,0 +1,12 @@
|
||||
## https://github.com/casey/just
|
||||
|
||||
default:
|
||||
just --list
|
||||
|
||||
build-index-html:
|
||||
asciidoctor -a docinfo=shared index.adoc
|
||||
line=$(grep 'Last updated' index.html) && sed -i "s/id=\"content\">/&$line/" index.html
|
||||
|
||||
build-and-publish-index-html: build-index-html
|
||||
scp index.html first-node:/mnt/first-volume/dkelkhoff/nginx/html/justinsgotskinnylegs.com/qqq-docs.html
|
||||
@echo "Updated: https://justinsgotskinnylegs.com/qqq-docs.html"
|
92
docs/metaData/Apps.adoc
Normal file
92
docs/metaData/Apps.adoc
Normal file
@ -0,0 +1,92 @@
|
||||
[#Apps]
|
||||
== Apps
|
||||
include::../variables.adoc[]
|
||||
|
||||
QQQ User Interfaces (e.g., Material Dashboard) generally organize their contents via *Apps*.
|
||||
Apps are a lightweight construct in QQQ - basically just containers for other objects.
|
||||
|
||||
Specifically, Apps can contain:
|
||||
|
||||
* {link-widgets}
|
||||
* {link-tables}
|
||||
* {link-process}
|
||||
* {link-reports}
|
||||
* Other {link-apps} - to create a multi-tiered navigational hierarchy.
|
||||
|
||||
=== QAppMetaData
|
||||
Apps are defined in a QQQ Instance in `*QAppMetaData*` objects. Apps must consist of either 1 or more {link-widgets}, or 1 or more *Sections*, which are expected to contain 1 or more {link-tables}, {link-processes}, or {link-reports}.
|
||||
|
||||
*QAppMetaData Properties:*
|
||||
|
||||
* `name` - *String, Required* - Unique name for the app within the QQQ Instance.
|
||||
* `label` - *String* - User-facing label for the app, presented in User Interfaces.
|
||||
Inferred from `name` if not set.
|
||||
* `permissionRules` - *QPermissionRules* - Permissions to apply to the app. See {link-permissionRules} for details.
|
||||
* `children` - *List of QAppChildMetaData* - Objects contained within the app. These can be {link-tables}, {link-processes}, {link-reports} or other {link-apps}.
|
||||
** See the example below for some common patterns for how these child-meta data objects are added to an App.
|
||||
* `parentAppName` - *String* - For an app which is a child of another app, the parent app's name is referenced in this field.
|
||||
** Note that this is generally automatically set when the child is added to its parent, in the `addChild` method.
|
||||
* `icon` - *QIcon* - An icon to display in a UI for the app. See {link-icons}.
|
||||
* `widgets` - *List of String* - A list of names of {link-widgets} to include in the app.
|
||||
* `sections` - *List of <<QAppSection>>* - A list of `QAppSection` objects, to create organizational subdivisions within the app.
|
||||
** As shown in the example below, the method `withSectionOfChildren` can be used to fluently add a new `QAppSection`, along with its child objects, to both an app and a section all at once.
|
||||
|
||||
==== QAppSection
|
||||
A `QAppSection` is an organizational subsection of a {link-app}.
|
||||
|
||||
* `name` - *String, Required* - Unique name for the section within its app.
|
||||
* `label` - *String* - User-facing label for the section, presented in User Interfaces.
|
||||
Inferred from `name` if not set.
|
||||
* `icon` - *QIcon* - An icon to display in a UI for the section. See {link-icons}.
|
||||
* `tables` - *List of String* - A list of names of {link-tables} to include in the section.
|
||||
* `processes` - *List of String* - A list of names of {link-processes} to include in the section.
|
||||
* `reports` - *List of String* - A list of names of {link-reports} to include in the section.
|
||||
|
||||
*Examples*
|
||||
[source,java]
|
||||
----
|
||||
/*******************************************************************************
|
||||
** Full example of constructing a QAppMetaData object.
|
||||
*******************************************************************************/
|
||||
public class ExampleAppMetaDataProducer extends MetaDataProducer<QAppMetaData>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the QAppMetaData
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QAppMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (new QAppMetaData()
|
||||
.withName("sample")
|
||||
.withLabel("My Sample App")
|
||||
.withIcon(new QIcon().withName("thumb_up"))
|
||||
.withWidgets(List.of(
|
||||
UserWelcomeWidget.NAME,
|
||||
SystemHealthLineChartWidget.NAME))
|
||||
.withSectionOfChildren(new QAppSection().withName("peoplePlacesAndThings"),
|
||||
qInstance.getTable(People.TABLE_NAME),
|
||||
qInstance.getTable(Places.TABLE_NAME),
|
||||
qInstance.getTable(Things.TABLE_NAME),
|
||||
qInstance.getProcess(AssociatePeopleWithPlacesProcess.NAME))
|
||||
.withSectionOfChildren(new QAppSection().withName("math").withLabel("Mathematics"),
|
||||
qInstance.getProcess(ComputePiProcess.NAME),
|
||||
qInstance.getReport(PrimeNumbersReport.NAME),
|
||||
qInstance.getReport(PolygonReport.NAME)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Since this meta-data producer expects to find other meta-data objects in the
|
||||
** QInstance, give it a sortOrder higher than the default (which we'll expect
|
||||
** the other objects used).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
}
|
||||
----
|
||||
|
150
docs/metaData/Backends.adoc
Normal file
150
docs/metaData/Backends.adoc
Normal file
@ -0,0 +1,150 @@
|
||||
[#Backends]
|
||||
== Backends
|
||||
include::../variables.adoc[]
|
||||
|
||||
A key component of QQQ is its ability to connect to various backend data stores, while providing the same interfaces to those backends - both User Interfaces, and Programming Interfaces.
|
||||
|
||||
For example, out-of-the-box, QQQ can connect to:
|
||||
|
||||
* <<RDBMSBackendMetaData,RDBMS>> (Relational Database Management Systems, such as MySQL)
|
||||
* File Systems (<<S3BackendMetaData,Amazon S3>> or <<FilesystemBackendMetaData,local disk>>)
|
||||
* <<APIBackendMetaData,JSON Web APIs>> (_using a custom mapping class per-API backend_).
|
||||
* In-Memory data stores
|
||||
|
||||
All {link-tables} in a QQQ instance must belong to a backend. As such, any instance using tables (which would be almost all instances) must define 1 or more backends.
|
||||
|
||||
=== QBackendMetaData
|
||||
Backends are defined in a QQQ Instance in a `*QBackendMetaData*` object.
|
||||
These objects will have some common attributes, but many different attributes based on the type of backend being used.
|
||||
|
||||
*QBackendMetaData Properties:*
|
||||
|
||||
* `name` - *String, Required* - Unique name for the backend within the QQQ Instance.
|
||||
* `backendType` - *String, Required* - Identifier for the backend type being defined.
|
||||
** This attribute is typically set in the constructor of a `QBackendMetaData` subclass, and as such does not need to be set when defining a backend meta data object.
|
||||
* `enabledCapabilities` and `disabledCapability` - *Sets*, containing *Capability* enum values.
|
||||
Basic rules that apply to all tables in the backend, describing what actions (such as Delete, or Count) are supported in the backend.
|
||||
** By default, QQQ assumes that a backend supports _most_ capabilities, with one exception being `QUERY_STATS`.
|
||||
** #TODO# fully explain rules here
|
||||
* `usesVariants` - *Boolean, default false* - Control whether or not the backend supports the concept of "Variants".
|
||||
** Supporting variants means that tables within the backend can exist in alternative "variants" of the backend.
|
||||
For example, this might mean a sharded or multi-tenant database backend (perhaps a different server or database name per-client).
|
||||
Or this might mean using more than one set of credentials for connecting to an API backend - each of those credential sets would be a "variant".
|
||||
** A backend that uses variants requires additional properties to be set. #TODO complete variant documentation#
|
||||
|
||||
In a QQQ application, one will typically not create an instance of `QBackendMetaData` directly, but instead will create an instance of one of its subclasses, specific to the type of backend being used.
|
||||
The currently available list of such classes are:
|
||||
|
||||
==== RDBMSBackendMetaData
|
||||
The meta data required for working with tables in an RDBMS (relational database) backend are defined in an instance of the `*RDBMSBackendMetaData*` class.
|
||||
|
||||
*RDBMSBackendMetaData Properties:*
|
||||
|
||||
* `vendor` - *String, Required* - Database vendor. Currently supported values are: `aurora`, `mysql`, `h2`.
|
||||
* `jdbcUrl` - *String, Optional* - Full JDBC URL for connecting to the database.
|
||||
** If this property is provided, then following properties (which are the components of a JDBC URL) are ignored.
|
||||
In other words, you can either provide the `jdbcUrl`, or the individual components that make it up.
|
||||
* `hostName` - *String, Conditionally Required* - Hostname or ip address of the RDBMS server.
|
||||
* `port` - *Integer, Conditionally Required* - Port used to connect to the RDBMS server.
|
||||
* `databaseName` - *String, Conditionally Required* - Name of the database being connected to, within the RDBMS server.
|
||||
* `username` - *String, Conditionally Required* - Username for authenticating in the database server.
|
||||
* `password` - *String, Conditionally Required* - Password for authenticating in the database server.
|
||||
|
||||
*Examples*
|
||||
[source,java]
|
||||
----
|
||||
/*******************************************************************************
|
||||
** Full example of constructing an RDBMSBackendMetaData
|
||||
*******************************************************************************/
|
||||
public class ExampleDatabaseBackendMetaDataProducer extends MetaDataProducer<QBackendMetaData>
|
||||
{
|
||||
public static final String NAME = "rdbmsBackend";
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the QBackendMetaData
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QBackendMetaData produce(QInstance qInstance)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// read settings from either a .env file or the system's environment //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
QMetaDataVariableInterpreter interpreter = new QMetaDataVariableInterpreter();
|
||||
String vendor = interpreter.interpret("${env.RDBMS_VENDOR}");
|
||||
String hostname = interpreter.interpret("${env.RDBMS_HOSTNAME}");
|
||||
String port = interpreter.interpret("${env.RDBMS_PORT}");
|
||||
String databaseName = interpreter.interpret("${env.RDBMS_DATABASE_NAME}");
|
||||
String username = interpreter.interpret("${env.RDBMS_USERNAME}");
|
||||
String password = interpreter.interpret("${env.RDBMS_PASSWORD}");
|
||||
|
||||
return (new RDBMSBackendMetaData()
|
||||
.withName(NAME)
|
||||
.withVendor(vendor)
|
||||
.withHostName(hostname)
|
||||
.withPort(ValueUtils.getValueAsInteger(port))
|
||||
.withDatabaseName(databaseName)
|
||||
.withUsername(username)
|
||||
.withPassword(password)
|
||||
.withCapability(Capability.QUERY_STATS));
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
==== S3BackendMetaData
|
||||
The meta data required for working with tables in an Amazon S3 backend are defined in an instance of the `*S3BackendMetaData*` class.
|
||||
|
||||
*S3BackendMetaData Properties:*
|
||||
|
||||
* `bucketName` - *String, Required* - Bucket name to connect to inside AWS S3.
|
||||
* `accessKey` - *String, Required* - Access key for connecting to S3 inside AWS S3.
|
||||
* `secretKey` - *String, Required* - Secret key for connecting to S3 inside AWS S3.
|
||||
* `region` - *String, Required* - AWS region containing the Bucket in S3.
|
||||
* `basePath` - *String, Required* - Base path to the files within the S3 bucket.
|
||||
|
||||
==== FilesystemBackendMetaData
|
||||
The meta data required for working with tables in a (local) filesystem backend are defined in an instance of the `*FilesystemBackendMetaData*` class.
|
||||
|
||||
*FilesystemBackendMetaData Properties:*
|
||||
|
||||
* `basePath` - *String, Required* - Base path to the backend's files.
|
||||
|
||||
==== APIBackendMetaData
|
||||
The meta data required for working with tables in a web API are defined in an instance of the `*APIBackendMetaData*` class.
|
||||
|
||||
QQQ provides a minimal, reasonable default implementation for working with web APIs, making assumptions such as using `POST` to insert records, and `GET` with a primary key in the URL to get a single record.
|
||||
However, in our experience, almost all APIs are implemented differently enough, that a layer of custom code is required.
|
||||
For example, query/search endpoints are almost always unique in how they take their search parameters, and how they wrap their output.
|
||||
|
||||
To deal with this, a QQQ API Backend can define a custom class in the `actionUtil` property of the backend meta-data, as a subclass of `BaseAPIActionUtil`, which ideally can override methods at the level where unique functionality is needed.
|
||||
For example, an application need not define the full method for executing a Query against an API backend (which would need to make the HTTP request (actually multiple, to deal with pagination)).
|
||||
Instead, one can just override the `buildQueryStringForGet` method, where the unique details of making the request are defined, and maybe the `jsonObjectToRecord` method, where records are mapped from the API's response to a QRecord.
|
||||
|
||||
#todo - full reference and examples for `BaseAPIActionUtil`#
|
||||
|
||||
*APIBackendMetaData Properties:*
|
||||
|
||||
* `baseUrl` - *String, Required* - Base URL for connecting to the API.
|
||||
* `contentType` - *String, Required* - value of `Content-type` header included in all requests to the API.
|
||||
* `actionUtil` - *QCodeReference, Required* - Reference to a class that extends `BaseAPIActionUtil`, where custom code for working with this API backend is defined.
|
||||
* `customValues` - *Map of String → Serializable* - Application-defined additional name=value pairs that can
|
||||
* `authorizationType` - *Enum, Required* - Specifies how authentication is provided for the API.
|
||||
The value here, dictates which other authentication-related properties are required.
|
||||
Possible values are:
|
||||
** `API_KEY_HEADER` - Uses the `apiKey` property in an HTTP header named `API-Key`.
|
||||
_In the future, when needed, QQQ will add a property, tentatively named `apiKeyHeaderName`, to allow customization of this header name._
|
||||
** `API_TOKEN` - Uses the `apiKey` property in an `Authroization` header, as: `"Token " + apiKey`
|
||||
** `BASIC_AUTH_API_KEY` - Uses the `apiKey` property, Base64-encoded, in an `Authroization` header, as `"Basic " + base64(apiKey)`
|
||||
** `BASIC_AUTH_USERNAME_PASSWORD` - Combines the `username` and `password` properties, Base64-encoded, in an `Authroization` header, as `"Basic " + base64(username + ":" + password)`
|
||||
** `OAUTH2` - Implements an OAuth2 client credentials token grant flow, using the properties `clientId` and `clientSecret`.
|
||||
By default, the id & secret are sent as query-string parameters to the API's `oauth/token` endpoint.
|
||||
Alternatively, if the meta-data has a `customValue` of `setCredentialsInHeader=true`, then the id & secret are posted in an `Authorization` header (base-64 encoded, and concatenated with `":"`).
|
||||
** `API_KEY_QUERY_PARAM` - Uses the `apiKey` property as a query-string parameter, with its name taken from the `apiKeyQueryParamName` property.
|
||||
** `CUSTOM` - Has a no-op implementation at the QQQ layer.
|
||||
Assumes that an override of `protected void handleCustomAuthorization(HttpRequestBase request)` be implemented in the backend's `actionUtil` class.
|
||||
This would be
|
||||
* `apiKey` - *String, Conditional* - See `authorizationType` above for details.
|
||||
* `clientId` - *String, Conditional* - See `authorizationType` above for details.
|
||||
* `clientSecret` - *String, Conditional* - See `authorizationType` above for details.
|
||||
* `username` - *String, Conditional* - See `authorizationType` above for details.
|
||||
* `password` - *String, Conditional* - See `authorizationType` above for details.
|
||||
* `apiKeyQueryParamName` - *String, Conditional* - See `authorizationType` above for details.
|
@ -1,4 +1,5 @@
|
||||
== QQQ Fields
|
||||
[#Fields]
|
||||
== Fields
|
||||
include::../variables.adoc[]
|
||||
|
||||
QQQ Fields define
|
||||
@ -20,5 +21,99 @@ Used to set values in the `displayValues` map within a `QRecord`.
|
||||
* `possibleValueSourceName` - *String* - Reference to a {link-pvs} to be used for this field.
|
||||
Values in this field should correspond to ids from the referenced Possible Value Source.
|
||||
* `maxLength` - *Integer* - Maximum length (number of characters) allowed for values in this field.
|
||||
Only applicable for fields with `type=STRING`.
|
||||
* `
|
||||
Only applicable for fields with `type=STRING`. Needs to be used with a `FieldBehavior` of type `ValueTooLongBehavior`.
|
||||
|
||||
==== Field Behaviors
|
||||
Additional behaviors can be attached to fields through the use of the `behaviors` attribute,
|
||||
which is a `Set` of 0 or more instances of implementations of the `FieldBehavior` interface.
|
||||
Note that in some cases, these instances may be `enum` constants,
|
||||
but other times may be regular Objects.
|
||||
|
||||
QQQ provides a set of common field behaviors.
|
||||
Applications can also define their own field behaviors by implementing the `FieldBehavior` interface,
|
||||
and attaching instances of their custom behavior classes to fields.
|
||||
|
||||
===== ValueTooLongBehavior
|
||||
Used on String fields. Requires the field to have a `maxLength` set.
|
||||
Depending on the chosen instance of this enum, before a record is Inserted or Updated,
|
||||
if the value in the field is longer than the `maxLength`, then one of the following actions can occur:
|
||||
|
||||
* `TRUNCATE` - The value will be simply truncated to the `maxLength`.
|
||||
* `TRUNCATE_ELLIPSIS` - The value will be truncated to 3 characters less than the `maxLength`, and three periods (an ellipsis) will be placed at the end.
|
||||
* `ERROR` - An error will be reported, and the record will not be inserted or updated.
|
||||
* `PASS_THROUGH` - Nothing will happen. This is the same as not having a `ValueTooLongBehavior` on the field.
|
||||
|
||||
[source,java]
|
||||
.Examples of using ValueTooLongBehavior
|
||||
----
|
||||
new QFieldMetaData("sku", QFieldType.STRING)
|
||||
.withMaxLength(40),
|
||||
.withBehavior(ValueTooLongBehavior.ERROR),
|
||||
|
||||
new QFieldMetaData("reason", QFieldType.STRING)
|
||||
.withMaxLength(250),
|
||||
.withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS),
|
||||
|
||||
----
|
||||
|
||||
===== DynamicDefaultValueBehavior
|
||||
Used to set a dynamic default value to a field when it is being inserted or updated.
|
||||
For example, instead of having a hard-coded `defaultValue` specified in the field meta-data,
|
||||
and instead of having to add, for example, a pre-insert custom action.
|
||||
|
||||
* `CREATE_DATE` - On inserts, sets the field's value to the current time.
|
||||
* `MODIFY_DATE` - On inserts and updates, sets the field's value to the current time.
|
||||
* `USER_ID` - On inserts and updates, sets the field's value to the current user's id (but only if the value is currently null).
|
||||
|
||||
_Note that the `QInstanceEnricher` will, by default, add the `CREATE_DATE` and `MODIFY_DATE` `DynamicDefaultValueBehavior`
|
||||
options to any fields named `"createDate"` or `"modifyDate"`.
|
||||
This behavior can be disabled by setting the `configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate` property
|
||||
on the `QInstanceEnricher` instance used by the application to `false`._
|
||||
|
||||
[source,java]
|
||||
.Examples of using DynamicDefaultValueBehavior
|
||||
----
|
||||
new QFieldMetaData("createDate", QFieldType.DATE_TIME)
|
||||
.withBehavior(DynamicDefaultValueBehavior.CREATE_DATE),
|
||||
|
||||
new QFieldMetaData("modifyDate", QFieldType.DATE_TIME)
|
||||
.withBehavior(DynamicDefaultValueBehavior.MODIFY_DATE),
|
||||
|
||||
new QFieldMetaData("createdByUserId", QFieldType.STRING)
|
||||
.withBehavior(DynamicDefaultValueBehavior.USER_ID),
|
||||
----
|
||||
|
||||
===== DateTimeDisplayValueBehavior
|
||||
By default, in QQQ, fields of type `DATE_TIME` are stored in UTC,
|
||||
and their values in a QRecord is a java `Instant` instance, which is always UTC.
|
||||
However, frontends will prefer to display date-time values in the user's local Time Zone whenever possible.
|
||||
|
||||
Using `DateTimeDisplayValueBehavior` allows a `DATE_TIME` field to be displayed in a different Time Zone.
|
||||
An example use-case for this would be displaying airplane flight times,
|
||||
where you would want a flight from California to New York to display Pacific Time for its departure time,
|
||||
and Eastern Time for its arrival.
|
||||
|
||||
An instance of `DateTimeDisplayValueBehavior` can be configured to either use a hard-coded time `ZoneId`
|
||||
(for example, to always show users UTC, or a business's home-office time zone).
|
||||
Or, it can be set up to get the time zone to use from another field in the table.
|
||||
|
||||
[source,java]
|
||||
.Examples of using DateTimeDisplayValueBehavior
|
||||
----
|
||||
new QTableMetaData().withName("flights").withFields(List.of(
|
||||
...
|
||||
new QFieldMetaData("departureTimeZoneId", QFieldType.STRING),
|
||||
new QFieldMetaData("arrivaTimeZoneId", QFieldType.STRING),
|
||||
|
||||
new QFieldMetaData("departureTime", QFieldType.DATE_TIME)
|
||||
.withBehavior(new DateTimeDisplayValueBehavior()
|
||||
.withZoneIdFromFieldName("departureTimeZoneId")),
|
||||
|
||||
new QFieldMetaData("arrivalTime", QFieldType.DATE_TIME)
|
||||
.withBehavior(new DateTimeDisplayValueBehavior()
|
||||
.withZoneIdFromFieldName("arrivalTimeZoneId"))
|
||||
|
||||
new QFieldMetaData("ticketSaleStartDateTime", QFieldType.DATE_TIME)
|
||||
.withBehavior(new DateTimeDisplayValueBehavior()
|
||||
.withDefaultZoneId("UTC"))
|
||||
----
|
||||
|
20
docs/metaData/Icons.adoc
Normal file
20
docs/metaData/Icons.adoc
Normal file
@ -0,0 +1,20 @@
|
||||
[#Icons]
|
||||
== Icons
|
||||
include::../variables.adoc[]
|
||||
|
||||
#TODO#
|
||||
|
||||
=== QIcon
|
||||
Icons are defined in a QQQ Instance in a `*QIcon*` object.
|
||||
|
||||
#TODO#
|
||||
|
||||
*QIcon Properties:*
|
||||
|
||||
* `name` - *String* - Name of an icon from the https://mui.com/material-ui/material-icons/[Material UI Icon set]
|
||||
** Note that icon names from the link above need to be converted from _CamelCase_ to _underscore_case_...
|
||||
* `path` - *String* - Path to a file served under the application's web root, to be used as the icon.
|
||||
|
||||
_Either `name` or `path` must be specified. If both are given, then name is used._
|
||||
|
||||
|
17
docs/metaData/Joins.adoc
Normal file
17
docs/metaData/Joins.adoc
Normal file
@ -0,0 +1,17 @@
|
||||
[#Joins]
|
||||
== Joins
|
||||
include::../variables.adoc[]
|
||||
|
||||
#TODO#
|
||||
|
||||
=== QJoinMetaData
|
||||
Joins are defined in a QQQ Instance in a `*QJoinMetaData*` object.
|
||||
|
||||
#TODO#
|
||||
|
||||
*QJoinMetaData Properties:*
|
||||
|
||||
* `name` - *String, Required* - Unique name for the join within the QQQ Instance. #todo infererences or conventions?#
|
||||
|
||||
#TODO#
|
||||
|
13
docs/metaData/PermissionRules.adoc
Normal file
13
docs/metaData/PermissionRules.adoc
Normal file
@ -0,0 +1,13 @@
|
||||
[#PermissionRules]
|
||||
== Permission Rules
|
||||
include::../variables.adoc[]
|
||||
|
||||
#TODO#
|
||||
|
||||
=== PermissionRule
|
||||
#TODO#
|
||||
|
||||
*PermissionRule Properties:*
|
||||
|
||||
#TODO#
|
||||
|
17
docs/metaData/PossibleValueSources.adoc
Normal file
17
docs/metaData/PossibleValueSources.adoc
Normal file
@ -0,0 +1,17 @@
|
||||
[#PossibleValueSources]
|
||||
== Possible Value Sources
|
||||
include::../variables.adoc[]
|
||||
|
||||
#TODO#
|
||||
|
||||
=== QPossibleValueSource
|
||||
A Possible Value Source is defined in a QQQ Instance in a `*QPossibleValueSource*` object.
|
||||
|
||||
#TODO#
|
||||
|
||||
*QPossibleValueSource Properties:*
|
||||
|
||||
* `name` - *String, Required* - Unique name for the possible value source within the QQQ Instance.
|
||||
|
||||
#TODO#
|
||||
|
216
docs/metaData/Processes.adoc
Normal file
216
docs/metaData/Processes.adoc
Normal file
@ -0,0 +1,216 @@
|
||||
[#Processes]
|
||||
== Processes
|
||||
|
||||
include::../variables.adoc[]
|
||||
|
||||
Besides {link-tables}, the other most common type of object in a QQQ Instance is the Process.
|
||||
Processes are "custom" actions (e.g., defined by the application developers, rather than QQQ) that users and/or the system can execute against records.
|
||||
Processes generally are made up of two types of sub-objects:
|
||||
|
||||
* *Screens* - i.e., User Interfaces (e.g., for gathering input and/or showing output).
|
||||
* *BackendSteps* - Java classes (of type `BackendStep`) that execute the logic of the process.
|
||||
|
||||
=== QProcessMetaData
|
||||
|
||||
Processes are defined in a QQQ Instance in a `*QProcessMetaData*` object.
|
||||
In addition to directly building a `QProcessMetaData` object setting its properties directly, there are a few common process patterns that provide *Builder* objects for ease-of-use.
|
||||
See StreamedETLWithFrontendProcess below for a common example
|
||||
|
||||
*QProcessMetaData Properties:*
|
||||
|
||||
* `name` - *String, Required* - Unique name for the process within the QQQ Instance.
|
||||
* `label` - *String* - User-facing label for the process, presented in User Interfaces.
|
||||
Inferred from `name` if not set.
|
||||
* `icon` - *QIcon* - Icon associated with this process in certain user interfaces.
|
||||
See {link-icons}.
|
||||
* `tableName` - *String* - Name of a {link-table} that the process is associated with in User Interfaces (e.g., Action menu).
|
||||
* `isHidden` - *Boolean, default false* - Option to hide the process from all User Interfaces.
|
||||
* `basepullConfiguration` - *<<BasepullConfiguration>>* - config for the common "Basepull" pattern, of identifying records with a timestamp greater than the last time the process was ran.
|
||||
See below for details.
|
||||
* `permissionRules` - *QPermissionRules object* - define the permission/access rules for the process.
|
||||
See {link-permissionRules} for details.
|
||||
* `steps` and `stepList` - *Map of String → <<QStepMetaData>>* and *List of QStepMetaData* - Defines the <<QFrontendStepMetaData,screens>> and <<QBackendStepMetaData,backend code>> that makes up the process.
|
||||
** `stepList` is the list of steps in the order that they will by default be executed.
|
||||
** `steps` is a map, including all steps from `stepList`, but which may also include steps which can used by the process if its backend steps make the decision to do so, at run-time.
|
||||
** A process's steps are normally defined in one of two was:
|
||||
*** 1) by a single call to `.withStepList(List<QStepMetaData>)`, which internally adds each step into the `steps` map.
|
||||
*** 2) by multiple calls to `.addStep(QStepMetaData)`, which adds a step to both the `stepList` and `steps` map.
|
||||
** If a process also needs optional steps, they should be added by a call to `.addOptionalStep(QStepMetaData)`, which only places them in the `steps` map.
|
||||
* `schedule` - *<<QScheduleMetaData>>* - set up the process to run automatically on the specified schedule.
|
||||
See below for details.
|
||||
* `minInputRecords` - *Integer* - #not used...#
|
||||
* `maxInputRecords` - *Integer* - #not used...#
|
||||
|
||||
#todo: supplementalMetaData (API)#
|
||||
|
||||
==== QStepMetaData
|
||||
|
||||
This is the base class for the two types of steps in a process - <<QFrontendStepMetaData,screens>> and <<QBackendStepMetaData,backend code>>.
|
||||
There are some shared attributes of both of them, defined here.
|
||||
|
||||
*QStepMetaData Properties:*
|
||||
|
||||
* `name` - *String, Required* - Unique name for the step within the process.
|
||||
* `label` - *String* - User-facing label for the step, presented in User Interfaces.
|
||||
Inferred from `name` if not set.
|
||||
* `stepType` - *String* - _Deprecated._
|
||||
|
||||
==== QFrontendStepMetaData
|
||||
|
||||
For processes with a user-interface, they must define one or more "screens" in the form of `QFrontendStepMetaData` objects.
|
||||
|
||||
*QFrontendStepMetaData Properties:*
|
||||
|
||||
* `components` - *List of <<QFrontendComponentMetaData>>* - a list of components to be rendered on the screen.
|
||||
* `formFields` - *List of String* - list of field names used by the screen as form-inputs.
|
||||
* `viewFields` - *List of String* - list of field names used by the screen as visible outputs.
|
||||
* `recordListFields` - *List of String* - list of field names used by the screen in a record listing.
|
||||
|
||||
==== QFrontendComponentMetaData
|
||||
|
||||
A screen in a process may consist of multiple "components" - e.g., help text, and a form, and a list of records.
|
||||
Each of these components are defined in a `QFrontendComponentMetaData`.
|
||||
|
||||
*QFrontendComponentMetaData Properties:*
|
||||
|
||||
* `type` - *enum, Required* - The type of component to display.
|
||||
Each component type works with different keys in the `values` map.
|
||||
Possible values for `type` are:
|
||||
** `EDIT_FORM` - Displays a list of fields for editing, similar to a record edit screen.
|
||||
Requires that `formFields` be populated in the step.
|
||||
** `VIEW_FORM` - Displays a list of fields for viewing (not editing), similar to a record view screen.
|
||||
Requires that `viewFields` be populated in the step.
|
||||
** `HELP_TEXT` - Block of help text to be display on the screen.
|
||||
Requires an entry in the component's `values` map named `"text"`.
|
||||
** `HTML` - Block of custom HTML, generated by the process backend.
|
||||
Expects a process value named `html`.
|
||||
** `DOWNLOAD_FORM` - Presentation of a link to download a file generated by the process.
|
||||
Expects process values named `downloadFileName` and `serverFilePath`.
|
||||
** `GOOGLE_DRIVE_SELECT_FOLDER` - Special form that presents a UI from Google Drive, where the user can select a folder (e.g., as a target for uploading files in a subsequent backend step).
|
||||
** `BULK_EDIT_FORM` - For use by the standard QQQ Bulk Edit process.
|
||||
** `VALIDATION_REVIEW_SCREEN` - For use by the QQQ Streamed ETL With Frontend process family of processes.
|
||||
Displays a component prompting the user to run full validation or to skip it, or, if full validation has been ran, then showing the results of that validation.
|
||||
** `PROCESS_SUMMARY_RESULTS` - For use by the QQQ Streamed ETL With Frontend process family of processes.
|
||||
Displays the summary results of running the process.
|
||||
** `RECORD_LIST` - _Deprecated.
|
||||
Showed a grid with a list of records as populated by the process._
|
||||
* `values` - *Map of String → Serializable* - Key=value pairs, with different expectations based on the component's `type`.
|
||||
See above for details.
|
||||
|
||||
==== QBackendStepMetaData
|
||||
|
||||
Process Backend Steps are where custom (at this time, Java, but in the future, potentially, from any supported language) code is executed, to provide the logic of the process.
|
||||
QQQ comes with several common backend steps, such as for extracting lists of records, storing lists of records, etc.
|
||||
|
||||
*QBackendStepMetaData Properties:*
|
||||
|
||||
* `code` - *QCodeReference, Required* - Reference to the code to be executed for the step.
|
||||
The referenced code must implement the `BackendStep` interface.
|
||||
* `inputMetaData` - *QFunctionInputMetaData* - Definition of the data that the backend step expects and/or requires.
|
||||
Sub-properties are:
|
||||
** `fieldList` - *List of {link-fields}* - Optional list of fields used by the process step.
|
||||
In general, a process does not _have to_ specify the fields that its steps use.
|
||||
It can be used, however, for example, to cause a `defaultValue` to be applied to a field if it isn't specified in the process's input.
|
||||
It can also be used to cause the process to throw an error, if a field is marked as `isRequired`, but a value is not present.
|
||||
** `recordListMetaData` - *RecordListMetaData object* - _Not used at this time._
|
||||
|
||||
==== BasepullConfiguration
|
||||
|
||||
A "Basepull" process is a common pattern where an application needs to perform some action on all new (or updated) records from a particular data source.
|
||||
To implement this pattern, one needs to store the timestamp of when the action runs, then query the source for records where a date-time field is after that timestamp.
|
||||
|
||||
QQQ helps facilitate this pattern by automatically retrieving and updating that timestamp field, and by building a default query filter based on that timestamp.
|
||||
|
||||
This is done by adding a `BasepullConfiguration` object to a process's meta-data.
|
||||
|
||||
*BasepullConfiguration Properties:*
|
||||
|
||||
* `tableName` - *String, Required* - Name of a {link-table} in the QQQ Instance where the basepull timestamps are stored.
|
||||
* `keyField` - *String, Required* - Name of a {link-field} in the basepull table that stores a unique identifier for the process.
|
||||
* `keyValue` - *String* - Optional value to be stored in the `keyField` of the basepull table as the unique identifier for the process.
|
||||
If not set, then the process's `name` is used.
|
||||
* `lastRunTimeFieldName` - *String, Required* - Name of a {link-field} in the basepull table that stores the last-run time for the process.
|
||||
* `hoursBackForInitialTimestamp` - *Integer* - Optional number of hours to go back in time (from `now`) for the first time that the process is executed (i.e., if there is no timestamp stored in the basepull table).
|
||||
* `timestampField` - *String, Required* - Name of a {link-field} in the table being queried against the last-run timestamp.
|
||||
|
||||
==== QScheduleMetaData
|
||||
|
||||
QQQ can automatically run processes using an internal scheduler, if they are configured with a `QScheduleMetaData` object.
|
||||
|
||||
*QScheduleMetaData Properties*
|
||||
|
||||
* `repeatSeconds` - *Integer, Conditional* - How often the process should be executed, in seconds.
|
||||
* `repeatMillis` - *Integer, Conditional* - How often the process should be executed, in milliseconds.
|
||||
Mutually exclusive with `repeatSeconds`.
|
||||
* `initialDelaySeconds` - *Integer, Conditional* - How long between when the scheduler starts and the process should first run, in seconds.
|
||||
* `initialDelayMillis` - *Integer, Conditional* - How long between when the scheduler starts and the process should first run, in milliseconds.
|
||||
Mutually exclusive with `initialDelaySeconds`.
|
||||
* `variantRunStrategory` - *enum, Conditional* - For processes than run against {link-tables} that use a {link-backend} with Variants enabled, this property controls if the various instances of the process should run in `PARALLEL` or in `SERIAL`.
|
||||
* `variantBackend` - *enum, Conditional* - For processes than run against {link-tables} that use a {link-backend} with Variants enabled, this property specifies the name of the {link-backend}.
|
||||
|
||||
==== StreamedETLWithFrontendProcess
|
||||
|
||||
A common pattern for QQQ processes to exhibit is called the "Streamed ETL With Frontend" process pattern.
|
||||
This pattern is to do an "Extract, Transform, Load" job on a potentially large set of records.
|
||||
|
||||
The records are Streamed through the process's steps, meaning, QQQ runs multiple threads - a producer, which is selecting records, and a consumer, which is processing records.
|
||||
As such, in general, an unlimited number of records can be processed by a process, without worrying about exhausting server resources (e.g., OutOfMemory).
|
||||
|
||||
These processes also have a standard user-interface for displaying a summary of what the process will do (and has done), with a small number of records as a preview.
|
||||
The goal of the summary is to give the user the big-picture of what the process will do (e.g., X records will be inserted; Y records will be updated), along with a small view of some details on the records that will be stored (e.g., on record A field B will be set to C).
|
||||
|
||||
This type of process uses 3 backend steps, and 2 frontend steps, as follows:
|
||||
|
||||
* *preview* (backend) - does just a little work (limited # of rows), to give the user a preview of what the final result will be - e.g., some data to seed the review screen.
|
||||
* *review* (frontend) - a review screen, which after the preview step does not have a full process summary, but can generally tell the user how many records are input to the process, and can show a preview of a small number of the records.
|
||||
* *validate* (backend) - optionally (per input on review screen), does like the preview step, but does it for all records from the extract step.
|
||||
* *review* (frontend) - a second view of the review screen, if the validate step was executed.
|
||||
Now that the full validation was performed, a full process summary can be shown, along with a some preview records.
|
||||
* *execute* (backend) - processes all the rows - does all the work - stores data in the backend.
|
||||
* *result* (frontend) - a result screen, showing a "past-tense" version of the process summary.
|
||||
|
||||
These backend steps are defined within QQQ, meaning they themselves do not execute any application-defined custom code.
|
||||
Instead, these steps use the following secondary <<QBackendStepMetaData,backend steps>>:
|
||||
|
||||
* *Extract* - Fetch the rows to be processed.
|
||||
Used by preview (but only for a limited number of rows), validate (without limit), and execute (without limit).
|
||||
* *Transform* - Do whatever transformation is needed to the rows.
|
||||
Done on preview, validate, and execute.
|
||||
Always works with a page of records at a time.
|
||||
Since it is called on the preview & validate steps, it should *NOT* ever store any data (unless it does a specific check to confirm that it is being used on an *execute* step).
|
||||
* *Load* - Store the records into the backend, as appropriate.
|
||||
Only called by the execute step.
|
||||
Always works with a page of records at a time.
|
||||
|
||||
The meta-data for a `StreamedETLWithFrontendProcess` uses several input fields on its steps.
|
||||
As such, it can be somewhat clumsy and error-prone to fully define a `StreamedETLWithFrontendProcess`.
|
||||
To improve this programmer-interface, an inner `Builder` class exists within `StreamedETLWithFrontendProcess` (generated by a call to `StreamedETLWithFrontendProcess.processMetaDataBuilder()`).
|
||||
|
||||
*StreamedETLWithFrontendProcess.Builder methods:*
|
||||
|
||||
* `withName(String name) - Set the name for the process.
|
||||
* `withLabel(String label) - Set the label for the process.
|
||||
* `withIcon(QIcon icon)` - Set an {link-icon} to be display with the process in the UI.
|
||||
* `withExtractStepClass(Class<? extends AbstractExtractStep>)` - Define the Extract step for the process.
|
||||
If no special extraction logic is needed, `ExtractViaQuery.class` is often a reasonable default.
|
||||
In other cases, `ExtractViaQuery` can be a reasonable class to extend for a custom extract step.
|
||||
* `withTransformStepClass(Class<? extends AbstractTransformStep>)` - Define the Transform step for the process.
|
||||
If no transformation logic is needed, `NoopTransformStep.class` can be used (though this is not very common).
|
||||
* `withLoadStepClass(Class<? extends AbstractLoadStep>)` - Define the Load step for the process.
|
||||
Several standard implementations exist, such as: `LoadViaInsertStep.class`, `LoadViaUpdateStep.class`, and `LoadViaDeleteStep.class`.
|
||||
* `withTableName(String tableName)` - Specify the name of the {link-table} that the process should be associated with in the UI.
|
||||
* `withSourceTable(String sourceTable)` - Specify the name of the {link-table} to be used as the source of records for the process.
|
||||
* `withDestinationTable(String destinationTable)` - Specify the name of the {link-table} to be used as the destination for records from the process.
|
||||
* `withSupportsFullValidation(Boolean supportsFullValidation)` - By default, all StreamedETLWithFrontendProcesses do allow the user to choose to run the full validation step.
|
||||
However, in case cases it may not make sense to do so - so this method can be used to turn off that option.
|
||||
* `withDoFullValidation(Boolean doFullValidation)` - By default, all StreamedETLWithFrontendProcesses will prompt the user if they want to run the full validation step or not.
|
||||
However, in case cases you may want to enforce that the validation step always be executed.
|
||||
Calling this method will remove the option from the user, and always run a full validation.
|
||||
* `withTransactionLevelAutoCommit()`, `withTransactionLevelPage()`, and `withTransactionLevelProcess()` - Change the transaction-level used by the process.
|
||||
By default, these processes are ran with a single transaction for all pages of their execute step.
|
||||
But for some cases, doing page-level transactions can reduce long-transactions and locking within the system.
|
||||
* `withPreviewMessage(String previewMessage)` - Sets the message shown on the validation review screen(s) above the preview records.
|
||||
* `withReviewStepRecordFields(List<QFieldMetaData> fieldList)` -
|
||||
* `withFields(List<QFieldMetaData> fieldList)` - Adds additional input fields to the preview step of the process.
|
||||
* `withBasepullConfiguration(BasepullConfiguration basepullConfiguration)` - Add a <<BasepullConfiguration>> to the process.
|
||||
* `withSchedule(QScheduleMetaData schedule)` - Add a <<QScheduleMetaData>> to the process.
|
39
docs/metaData/QInstance.adoc
Normal file
39
docs/metaData/QInstance.adoc
Normal file
@ -0,0 +1,39 @@
|
||||
[#QInstance]
|
||||
== QInstance
|
||||
include::../variables.adoc[]
|
||||
|
||||
An application in QQQ is defined as a set of Meta Data objects. These objects are all stored together in an object called a `*QInstance*`.
|
||||
|
||||
Currently, a `QInstance` must be programmatically constructed in java code - e.g., by constructing objects which get added to the QInstance, for example:
|
||||
|
||||
[source,java]
|
||||
.Adding meta-data for two tables and one process to a QInstance
|
||||
----
|
||||
QInstance qInstance = new QInstance();
|
||||
qInstance.addTable(definePersonTable());
|
||||
qInstance.addTable(defineHomeTable());
|
||||
qInstance.addProcess(defineSendPersonHomeProcess());
|
||||
----
|
||||
|
||||
It is on the QQQ roadmap to allow meta-data to be defined in a non-programmatic way, for example, in YAML or JSON files, or even from a dynamic data source (e.g. a document or relational database).
|
||||
|
||||
The middleware and/or frontends being used in an application will drive how the `QInstance` is connected to the running server/process.
|
||||
For example, using the `qqq-middleware-javalin` module, a the `QJavalinImplementation` class () has a constructor which takes a `QInstance` as an argument:
|
||||
|
||||
[source,java]
|
||||
.Starting a QQQ Javalin middleware server - passing a QInstance as a parameter to the new QJavalinImplementation
|
||||
----
|
||||
QJavalinImplementation qJavalinImplementation = new QJavalinImplementation(qInstance);
|
||||
Javalin service = Javalin.create();
|
||||
service.routes(qJavalinImplementation.getRoutes());
|
||||
service.start();
|
||||
----
|
||||
|
||||
*QBackendMetaData Setup Methods:*
|
||||
|
||||
These are the methods that one is most likely to use when setting up (defining) a `QInstance` object:
|
||||
|
||||
* asdf
|
||||
|
||||
*QBackendMetaData Usage Methods:*
|
||||
|
@ -1,4 +1,5 @@
|
||||
== QQQ Reports
|
||||
[#Reports]
|
||||
== Reports
|
||||
include::../variables.adoc[]
|
||||
|
||||
QQQ can generate reports based on {link-tables} defined within a QQQ Instance.
|
||||
|
80
docs/metaData/SecurtiyKeyTypes.adoc
Normal file
80
docs/metaData/SecurtiyKeyTypes.adoc
Normal file
@ -0,0 +1,80 @@
|
||||
[#SecurityKeyTypes]
|
||||
== Security Key Types
|
||||
include::../variables.adoc[]
|
||||
|
||||
In QQQ, record-level security is provided by using a lock & key metaphor.
|
||||
|
||||
The use-case being handled here is:
|
||||
|
||||
* A user has full permission on a table (query, insert, update, and delete).
|
||||
* However, they should only be allowed to read a sub-set of the rows in the table.
|
||||
** e.g., maybe it's a multi-tenant system, or the table has user-specific records.
|
||||
|
||||
The lock & key metaphor is realized by the user being associated with one or more "Keys"
|
||||
(as values in their session), and records in tables being associated with one or more "Locks"
|
||||
(as values in fields).
|
||||
A user is only allowed to access records where the user's key(s) match the record's security lock(s).
|
||||
|
||||
For a practical example, picture a multi-tenant Order Management System,where all orders are assigned to a "client".
|
||||
Users (customers) should only be able to see orders associated with the client which that user works for.
|
||||
|
||||
In this scenario, the `order` table would have a "lock" on its `clientId` field.
|
||||
Customer-users would have a `clientId` key in their session.
|
||||
When the QQQ backend did a search for records (e.g., an SQL query) it would implicitly
|
||||
(without any code being written by the application developer) filter the table to only
|
||||
allow the user to see records with their `clientId`.
|
||||
|
||||
To implement this scenario, the application would define the following pieces of meta-data:
|
||||
|
||||
* At the QQQ-Instance level, a `SecurityKeyType`,
|
||||
to define a domain of possible locks & keys within an application.
|
||||
** An application can define multiple Security Key Types.
|
||||
For example, maybe `clientId` and `userId` as key types.
|
||||
* At the per-table level, a `RecordSecurityLock`,
|
||||
which references a security key type, and how that key type should be applied to the table.
|
||||
** For example, what field stores the `clientId` value in the `order` table.
|
||||
* Finally, when a user's session is constructed via a QQQ Authentication provider,
|
||||
security key values are set, based on data from the authentication backend.
|
||||
|
||||
=== Additional Scenarios
|
||||
|
||||
==== All Access Key
|
||||
A "super-user" may be allowed to access all records in a table regardless of their record locks,
|
||||
if the Security Key Type specifies an `allAccessKeyName`,
|
||||
and if the user has a key in their session with that key name, and a value of `true`.
|
||||
Going back to the lock & key metaphor, this can be thought of as a "skeleton key",
|
||||
that can unlock any record lock (of the security key's type).
|
||||
|
||||
==== Null Value Behaviors
|
||||
In a record security lock, different behaviors can be defined for handling rows with a null key value.
|
||||
|
||||
For example:
|
||||
|
||||
* Sometimes orders may be loaded into the OMS system described above, where the application doesn't yet know what client the order belongs to.
|
||||
In this case, the application may need to ensure that such records, with a `null` value in `clientId` are hidden from customer-users,
|
||||
to avoid potentially leaking a different client's data.
|
||||
** This can be accomplished with a record security lock on the `order` table, with a `nullValueBehavior` of `DENY`.
|
||||
** Furthermore, if internal/admin users _should_ be given access to such records, then the security key type can be
|
||||
configured with a `nullValueBehaviorKeyName` (e.g., `"clientIdNullValueBehavior"`), which can be set per-user to allow
|
||||
access to records, overriding the table lock's specified `nullValueBehavior`.
|
||||
*** This could also be done by giving internal/admin users an `allAccessKey`, but sometimes that is not what is required.
|
||||
|
||||
* Maybe a warehouse locations table is assigned a `clientId` once inventory for a client is placed in the location,
|
||||
at which point in time, only the client's users should be allowed to see the record.
|
||||
But, if no client has been assigned to the location, and `clientId` is `null`,
|
||||
then you may want to allow any user to see such records.
|
||||
** This can be accomplished with a record security lock on the Warehouse Locations table, with a `nullValueBehavior` of `ALLOW`.
|
||||
|
||||
=== QSecurityKeyType
|
||||
A Security Key Type is defined in a QQQ Instance in a `*QSecurityKeyType*` object.
|
||||
|
||||
*QSecurityKeyType Properties:*
|
||||
|
||||
* `name` - *String, Required* - Unique name for this security key type within the QQQ Instance.
|
||||
* `allAccessKeyName` - *String* - Optional name of the all-access security key associated with this key type.
|
||||
* `nullValueBehaviorKeyName` - *String* - Optional name of the null-value-behavior overriding security key associated with this key type.
|
||||
** Note, `name`, `allAccessKeyName`, and `nullValueBehaviorKeyName` are all checked against each other for uniqueness.
|
||||
A `QInstanceValidationException` will be thrown if any name collisions occur.
|
||||
* `possibleValueSourceName` - *String* - Optional reference to a possible value source from which value for the key can come.
|
||||
|
||||
|
@ -1,15 +1,16 @@
|
||||
== QQQ Tables
|
||||
[#Tables]
|
||||
== Tables
|
||||
include::../variables.adoc[]
|
||||
|
||||
The core type of object in a QQQ Instance is the Table.
|
||||
One of the most common types of object in a QQQ Instance is the Table.
|
||||
In the most common use-case, a QQQ Table may be the in-app representation of a Database table.
|
||||
That is, it is a collection of records (or rows) of data, each of which has a set of fields (or columns).
|
||||
|
||||
QQQ also allows other types of data sources ({link-backends}) to be used as tables, such as File systems, API's, Java enums or objects, etc.
|
||||
QQQ also allows other types of data sources ({link-backends}) to be used as tables, such as File systems, API's, etc.
|
||||
All of these backend types present the same interfaces (both user-interfaces, and application programming interfaces), regardless of their backend type.
|
||||
|
||||
=== QTableMetaData
|
||||
Tables are defined in a QQQ Instance in a `*QTableMetaData*` object.
|
||||
Tables are defined in a QQQ Instance in `*QTableMetaData*` objects.
|
||||
All tables must reference a {link-backend}, a list of fields that define the shape of records in the table, and additional data to describe how to work with the table within its backend.
|
||||
|
||||
*QTableMetaData Properties:*
|
||||
@ -19,14 +20,37 @@ All tables must reference a {link-backend}, a list of fields that define the sha
|
||||
Inferred from `name` if not set.
|
||||
* `backendName` - *String, Required* - Name of a {link-backend} in which this table's data is managed.
|
||||
* `fields` - *Map of String → {link-field}, Required* - The columns of data that make up all records in this table.
|
||||
* `primaryKeyField` - *String, Conditional* - Name of a {link-field} that serves as the primary key (e.g., unique identifier) for records in this table.
|
||||
* `uniqueKeys` - *List of UniqueKey* - Definition of additional unique constraints (from an RDBMS point of view) from the table.
|
||||
* `primaryKeyField` - *String, Conditional* - Name of a {link-field} that serves as the primary key (unique identifier) for records in this table.
|
||||
** Whether a primary key field is required or not depends on the backend type that the table belongs to.
|
||||
* `uniqueKeys` - *List of UniqueKey* - Definition of additional unique keys or constraints (from an RDBMS point of view) from the table.
|
||||
e.g., sets of columns which must have unique values for each record in the table.
|
||||
The properties of the `UniqueKey` object are:
|
||||
** `fieldNames` - *List of String, Required* - List of field names from this table.
|
||||
** `label` - *String* - Optional label to be shown to users with error messages (e.g., for violation of this unique key).
|
||||
* `backendDetails` - *QTableBackendDetails or subclass* - Additional data to configure the table within its {link-backend}.
|
||||
* `automationDetails` - *QTableAutomationDetails* - Configuration of automated jobs that run against records in the table, e.g., upon insert or update.
|
||||
** For example, for an RDBMS-type backend, the name of the table within the database.
|
||||
** vs. a FileSystem backend, this may be the sub-path where files for the table are stored.
|
||||
** #todo - details on these#
|
||||
* `automationDetails` - *<<QTableAutomationDetails>>* - Configuration of automated jobs that run against records in the table, e.g., upon insert or update.
|
||||
* `customizers` - *Map of String → QCodeReference* - References to custom code that are injected into standard table actions, that allow applications to customize certain parts of how the table works.
|
||||
** Allowed values for keys in this map come from the `role` property of the `TableCustomizers` enum.
|
||||
** Based on the key in this map, the `QCodeReference` used as the value must be of the appropriate java type, as specified in the `expectedType` property of the `TableCustomizers` enum value corresponding to the key.
|
||||
** Example:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
// in defining a QTableMetaData, a customizer can be added as:
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(MyPreInsCustomizer.class))
|
||||
|
||||
// where MyPreInsCustomizer would be defined as:
|
||||
public class MyPreInsCustomizer extends AbstractPreInsertCustomizer
|
||||
----
|
||||
|
||||
|
||||
* `isHidden` - *Boolean, default false* - Option to hide the table from all User Interfaces.
|
||||
* `parentAppName` - *String* - Name of a {link-app} that this table exists within.
|
||||
* `icon` - *QIcon* - Icon associated with this table in certain user interfaces.
|
||||
** This field generally does not need to be set on the table when it is defined, but rather, is set when the table gets placed within an app.
|
||||
* `icon` - *QIcon* - Icon associated with this table in certain user interfaces. See {link-icons}.
|
||||
* `recordLabelFormat` - *String* - Java Format String, used with `recordLabelFields` to produce a label shown for records from the table.
|
||||
* `recordLabelFields` - *List of String, Conditional* - Used with `recordLabelFormat` to provide values for any format specifiers in the format string.
|
||||
These strings must be field names within the table.
|
||||
@ -42,8 +66,219 @@ new QFieldMetaData("birthDate", QFieldType.DATE)
|
||||
.withRecordLabelFormat("%s (%s)")
|
||||
.withRecordLabelFields(List.of("name", "birthDate"))
|
||||
----
|
||||
* `sections` - *List of QFieldSection* - Mechanism to organize fields within user interfaces, into logical sections.
|
||||
|
||||
* `sections` - *List of <<QFieldSection>>* - Mechanism to organize fields within user interfaces, into logical sections.
|
||||
If any sections are present in the table meta data, then all fields in the table must be listed in exactly 1 section.
|
||||
If no sections are defined, then instance enrichment will define default sections.
|
||||
* `associatedScripts` - *List of AssociatedScript* - Definition of user-defined scripts that can be associated with records within the table.
|
||||
* `associatedScripts` - *List of <<AssociatedScript>>* - Definition of user-defined scripts that can be associated with records within the table.
|
||||
* `enabledCapabilities` and `disabledCapabilities` - *Set of Capability enum values* - Overrides from the backend level, for capabilities that this table does or does not possess.
|
||||
* `associations` - *List of <<Association>>* - tables whose records can be managed along with records from this table. See below for details.
|
||||
* `recordSecurityLocks` - *List of <<RecordSecurityLock>>* - locks that apply to records in the table - e.g., to control what users can or cannot access records in the table.
|
||||
See RecordSecurityLock below for details.
|
||||
* `permissionRules` - *QPermissionRules object* - define the permission/access rules for the table.
|
||||
See {link-permissionRules} for details.
|
||||
* `auditRules` - *<<QAuditRules>> object* - define the audit rules for the table.
|
||||
See QAuditRules below for details.
|
||||
* `cacheOf` - *<<CacheOf>> object* - specify that this table serves as a "cache of" another table.
|
||||
See CacheOf object below for details.
|
||||
* `exposedJoins` - *List of <<ExposedJoin>> objects* - optional list of joined tables that are to be exposed in User Interfaces.
|
||||
See ExposedJoin object below for details.
|
||||
|
||||
#todo: supplementalMetaData (API)#
|
||||
|
||||
|
||||
==== QFieldSection
|
||||
When users view records from a QQQ Table in a UI, fields are organized on the screen based on the `QFieldSection` objects in the table's meta-data.
|
||||
|
||||
*QFieldSection Properties:*
|
||||
|
||||
* `name` - *String, Required* - unique identifier for the section within its table.
|
||||
* `label` - *String* - User-facing label for the section, presented in User Interfaces.
|
||||
Inferred from `name` if not set.
|
||||
* `tier` - *enum* - importance of the fields in section for the table.
|
||||
Different tiers may be presented differently in UI's.
|
||||
Only a single `T1` section is allowed per-table. Possible values are: `T1`, `T2`, and `T3`.
|
||||
* `icon` - *QIcon* - Icon associated with this section in certain user interfaces. See {link-icons}.
|
||||
* `isHidden` - *Boolean, default false* - Option to hide the table from all User Interfaces.
|
||||
* `gridColumns` - *Integer* - Option to specify how many columns in a grid layout the section should use.
|
||||
For the Material-Dashboard frontend, this is a grid of 12.
|
||||
* `fieldNames` - *List of String, Conditional* - List of names of {link-fields} from this table to be included in this section.
|
||||
* `widgetName` - *String, Conditional* - Name of a {link-widget} to be displayed in this section.
|
||||
** Note that exactly one of `fieldNames` or `widgetName` must be used.
|
||||
|
||||
==== QTableAutomationDetails
|
||||
Records in QQQ can have application-defined custom actions automatically asynchronously executed against them after they are inserted or updated.
|
||||
The configuration to enable this functionality is assigned to a table in a `QTableAutomationDetails` object.
|
||||
|
||||
*QTableAutomationDetails Properties:*
|
||||
|
||||
* `statusTracking` - *AutomationStatusTracking object, Required* - define how QQQ should keep track, per record, its status (e.g., pending-insert-automations, running-update-automations, etc).
|
||||
Properties of `AutomationStatusTracking` object are:
|
||||
** `type` - *enum, Required* - what type of tracking is used for the table.
|
||||
Possible values are:
|
||||
*** `FIELD_IN_TABLE` - specifies that the table has a field which stores an `AutomationStatus` id.
|
||||
*** _Additional types may be defined in the future, such as ONE_TO_ONE_TABLE or SHARED_TABLE._
|
||||
** `fieldName` - *String, Conditional* - for `type=FIELD_IN_TABLE`, this property specifies the name of the {link-field} in the table that stores the `AutomationStatus` id.
|
||||
* `providerName` - *String, Required* - name of an Automation Provider within the QQQ Instance, which is responsible for running the automations on this table.
|
||||
* `overrideBatchSize` - *Integer* - optional control over how many records from the table are processed in a single batch/page.
|
||||
For tables with "slow" actions (e.g., one that may need to make an API call per-record), using a smaller batch size (say, 50) may be required to avoid timeout errors.
|
||||
* `actions` - *List of TableAutomationAction* - list of the actions to perform on new and updated records in the table.
|
||||
Properties are:
|
||||
** `name` - *String, Required* - unique identifier for the action within its table.
|
||||
** `triggerEvent` - *enum, Required* - indicate which event type (`POST_INSERT`, `POST_UPDATE`, or `PRE_DELETE` (which is not yet implemented)) the action applies to.
|
||||
** `priority` - *Integer, default 500* - mechanism to control the order in which actions on a table are executed, if there are more than one.
|
||||
Actions with a smaller value for `priority` are executed first. Ties are broken in an undefined manner.
|
||||
** `filter` - *QQueryFilter* - optional filter that gets applied to records when they match the `triggerEvent`, to control which records have the action ran against them.
|
||||
** `includeRecordAssociations` - *Boolean, default false* - for tables that have associations, control whether or not a record's associated records are loaded when records are fetched and passed into the action's custom code.
|
||||
** `values` - *Map of String → Serializable* - optional application-defined map of name=value pairs that can be passed into the action's custom code.
|
||||
** `processName` - *String, Conditional* - name of a {link-processes} in the QQQ Instance which is executed as the custom-code of the action.
|
||||
** `codeReference` - *QCodeReference, Conditional* - reference to a class that extends `RecordAutomationHandler`, to be executed as the custom-code of the action.
|
||||
*** Note, exactly one of `processName` or `codeReference` must be provided.
|
||||
|
||||
|
||||
|
||||
==== Association
|
||||
An `Association` is a way to define a relationship between tables, that facilitates, for example, a parent record having a list of its child records included in it when it is returned from a Query.
|
||||
Similarly, associated records can automatically be inserted/updated/deleted if they are included in a parent record when it is stored.
|
||||
|
||||
*Association Properties:*
|
||||
|
||||
* `name` - *String, Required* - unique name for the association within this table.
|
||||
Used as the key in the `associatedRecords` map within `QRecord` objects for this table.
|
||||
* `associatedTableName` - *String, Required* - name of a {link-table}, which is the associated table.
|
||||
* `joinName` - *String, Required* - name of a {link-join} in the instance, which defines how the tables are joined.
|
||||
|
||||
|
||||
|
||||
==== RecordSecurityLock
|
||||
A `RecordSecurityLock` is the mechanism through which users can be allowed or denied access to read and/or write records, based on values in the record, and values in the user's session.
|
||||
Record security locks must correspond to a {link-securityKeyType}.
|
||||
|
||||
For example:
|
||||
|
||||
* An instance may have a security key type called `clientId`.
|
||||
* Users may have 1 or more `clientId` values in their Session, or, they may have an "All Clients" key in their session (e.g., for internal/admin users).
|
||||
* For some tables, it may be required to limit visibility to records based on a user's `clientId` key.
|
||||
To do this. a *RecordSecurityLock* would be applied to the table, specifying the `clientId` field corresponds to the `clientId` security key.
|
||||
* With these settings in place, QQQ will prevent users from viewing records from this table that do not have a matching key, and will similarly prevent users from writing records with an invalid key value.
|
||||
** For example, in an RDBMS backend, all `SELECT` statements generated against such a table will have an implicit filter, such as `AND client_id = ?` based on the user's security key values.
|
||||
|
||||
*RecordSecurityLock Properties:*
|
||||
|
||||
* `securityKeyType` - *String, Required* - name of a {link-securityKeyType} in the Instance.
|
||||
* `fieldName` - *String, Required* - name of a {link-field} in this table (or a joined table, if `joinNameChain` is set), where the value for the lock is stored.
|
||||
* `joinNameChain` - *List of String* - if the lock value is not stored in this table, but rather comes from a joined table, then this property defines the path of joins from this table to the table with the lock field.
|
||||
* `nullValueBehavior` - *enum, default: DENY* - control how records with a `null` value in the lock field should behave.
|
||||
Possible values are:
|
||||
** `DENY` - deny all users access to a record with a `null` value in the lock field (unless the user has an all-access key - see {link-securityKeyType})
|
||||
** `ALLOW` - allow all users access to a record with a `null` value in the lock field.
|
||||
** `ALLOW_WRITE_ONLY` - allow all users to write records with `null` in the lock field, but deny reads on records with `null` in the lock field (also excepted by all-access keys).
|
||||
* `lockScope` - *enum, default: READ_AND_WRITE* - control what types of operations the lock applies to.
|
||||
Possible values are:
|
||||
** `READ_AND_WRITE` - control both reading and writing records based on the user having an appropriate security key.
|
||||
** `WRITE` - allow all users to read the record, but limit writes to users with an appropriate security key.
|
||||
|
||||
|
||||
|
||||
==== QAuditRules
|
||||
The audit rules on a table define the level of detail that is automatically stored in the audit table (if any) for DML actions (Insert, Update, Delete).
|
||||
|
||||
*QAuditRules Properties:*
|
||||
|
||||
* `auditLevel` - *enum, Required* - level of details that are audited.
|
||||
Possible values are:
|
||||
** `NONE` - no automatic audits are stored for the table.
|
||||
** `RECORD` - only record-level audits are stored for the table (e.g., a message such as "record was edited", but without field-level details)
|
||||
** `FIELD` - full field-level audits are stored (e.g., including all old & new values as audit details).
|
||||
|
||||
|
||||
|
||||
==== CacheOf
|
||||
One QQQ Table can be defined as a "cache of" another QQQ Table by assigning a `CacheOf` object to the table which will function as the cache.
|
||||
_Note, at this time, only limited use-cases are supported._
|
||||
|
||||
*CacheOf Properties:*
|
||||
|
||||
* `sourceTable` - *String, Required* - name of the other QQQ Table that is the source of data in this cache.
|
||||
* `expirationSeconds` - *Integer* - optional number of seconds that a cached record is allowed to exist before it is considered expired, and must be re-fetched from the source table.
|
||||
* `cachedDateFieldName` - *String, Conditional* - used with `expirationSeconds` to define the field in this table that is used for storing the timestamp for when the record was cached.
|
||||
* `useCases` - *List of CacheUseCase* - what caching use-cases are to be implemented.
|
||||
|
||||
Properties of *CacheUseCase* are:
|
||||
|
||||
* `type` - *Enum, Required* - the type of use-case. Possible values are:
|
||||
** `PRIMARY_KEY_TO_PRIMARY_KEY` - the primary key in the cache table equals the primary key in the source table.
|
||||
** `UNIQUE_KEY_TO_PRIMARY_KEY` - a unique key in the cache table equals the primary key in the source table.
|
||||
** `UNIQUE_KEY_TO_UNIQUE_KEY` - a unique key in the cache table equals a unique key in the source table.
|
||||
* `cacheSourceMisses` - *Boolean, default false* - whether or not, if a "miss" happens in the source, if that fact gets cached.
|
||||
* `cacheUniqueKey` - *UniqueKey, conditional* - define the fields in the cache table that define the unique key being used as the cache key.
|
||||
* `sourceUniqueKey` - *UniqueKey, conditional* - define the fields in the source table that define the unique key being used as the cache key.
|
||||
* `doCopySourcePrimaryKeyToCache` - *Boolean, default false* - specify whether or not the value of the primary key in the source table should be copied into records built in the cache table.
|
||||
* `excludeRecordsMatching` - *List of QQueryFilter* - optional filter to be applied to records before they are cached.
|
||||
If a record matches the filter, then it will not be cached.
|
||||
|
||||
|
||||
==== ExposedJoin
|
||||
Query screens in QQQ applications can potentially allow users to both display fields from joined tables, and filter by fields from joined tables, for any {link-join} explicitly defined as an *Exposed Join*.
|
||||
|
||||
_The reasoning why not all joins are implicitly exposed is that in many applications, the full join-graph can sometimes be overwhelming, surprisingly broad, and not necessarily practically useful.
|
||||
This could be subject to change in the future, e.g., given a UI that allowed users to more explicitly add additional join tables..._
|
||||
|
||||
*ExposedJoin Properties:*
|
||||
|
||||
* `label` - *String, Required* - how the joined table should be presented in the UI.
|
||||
* `joinTable` - *String, Required* - name of the QQQ Table that is joined to this table, and is being exposed as a join in the UI.
|
||||
* `joinPath` - *List of String, Required* - names of 1 or more QQQ Joins that describe how to get from this table to the join table.
|
||||
|
||||
|
||||
|
||||
==== AssociatedScript
|
||||
A QQQ Table can have end-user defined Script records associated with individual records in the table by use of the `associatedScripts` property of the table's meta-data.
|
||||
|
||||
The "types" of these scripts (e.g., how they are used in an application) are wholly application-designed & managed.
|
||||
QQQ provides the mechanism for UI's to present and manage such scripts (e.g., the *Developer Mode* screen in the Material Dashboard), as well as an interface to load & execute such scripts `RunAssociatedScriptAction`).
|
||||
|
||||
*AssociatedScript Properties:*
|
||||
|
||||
* `fieldName` - *String, Required* - name of a {link-field} in the table which stores the id of the associated script record.
|
||||
* `scriptTypeId` - *Serializable (typically Integer), Required* - primary key value from the `"scriptType"` table in the instance, to designate the type of the Script.
|
||||
* `scriptTester` - *QCodeReference* - reference to a class which implements `TestScriptActionInterface`, that can be used by UI's for running an associated script to test it.
|
||||
|
||||
|
||||
|
||||
=== Supplemental Meta Data
|
||||
==== QQQ Frontend Material Dashboard
|
||||
When running a QQQ application with the QQQ Frontend Material Dashboard module (QFMD),
|
||||
there are various pieces of supplemental meta-data which can be assigned to a Table,
|
||||
to modify some behaviors for the table in this UI.
|
||||
|
||||
===== Default Quick Filter Field Names
|
||||
QFMD's table query has a "Basic" mode, which will always display a subset of the table's fields as quick-access filters.
|
||||
By default, the "Tier 1" fields on a table (e.g., fields in a Section that is marked as T1) will be used for this purpose.
|
||||
|
||||
However, you can customize which fields are shown as the default quick-filter fields, by providing a list of field names in a
|
||||
`MaterialDashboardTableMetaData` object, placed in the table's `supplementalMetaData`.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
table.withSupplementalMetaData(new MaterialDashboardTableMetaData()
|
||||
.withDefaultQuickFilterFieldNames(List.of("id", "warehouseId", "statusId", "orderDate")));
|
||||
----
|
||||
|
||||
===== Go To Field Names
|
||||
QFMD has a feature where a table's query screen can include a "Go To" button,
|
||||
which a user can hit to open a modal popup, into which the user can enter a record's identifier,
|
||||
to be brought directly to the record matching that identifier.
|
||||
|
||||
To use this feature, the table must have a List of `GotoFieldNames` set in its
|
||||
`MaterialDashboardTableMetaData` object in the table's `supplementalMetaData`.
|
||||
|
||||
Each entry in this list is actually a list of fields, e.g., to account for a multi-value unique-key.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
table.withSupplementalMetaData(new MaterialDashboardTableMetaData()
|
||||
.withGotoFieldNames(List.of(
|
||||
List.of("id"),
|
||||
List.of("partnerName", "partnerOrderId"))));
|
||||
----
|
||||
|
@ -1,553 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="generator" content="Asciidoctor 2.0.18">
|
||||
<title>QQQ Tables</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:300,300italic,400,400italic,600,600italic%7CNoto+Serif:400,400italic,700,700italic%7CDroid+Sans+Mono:400,700">
|
||||
<style>
|
||||
/*! Asciidoctor default stylesheet | MIT License | https://asciidoctor.org */
|
||||
/* Uncomment the following line when using as a custom stylesheet */
|
||||
/* @import "https://fonts.googleapis.com/css?family=Open+Sans:300,300italic,400,400italic,600,600italic%7CNoto+Serif:400,400italic,700,700italic%7CDroid+Sans+Mono:400,700"; */
|
||||
html{font-family:sans-serif;-webkit-text-size-adjust:100%}
|
||||
a{background:none}
|
||||
a:focus{outline:thin dotted}
|
||||
a:active,a:hover{outline:0}
|
||||
h1{font-size:2em;margin:.67em 0}
|
||||
b,strong{font-weight:bold}
|
||||
abbr{font-size:.9em}
|
||||
abbr[title]{cursor:help;border-bottom:1px dotted #dddddf;text-decoration:none}
|
||||
dfn{font-style:italic}
|
||||
hr{height:0}
|
||||
mark{background:#ff0;color:#000}
|
||||
code,kbd,pre,samp{font-family:monospace;font-size:1em}
|
||||
pre{white-space:pre-wrap}
|
||||
q{quotes:"\201C" "\201D" "\2018" "\2019"}
|
||||
small{font-size:80%}
|
||||
sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
|
||||
sup{top:-.5em}
|
||||
sub{bottom:-.25em}
|
||||
img{border:0}
|
||||
svg:not(:root){overflow:hidden}
|
||||
figure{margin:0}
|
||||
audio,video{display:inline-block}
|
||||
audio:not([controls]){display:none;height:0}
|
||||
fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}
|
||||
legend{border:0;padding:0}
|
||||
button,input,select,textarea{font-family:inherit;font-size:100%;margin:0}
|
||||
button,input{line-height:normal}
|
||||
button,select{text-transform:none}
|
||||
button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}
|
||||
button[disabled],html input[disabled]{cursor:default}
|
||||
input[type=checkbox],input[type=radio]{padding:0}
|
||||
button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}
|
||||
textarea{overflow:auto;vertical-align:top}
|
||||
table{border-collapse:collapse;border-spacing:0}
|
||||
*,::before,::after{box-sizing:border-box}
|
||||
html,body{font-size:100%}
|
||||
body{background:#fff;color:rgba(0,0,0,.8);padding:0;margin:0;font-family:"Noto Serif","DejaVu Serif",serif;line-height:1;position:relative;cursor:auto;-moz-tab-size:4;-o-tab-size:4;tab-size:4;word-wrap:anywhere;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased}
|
||||
a:hover{cursor:pointer}
|
||||
img,object,embed{max-width:100%;height:auto}
|
||||
object,embed{height:100%}
|
||||
img{-ms-interpolation-mode:bicubic}
|
||||
.left{float:left!important}
|
||||
.right{float:right!important}
|
||||
.text-left{text-align:left!important}
|
||||
.text-right{text-align:right!important}
|
||||
.text-center{text-align:center!important}
|
||||
.text-justify{text-align:justify!important}
|
||||
.hide{display:none}
|
||||
img,object,svg{display:inline-block;vertical-align:middle}
|
||||
textarea{height:auto;min-height:50px}
|
||||
select{width:100%}
|
||||
.subheader,.admonitionblock td.content>.title,.audioblock>.title,.exampleblock>.title,.imageblock>.title,.listingblock>.title,.literalblock>.title,.stemblock>.title,.openblock>.title,.paragraph>.title,.quoteblock>.title,table.tableblock>.title,.verseblock>.title,.videoblock>.title,.dlist>.title,.olist>.title,.ulist>.title,.qlist>.title,.hdlist>.title{line-height:1.45;color:#7a2518;font-weight:400;margin-top:0;margin-bottom:.25em}
|
||||
div,dl,dt,dd,ul,ol,li,h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6,pre,form,p,blockquote,th,td{margin:0;padding:0}
|
||||
a{color:#2156a5;text-decoration:underline;line-height:inherit}
|
||||
a:hover,a:focus{color:#1d4b8f}
|
||||
a img{border:0}
|
||||
p{line-height:1.6;margin-bottom:1.25em;text-rendering:optimizeLegibility}
|
||||
p aside{font-size:.875em;line-height:1.35;font-style:italic}
|
||||
h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6{font-family:"Open Sans","DejaVu Sans",sans-serif;font-weight:300;font-style:normal;color:#ba3925;text-rendering:optimizeLegibility;margin-top:1em;margin-bottom:.5em;line-height:1.0125em}
|
||||
h1 small,h2 small,h3 small,#toctitle small,.sidebarblock>.content>.title small,h4 small,h5 small,h6 small{font-size:60%;color:#e99b8f;line-height:0}
|
||||
h1{font-size:2.125em}
|
||||
h2{font-size:1.6875em}
|
||||
h3,#toctitle,.sidebarblock>.content>.title{font-size:1.375em}
|
||||
h4,h5{font-size:1.125em}
|
||||
h6{font-size:1em}
|
||||
hr{border:solid #dddddf;border-width:1px 0 0;clear:both;margin:1.25em 0 1.1875em}
|
||||
em,i{font-style:italic;line-height:inherit}
|
||||
strong,b{font-weight:bold;line-height:inherit}
|
||||
small{font-size:60%;line-height:inherit}
|
||||
code{font-family:"Droid Sans Mono","DejaVu Sans Mono",monospace;font-weight:400;color:rgba(0,0,0,.9)}
|
||||
ul,ol,dl{line-height:1.6;margin-bottom:1.25em;list-style-position:outside;font-family:inherit}
|
||||
ul,ol{margin-left:1.5em}
|
||||
ul li ul,ul li ol{margin-left:1.25em;margin-bottom:0}
|
||||
ul.circle{list-style-type:circle}
|
||||
ul.disc{list-style-type:disc}
|
||||
ul.square{list-style-type:square}
|
||||
ul.circle ul:not([class]),ul.disc ul:not([class]),ul.square ul:not([class]){list-style:inherit}
|
||||
ol li ul,ol li ol{margin-left:1.25em;margin-bottom:0}
|
||||
dl dt{margin-bottom:.3125em;font-weight:bold}
|
||||
dl dd{margin-bottom:1.25em}
|
||||
blockquote{margin:0 0 1.25em;padding:.5625em 1.25em 0 1.1875em;border-left:1px solid #ddd}
|
||||
blockquote,blockquote p{line-height:1.6;color:rgba(0,0,0,.85)}
|
||||
@media screen and (min-width:768px){h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6{line-height:1.2}
|
||||
h1{font-size:2.75em}
|
||||
h2{font-size:2.3125em}
|
||||
h3,#toctitle,.sidebarblock>.content>.title{font-size:1.6875em}
|
||||
h4{font-size:1.4375em}}
|
||||
table{background:#fff;margin-bottom:1.25em;border:1px solid #dedede;word-wrap:normal}
|
||||
table thead,table tfoot{background:#f7f8f7}
|
||||
table thead tr th,table thead tr td,table tfoot tr th,table tfoot tr td{padding:.5em .625em .625em;font-size:inherit;color:rgba(0,0,0,.8);text-align:left}
|
||||
table tr th,table tr td{padding:.5625em .625em;font-size:inherit;color:rgba(0,0,0,.8)}
|
||||
table tr.even,table tr.alt{background:#f8f8f7}
|
||||
table thead tr th,table tfoot tr th,table tbody tr td,table tr td,table tfoot tr td{line-height:1.6}
|
||||
h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6{line-height:1.2;word-spacing:-.05em}
|
||||
h1 strong,h2 strong,h3 strong,#toctitle strong,.sidebarblock>.content>.title strong,h4 strong,h5 strong,h6 strong{font-weight:400}
|
||||
.center{margin-left:auto;margin-right:auto}
|
||||
.stretch{width:100%}
|
||||
.clearfix::before,.clearfix::after,.float-group::before,.float-group::after{content:" ";display:table}
|
||||
.clearfix::after,.float-group::after{clear:both}
|
||||
:not(pre).nobreak{word-wrap:normal}
|
||||
:not(pre).nowrap{white-space:nowrap}
|
||||
:not(pre).pre-wrap{white-space:pre-wrap}
|
||||
:not(pre):not([class^=L])>code{font-size:.9375em;font-style:normal!important;letter-spacing:0;padding:.1em .5ex;word-spacing:-.15em;background:#f7f7f8;border-radius:4px;line-height:1.45;text-rendering:optimizeSpeed}
|
||||
pre{color:rgba(0,0,0,.9);font-family:"Droid Sans Mono","DejaVu Sans Mono",monospace;line-height:1.45;text-rendering:optimizeSpeed}
|
||||
pre code,pre pre{color:inherit;font-size:inherit;line-height:inherit}
|
||||
pre>code{display:block}
|
||||
pre.nowrap,pre.nowrap pre{white-space:pre;word-wrap:normal}
|
||||
em em{font-style:normal}
|
||||
strong strong{font-weight:400}
|
||||
.keyseq{color:rgba(51,51,51,.8)}
|
||||
kbd{font-family:"Droid Sans Mono","DejaVu Sans Mono",monospace;display:inline-block;color:rgba(0,0,0,.8);font-size:.65em;line-height:1.45;background:#f7f7f7;border:1px solid #ccc;border-radius:3px;box-shadow:0 1px 0 rgba(0,0,0,.2),inset 0 0 0 .1em #fff;margin:0 .15em;padding:.2em .5em;vertical-align:middle;position:relative;top:-.1em;white-space:nowrap}
|
||||
.keyseq kbd:first-child{margin-left:0}
|
||||
.keyseq kbd:last-child{margin-right:0}
|
||||
.menuseq,.menuref{color:#000}
|
||||
.menuseq b:not(.caret),.menuref{font-weight:inherit}
|
||||
.menuseq{word-spacing:-.02em}
|
||||
.menuseq b.caret{font-size:1.25em;line-height:.8}
|
||||
.menuseq i.caret{font-weight:bold;text-align:center;width:.45em}
|
||||
b.button::before,b.button::after{position:relative;top:-1px;font-weight:400}
|
||||
b.button::before{content:"[";padding:0 3px 0 2px}
|
||||
b.button::after{content:"]";padding:0 2px 0 3px}
|
||||
p a>code:hover{color:rgba(0,0,0,.9)}
|
||||
#header,#content,#footnotes,#footer{width:100%;margin:0 auto;max-width:62.5em;*zoom:1;position:relative;padding-left:.9375em;padding-right:.9375em}
|
||||
#header::before,#header::after,#content::before,#content::after,#footnotes::before,#footnotes::after,#footer::before,#footer::after{content:" ";display:table}
|
||||
#header::after,#content::after,#footnotes::after,#footer::after{clear:both}
|
||||
#content{margin-top:1.25em}
|
||||
#content::before{content:none}
|
||||
#header>h1:first-child{color:rgba(0,0,0,.85);margin-top:2.25rem;margin-bottom:0}
|
||||
#header>h1:first-child+#toc{margin-top:8px;border-top:1px solid #dddddf}
|
||||
#header>h1:only-child,body.toc2 #header>h1:nth-last-child(2){border-bottom:1px solid #dddddf;padding-bottom:8px}
|
||||
#header .details{border-bottom:1px solid #dddddf;line-height:1.45;padding-top:.25em;padding-bottom:.25em;padding-left:.25em;color:rgba(0,0,0,.6);display:flex;flex-flow:row wrap}
|
||||
#header .details span:first-child{margin-left:-.125em}
|
||||
#header .details span.email a{color:rgba(0,0,0,.85)}
|
||||
#header .details br{display:none}
|
||||
#header .details br+span::before{content:"\00a0\2013\00a0"}
|
||||
#header .details br+span.author::before{content:"\00a0\22c5\00a0";color:rgba(0,0,0,.85)}
|
||||
#header .details br+span#revremark::before{content:"\00a0|\00a0"}
|
||||
#header #revnumber{text-transform:capitalize}
|
||||
#header #revnumber::after{content:"\00a0"}
|
||||
#content>h1:first-child:not([class]){color:rgba(0,0,0,.85);border-bottom:1px solid #dddddf;padding-bottom:8px;margin-top:0;padding-top:1rem;margin-bottom:1.25rem}
|
||||
#toc{border-bottom:1px solid #e7e7e9;padding-bottom:.5em}
|
||||
#toc>ul{margin-left:.125em}
|
||||
#toc ul.sectlevel0>li>a{font-style:italic}
|
||||
#toc ul.sectlevel0 ul.sectlevel1{margin:.5em 0}
|
||||
#toc ul{font-family:"Open Sans","DejaVu Sans",sans-serif;list-style-type:none}
|
||||
#toc li{line-height:1.3334;margin-top:.3334em}
|
||||
#toc a{text-decoration:none}
|
||||
#toc a:active{text-decoration:underline}
|
||||
#toctitle{color:#7a2518;font-size:1.2em}
|
||||
@media screen and (min-width:768px){#toctitle{font-size:1.375em}
|
||||
body.toc2{padding-left:15em;padding-right:0}
|
||||
#toc.toc2{margin-top:0!important;background:#f8f8f7;position:fixed;width:15em;left:0;top:0;border-right:1px solid #e7e7e9;border-top-width:0!important;border-bottom-width:0!important;z-index:1000;padding:1.25em 1em;height:100%;overflow:auto}
|
||||
#toc.toc2 #toctitle{margin-top:0;margin-bottom:.8rem;font-size:1.2em}
|
||||
#toc.toc2>ul{font-size:.9em;margin-bottom:0}
|
||||
#toc.toc2 ul ul{margin-left:0;padding-left:1em}
|
||||
#toc.toc2 ul.sectlevel0 ul.sectlevel1{padding-left:0;margin-top:.5em;margin-bottom:.5em}
|
||||
body.toc2.toc-right{padding-left:0;padding-right:15em}
|
||||
body.toc2.toc-right #toc.toc2{border-right-width:0;border-left:1px solid #e7e7e9;left:auto;right:0}}
|
||||
@media screen and (min-width:1280px){body.toc2{padding-left:20em;padding-right:0}
|
||||
#toc.toc2{width:20em}
|
||||
#toc.toc2 #toctitle{font-size:1.375em}
|
||||
#toc.toc2>ul{font-size:.95em}
|
||||
#toc.toc2 ul ul{padding-left:1.25em}
|
||||
body.toc2.toc-right{padding-left:0;padding-right:20em}}
|
||||
#content #toc{border:1px solid #e0e0dc;margin-bottom:1.25em;padding:1.25em;background:#f8f8f7;border-radius:4px}
|
||||
#content #toc>:first-child{margin-top:0}
|
||||
#content #toc>:last-child{margin-bottom:0}
|
||||
#footer{max-width:none;background:rgba(0,0,0,.8);padding:1.25em}
|
||||
#footer-text{color:hsla(0,0%,100%,.8);line-height:1.44}
|
||||
#content{margin-bottom:.625em}
|
||||
.sect1{padding-bottom:.625em}
|
||||
@media screen and (min-width:768px){#content{margin-bottom:1.25em}
|
||||
.sect1{padding-bottom:1.25em}}
|
||||
.sect1:last-child{padding-bottom:0}
|
||||
.sect1+.sect1{border-top:1px solid #e7e7e9}
|
||||
#content h1>a.anchor,h2>a.anchor,h3>a.anchor,#toctitle>a.anchor,.sidebarblock>.content>.title>a.anchor,h4>a.anchor,h5>a.anchor,h6>a.anchor{position:absolute;z-index:1001;width:1.5ex;margin-left:-1.5ex;display:block;text-decoration:none!important;visibility:hidden;text-align:center;font-weight:400}
|
||||
#content h1>a.anchor::before,h2>a.anchor::before,h3>a.anchor::before,#toctitle>a.anchor::before,.sidebarblock>.content>.title>a.anchor::before,h4>a.anchor::before,h5>a.anchor::before,h6>a.anchor::before{content:"\00A7";font-size:.85em;display:block;padding-top:.1em}
|
||||
#content h1:hover>a.anchor,#content h1>a.anchor:hover,h2:hover>a.anchor,h2>a.anchor:hover,h3:hover>a.anchor,#toctitle:hover>a.anchor,.sidebarblock>.content>.title:hover>a.anchor,h3>a.anchor:hover,#toctitle>a.anchor:hover,.sidebarblock>.content>.title>a.anchor:hover,h4:hover>a.anchor,h4>a.anchor:hover,h5:hover>a.anchor,h5>a.anchor:hover,h6:hover>a.anchor,h6>a.anchor:hover{visibility:visible}
|
||||
#content h1>a.link,h2>a.link,h3>a.link,#toctitle>a.link,.sidebarblock>.content>.title>a.link,h4>a.link,h5>a.link,h6>a.link{color:#ba3925;text-decoration:none}
|
||||
#content h1>a.link:hover,h2>a.link:hover,h3>a.link:hover,#toctitle>a.link:hover,.sidebarblock>.content>.title>a.link:hover,h4>a.link:hover,h5>a.link:hover,h6>a.link:hover{color:#a53221}
|
||||
details,.audioblock,.imageblock,.literalblock,.listingblock,.stemblock,.videoblock{margin-bottom:1.25em}
|
||||
details{margin-left:1.25rem}
|
||||
details>summary{cursor:pointer;display:block;position:relative;line-height:1.6;margin-bottom:.625rem;outline:none;-webkit-tap-highlight-color:transparent}
|
||||
details>summary::-webkit-details-marker{display:none}
|
||||
details>summary::before{content:"";border:solid transparent;border-left:solid;border-width:.3em 0 .3em .5em;position:absolute;top:.5em;left:-1.25rem;transform:translateX(15%)}
|
||||
details[open]>summary::before{border:solid transparent;border-top:solid;border-width:.5em .3em 0;transform:translateY(15%)}
|
||||
details>summary::after{content:"";width:1.25rem;height:1em;position:absolute;top:.3em;left:-1.25rem}
|
||||
.admonitionblock td.content>.title,.audioblock>.title,.exampleblock>.title,.imageblock>.title,.listingblock>.title,.literalblock>.title,.stemblock>.title,.openblock>.title,.paragraph>.title,.quoteblock>.title,table.tableblock>.title,.verseblock>.title,.videoblock>.title,.dlist>.title,.olist>.title,.ulist>.title,.qlist>.title,.hdlist>.title{text-rendering:optimizeLegibility;text-align:left;font-family:"Noto Serif","DejaVu Serif",serif;font-size:1rem;font-style:italic}
|
||||
table.tableblock.fit-content>caption.title{white-space:nowrap;width:0}
|
||||
.paragraph.lead>p,#preamble>.sectionbody>[class=paragraph]:first-of-type p{font-size:1.21875em;line-height:1.6;color:rgba(0,0,0,.85)}
|
||||
.admonitionblock>table{border-collapse:separate;border:0;background:none;width:100%}
|
||||
.admonitionblock>table td.icon{text-align:center;width:80px}
|
||||
.admonitionblock>table td.icon img{max-width:none}
|
||||
.admonitionblock>table td.icon .title{font-weight:bold;font-family:"Open Sans","DejaVu Sans",sans-serif;text-transform:uppercase}
|
||||
.admonitionblock>table td.content{padding-left:1.125em;padding-right:1.25em;border-left:1px solid #dddddf;color:rgba(0,0,0,.6);word-wrap:anywhere}
|
||||
.admonitionblock>table td.content>:last-child>:last-child{margin-bottom:0}
|
||||
.exampleblock>.content{border:1px solid #e6e6e6;margin-bottom:1.25em;padding:1.25em;background:#fff;border-radius:4px}
|
||||
.exampleblock>.content>:first-child{margin-top:0}
|
||||
.exampleblock>.content>:last-child{margin-bottom:0}
|
||||
.sidebarblock{border:1px solid #dbdbd6;margin-bottom:1.25em;padding:1.25em;background:#f3f3f2;border-radius:4px}
|
||||
.sidebarblock>:first-child{margin-top:0}
|
||||
.sidebarblock>:last-child{margin-bottom:0}
|
||||
.sidebarblock>.content>.title{color:#7a2518;margin-top:0;text-align:center}
|
||||
.exampleblock>.content>:last-child>:last-child,.exampleblock>.content .olist>ol>li:last-child>:last-child,.exampleblock>.content .ulist>ul>li:last-child>:last-child,.exampleblock>.content .qlist>ol>li:last-child>:last-child,.sidebarblock>.content>:last-child>:last-child,.sidebarblock>.content .olist>ol>li:last-child>:last-child,.sidebarblock>.content .ulist>ul>li:last-child>:last-child,.sidebarblock>.content .qlist>ol>li:last-child>:last-child{margin-bottom:0}
|
||||
.literalblock pre,.listingblock>.content>pre{border-radius:4px;overflow-x:auto;padding:1em;font-size:.8125em}
|
||||
@media screen and (min-width:768px){.literalblock pre,.listingblock>.content>pre{font-size:.90625em}}
|
||||
@media screen and (min-width:1280px){.literalblock pre,.listingblock>.content>pre{font-size:1em}}
|
||||
.literalblock pre,.listingblock>.content>pre:not(.highlight),.listingblock>.content>pre[class=highlight],.listingblock>.content>pre[class^="highlight "]{background:#f7f7f8}
|
||||
.literalblock.output pre{color:#f7f7f8;background:rgba(0,0,0,.9)}
|
||||
.listingblock>.content{position:relative}
|
||||
.listingblock code[data-lang]::before{display:none;content:attr(data-lang);position:absolute;font-size:.75em;top:.425rem;right:.5rem;line-height:1;text-transform:uppercase;color:inherit;opacity:.5}
|
||||
.listingblock:hover code[data-lang]::before{display:block}
|
||||
.listingblock.terminal pre .command::before{content:attr(data-prompt);padding-right:.5em;color:inherit;opacity:.5}
|
||||
.listingblock.terminal pre .command:not([data-prompt])::before{content:"$"}
|
||||
.listingblock pre.highlightjs{padding:0}
|
||||
.listingblock pre.highlightjs>code{padding:1em;border-radius:4px}
|
||||
.listingblock pre.prettyprint{border-width:0}
|
||||
.prettyprint{background:#f7f7f8}
|
||||
pre.prettyprint .linenums{line-height:1.45;margin-left:2em}
|
||||
pre.prettyprint li{background:none;list-style-type:inherit;padding-left:0}
|
||||
pre.prettyprint li code[data-lang]::before{opacity:1}
|
||||
pre.prettyprint li:not(:first-child) code[data-lang]::before{display:none}
|
||||
table.linenotable{border-collapse:separate;border:0;margin-bottom:0;background:none}
|
||||
table.linenotable td[class]{color:inherit;vertical-align:top;padding:0;line-height:inherit;white-space:normal}
|
||||
table.linenotable td.code{padding-left:.75em}
|
||||
table.linenotable td.linenos,pre.pygments .linenos{border-right:1px solid;opacity:.35;padding-right:.5em;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}
|
||||
pre.pygments span.linenos{display:inline-block;margin-right:.75em}
|
||||
.quoteblock{margin:0 1em 1.25em 1.5em;display:table}
|
||||
.quoteblock:not(.excerpt)>.title{margin-left:-1.5em;margin-bottom:.75em}
|
||||
.quoteblock blockquote,.quoteblock p{color:rgba(0,0,0,.85);font-size:1.15rem;line-height:1.75;word-spacing:.1em;letter-spacing:0;font-style:italic;text-align:justify}
|
||||
.quoteblock blockquote{margin:0;padding:0;border:0}
|
||||
.quoteblock blockquote::before{content:"\201c";float:left;font-size:2.75em;font-weight:bold;line-height:.6em;margin-left:-.6em;color:#7a2518;text-shadow:0 1px 2px rgba(0,0,0,.1)}
|
||||
.quoteblock blockquote>.paragraph:last-child p{margin-bottom:0}
|
||||
.quoteblock .attribution{margin-top:.75em;margin-right:.5ex;text-align:right}
|
||||
.verseblock{margin:0 1em 1.25em}
|
||||
.verseblock pre{font-family:"Open Sans","DejaVu Sans",sans-serif;font-size:1.15rem;color:rgba(0,0,0,.85);font-weight:300;text-rendering:optimizeLegibility}
|
||||
.verseblock pre strong{font-weight:400}
|
||||
.verseblock .attribution{margin-top:1.25rem;margin-left:.5ex}
|
||||
.quoteblock .attribution,.verseblock .attribution{font-size:.9375em;line-height:1.45;font-style:italic}
|
||||
.quoteblock .attribution br,.verseblock .attribution br{display:none}
|
||||
.quoteblock .attribution cite,.verseblock .attribution cite{display:block;letter-spacing:-.025em;color:rgba(0,0,0,.6)}
|
||||
.quoteblock.abstract blockquote::before,.quoteblock.excerpt blockquote::before,.quoteblock .quoteblock blockquote::before{display:none}
|
||||
.quoteblock.abstract blockquote,.quoteblock.abstract p,.quoteblock.excerpt blockquote,.quoteblock.excerpt p,.quoteblock .quoteblock blockquote,.quoteblock .quoteblock p{line-height:1.6;word-spacing:0}
|
||||
.quoteblock.abstract{margin:0 1em 1.25em;display:block}
|
||||
.quoteblock.abstract>.title{margin:0 0 .375em;font-size:1.15em;text-align:center}
|
||||
.quoteblock.excerpt>blockquote,.quoteblock .quoteblock{padding:0 0 .25em 1em;border-left:.25em solid #dddddf}
|
||||
.quoteblock.excerpt,.quoteblock .quoteblock{margin-left:0}
|
||||
.quoteblock.excerpt blockquote,.quoteblock.excerpt p,.quoteblock .quoteblock blockquote,.quoteblock .quoteblock p{color:inherit;font-size:1.0625rem}
|
||||
.quoteblock.excerpt .attribution,.quoteblock .quoteblock .attribution{color:inherit;font-size:.85rem;text-align:left;margin-right:0}
|
||||
p.tableblock:last-child{margin-bottom:0}
|
||||
td.tableblock>.content{margin-bottom:1.25em;word-wrap:anywhere}
|
||||
td.tableblock>.content>:last-child{margin-bottom:-1.25em}
|
||||
table.tableblock,th.tableblock,td.tableblock{border:0 solid #dedede}
|
||||
table.grid-all>*>tr>*{border-width:1px}
|
||||
table.grid-cols>*>tr>*{border-width:0 1px}
|
||||
table.grid-rows>*>tr>*{border-width:1px 0}
|
||||
table.frame-all{border-width:1px}
|
||||
table.frame-ends{border-width:1px 0}
|
||||
table.frame-sides{border-width:0 1px}
|
||||
table.frame-none>colgroup+*>:first-child>*,table.frame-sides>colgroup+*>:first-child>*{border-top-width:0}
|
||||
table.frame-none>:last-child>:last-child>*,table.frame-sides>:last-child>:last-child>*{border-bottom-width:0}
|
||||
table.frame-none>*>tr>:first-child,table.frame-ends>*>tr>:first-child{border-left-width:0}
|
||||
table.frame-none>*>tr>:last-child,table.frame-ends>*>tr>:last-child{border-right-width:0}
|
||||
table.stripes-all>*>tr,table.stripes-odd>*>tr:nth-of-type(odd),table.stripes-even>*>tr:nth-of-type(even),table.stripes-hover>*>tr:hover{background:#f8f8f7}
|
||||
th.halign-left,td.halign-left{text-align:left}
|
||||
th.halign-right,td.halign-right{text-align:right}
|
||||
th.halign-center,td.halign-center{text-align:center}
|
||||
th.valign-top,td.valign-top{vertical-align:top}
|
||||
th.valign-bottom,td.valign-bottom{vertical-align:bottom}
|
||||
th.valign-middle,td.valign-middle{vertical-align:middle}
|
||||
table thead th,table tfoot th{font-weight:bold}
|
||||
tbody tr th{background:#f7f8f7}
|
||||
tbody tr th,tbody tr th p,tfoot tr th,tfoot tr th p{color:rgba(0,0,0,.8);font-weight:bold}
|
||||
p.tableblock>code:only-child{background:none;padding:0}
|
||||
p.tableblock{font-size:1em}
|
||||
ol{margin-left:1.75em}
|
||||
ul li ol{margin-left:1.5em}
|
||||
dl dd{margin-left:1.125em}
|
||||
dl dd:last-child,dl dd:last-child>:last-child{margin-bottom:0}
|
||||
li p,ul dd,ol dd,.olist .olist,.ulist .ulist,.ulist .olist,.olist .ulist{margin-bottom:.625em}
|
||||
ul.checklist,ul.none,ol.none,ul.no-bullet,ol.no-bullet,ol.unnumbered,ul.unstyled,ol.unstyled{list-style-type:none}
|
||||
ul.no-bullet,ol.no-bullet,ol.unnumbered{margin-left:.625em}
|
||||
ul.unstyled,ol.unstyled{margin-left:0}
|
||||
li>p:empty:only-child::before{content:"";display:inline-block}
|
||||
ul.checklist>li>p:first-child{margin-left:-1em}
|
||||
ul.checklist>li>p:first-child>.fa-square-o:first-child,ul.checklist>li>p:first-child>.fa-check-square-o:first-child{width:1.25em;font-size:.8em;position:relative;bottom:.125em}
|
||||
ul.checklist>li>p:first-child>input[type=checkbox]:first-child{margin-right:.25em}
|
||||
ul.inline{display:flex;flex-flow:row wrap;list-style:none;margin:0 0 .625em -1.25em}
|
||||
ul.inline>li{margin-left:1.25em}
|
||||
.unstyled dl dt{font-weight:400;font-style:normal}
|
||||
ol.arabic{list-style-type:decimal}
|
||||
ol.decimal{list-style-type:decimal-leading-zero}
|
||||
ol.loweralpha{list-style-type:lower-alpha}
|
||||
ol.upperalpha{list-style-type:upper-alpha}
|
||||
ol.lowerroman{list-style-type:lower-roman}
|
||||
ol.upperroman{list-style-type:upper-roman}
|
||||
ol.lowergreek{list-style-type:lower-greek}
|
||||
.hdlist>table,.colist>table{border:0;background:none}
|
||||
.hdlist>table>tbody>tr,.colist>table>tbody>tr{background:none}
|
||||
td.hdlist1,td.hdlist2{vertical-align:top;padding:0 .625em}
|
||||
td.hdlist1{font-weight:bold;padding-bottom:1.25em}
|
||||
td.hdlist2{word-wrap:anywhere}
|
||||
.literalblock+.colist,.listingblock+.colist{margin-top:-.5em}
|
||||
.colist td:not([class]):first-child{padding:.4em .75em 0;line-height:1;vertical-align:top}
|
||||
.colist td:not([class]):first-child img{max-width:none}
|
||||
.colist td:not([class]):last-child{padding:.25em 0}
|
||||
.thumb,.th{line-height:0;display:inline-block;border:4px solid #fff;box-shadow:0 0 0 1px #ddd}
|
||||
.imageblock.left{margin:.25em .625em 1.25em 0}
|
||||
.imageblock.right{margin:.25em 0 1.25em .625em}
|
||||
.imageblock>.title{margin-bottom:0}
|
||||
.imageblock.thumb,.imageblock.th{border-width:6px}
|
||||
.imageblock.thumb>.title,.imageblock.th>.title{padding:0 .125em}
|
||||
.image.left,.image.right{margin-top:.25em;margin-bottom:.25em;display:inline-block;line-height:0}
|
||||
.image.left{margin-right:.625em}
|
||||
.image.right{margin-left:.625em}
|
||||
a.image{text-decoration:none;display:inline-block}
|
||||
a.image object{pointer-events:none}
|
||||
sup.footnote,sup.footnoteref{font-size:.875em;position:static;vertical-align:super}
|
||||
sup.footnote a,sup.footnoteref a{text-decoration:none}
|
||||
sup.footnote a:active,sup.footnoteref a:active{text-decoration:underline}
|
||||
#footnotes{padding-top:.75em;padding-bottom:.75em;margin-bottom:.625em}
|
||||
#footnotes hr{width:20%;min-width:6.25em;margin:-.25em 0 .75em;border-width:1px 0 0}
|
||||
#footnotes .footnote{padding:0 .375em 0 .225em;line-height:1.3334;font-size:.875em;margin-left:1.2em;margin-bottom:.2em}
|
||||
#footnotes .footnote a:first-of-type{font-weight:bold;text-decoration:none;margin-left:-1.05em}
|
||||
#footnotes .footnote:last-of-type{margin-bottom:0}
|
||||
#content #footnotes{margin-top:-.625em;margin-bottom:0;padding:.75em 0}
|
||||
div.unbreakable{page-break-inside:avoid}
|
||||
.big{font-size:larger}
|
||||
.small{font-size:smaller}
|
||||
.underline{text-decoration:underline}
|
||||
.overline{text-decoration:overline}
|
||||
.line-through{text-decoration:line-through}
|
||||
.aqua{color:#00bfbf}
|
||||
.aqua-background{background:#00fafa}
|
||||
.black{color:#000}
|
||||
.black-background{background:#000}
|
||||
.blue{color:#0000bf}
|
||||
.blue-background{background:#0000fa}
|
||||
.fuchsia{color:#bf00bf}
|
||||
.fuchsia-background{background:#fa00fa}
|
||||
.gray{color:#606060}
|
||||
.gray-background{background:#7d7d7d}
|
||||
.green{color:#006000}
|
||||
.green-background{background:#007d00}
|
||||
.lime{color:#00bf00}
|
||||
.lime-background{background:#00fa00}
|
||||
.maroon{color:#600000}
|
||||
.maroon-background{background:#7d0000}
|
||||
.navy{color:#000060}
|
||||
.navy-background{background:#00007d}
|
||||
.olive{color:#606000}
|
||||
.olive-background{background:#7d7d00}
|
||||
.purple{color:#600060}
|
||||
.purple-background{background:#7d007d}
|
||||
.red{color:#bf0000}
|
||||
.red-background{background:#fa0000}
|
||||
.silver{color:#909090}
|
||||
.silver-background{background:#bcbcbc}
|
||||
.teal{color:#006060}
|
||||
.teal-background{background:#007d7d}
|
||||
.white{color:#bfbfbf}
|
||||
.white-background{background:#fafafa}
|
||||
.yellow{color:#bfbf00}
|
||||
.yellow-background{background:#fafa00}
|
||||
span.icon>.fa{cursor:default}
|
||||
a span.icon>.fa{cursor:inherit}
|
||||
.admonitionblock td.icon [class^="fa icon-"]{font-size:2.5em;text-shadow:1px 1px 2px rgba(0,0,0,.5);cursor:default}
|
||||
.admonitionblock td.icon .icon-note::before{content:"\f05a";color:#19407c}
|
||||
.admonitionblock td.icon .icon-tip::before{content:"\f0eb";text-shadow:1px 1px 2px rgba(155,155,0,.8);color:#111}
|
||||
.admonitionblock td.icon .icon-warning::before{content:"\f071";color:#bf6900}
|
||||
.admonitionblock td.icon .icon-caution::before{content:"\f06d";color:#bf3400}
|
||||
.admonitionblock td.icon .icon-important::before{content:"\f06a";color:#bf0000}
|
||||
.conum[data-value]{display:inline-block;color:#fff!important;background:rgba(0,0,0,.8);border-radius:50%;text-align:center;font-size:.75em;width:1.67em;height:1.67em;line-height:1.67em;font-family:"Open Sans","DejaVu Sans",sans-serif;font-style:normal;font-weight:bold}
|
||||
.conum[data-value] *{color:#fff!important}
|
||||
.conum[data-value]+b{display:none}
|
||||
.conum[data-value]::after{content:attr(data-value)}
|
||||
pre .conum[data-value]{position:relative;top:-.125em}
|
||||
b.conum *{color:inherit!important}
|
||||
.conum:not([data-value]):empty{display:none}
|
||||
dt,th.tableblock,td.content,div.footnote{text-rendering:optimizeLegibility}
|
||||
h1,h2,p,td.content,span.alt,summary{letter-spacing:-.01em}
|
||||
p strong,td.content strong,div.footnote strong{letter-spacing:-.005em}
|
||||
p,blockquote,dt,td.content,span.alt,summary{font-size:1.0625rem}
|
||||
p{margin-bottom:1.25rem}
|
||||
.sidebarblock p,.sidebarblock dt,.sidebarblock td.content,p.tableblock{font-size:1em}
|
||||
.exampleblock>.content{background:#fffef7;border-color:#e0e0dc;box-shadow:0 1px 4px #e0e0dc}
|
||||
.print-only{display:none!important}
|
||||
@page{margin:1.25cm .75cm}
|
||||
@media print{*{box-shadow:none!important;text-shadow:none!important}
|
||||
html{font-size:80%}
|
||||
a{color:inherit!important;text-decoration:underline!important}
|
||||
a.bare,a[href^="#"],a[href^="mailto:"]{text-decoration:none!important}
|
||||
a[href^="http:"]:not(.bare)::after,a[href^="https:"]:not(.bare)::after{content:"(" attr(href) ")";display:inline-block;font-size:.875em;padding-left:.25em}
|
||||
abbr[title]{border-bottom:1px dotted}
|
||||
abbr[title]::after{content:" (" attr(title) ")"}
|
||||
pre,blockquote,tr,img,object,svg{page-break-inside:avoid}
|
||||
thead{display:table-header-group}
|
||||
svg{max-width:100%}
|
||||
p,blockquote,dt,td.content{font-size:1em;orphans:3;widows:3}
|
||||
h2,h3,#toctitle,.sidebarblock>.content>.title{page-break-after:avoid}
|
||||
#header,#content,#footnotes,#footer{max-width:none}
|
||||
#toc,.sidebarblock,.exampleblock>.content{background:none!important}
|
||||
#toc{border-bottom:1px solid #dddddf!important;padding-bottom:0!important}
|
||||
body.book #header{text-align:center}
|
||||
body.book #header>h1:first-child{border:0!important;margin:2.5em 0 1em}
|
||||
body.book #header .details{border:0!important;display:block;padding:0!important}
|
||||
body.book #header .details span:first-child{margin-left:0!important}
|
||||
body.book #header .details br{display:block}
|
||||
body.book #header .details br+span::before{content:none!important}
|
||||
body.book #toc{border:0!important;text-align:left!important;padding:0!important;margin:0!important}
|
||||
body.book #toc,body.book #preamble,body.book h1.sect0,body.book .sect1>h2{page-break-before:always}
|
||||
.listingblock code[data-lang]::before{display:block}
|
||||
#footer{padding:0 .9375em}
|
||||
.hide-on-print{display:none!important}
|
||||
.print-only{display:block!important}
|
||||
.hide-for-print{display:none!important}
|
||||
.show-for-print{display:inherit!important}}
|
||||
@media amzn-kf8,print{#header>h1:first-child{margin-top:1.25rem}
|
||||
.sect1{padding:0!important}
|
||||
.sect1+.sect1{border:0}
|
||||
#footer{background:none}
|
||||
#footer-text{color:rgba(0,0,0,.6);font-size:.9em}}
|
||||
@media amzn-kf8{#header,#content,#footnotes,#footer{padding:0}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="article">
|
||||
<div id="header">
|
||||
</div>
|
||||
<div id="content">
|
||||
<div class="sect1">
|
||||
<h2 id="_qqq_tables">QQQ Tables</h2>
|
||||
<div class="sectionbody">
|
||||
<div class="paragraph">
|
||||
<p>The core type of object in a QQQ Instance is the Table.
|
||||
In the most common use-case, a QQQ Table may be the in-app representation of a Database table.
|
||||
That is, it is a collection of records (or rows) of data, each of which has a set of fields (or columns).</p>
|
||||
</div>
|
||||
<div class="paragraph">
|
||||
<p>QQQ also allows other types of data sources (<a href="Backends{relfilesuffix}">QQQ Backends</a>) to be used as tables, such as File systems, API’s, Java enums or objects, etc.
|
||||
All of these backend types present the same interfaces (both user-interfaces, and application programming interfaces), regardless of their backend type.</p>
|
||||
</div>
|
||||
<div class="sect2">
|
||||
<h3 id="_qtablemetadata">QTableMetaData</h3>
|
||||
<div class="paragraph">
|
||||
<p>Tables are defined in a QQQ Instance in a <code><strong>QTableMetaData</strong></code> object.
|
||||
All tables must reference a <a href="Backends{relfilesuffix}">QQQ Backend</a>, a list of fields that define the shape of records in the table, and additional data to describe how to work with the table within its backend.</p>
|
||||
</div>
|
||||
<div class="paragraph">
|
||||
<p><strong>QTableMetaData Properties:</strong></p>
|
||||
</div>
|
||||
<div class="ulist">
|
||||
<ul>
|
||||
<li>
|
||||
<p><code>name</code> - <strong>String, Required</strong> - Unique name for the table within the QQQ Instance.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>label</code> - <strong>String</strong> - User-facing label for the table, presented in User Interfaces.
|
||||
Inferred from <code>name</code> if not set.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>backendName</code> - <strong>String, Required</strong> - Name of a <a href="Backends{relfilesuffix}">QQQ Backend</a> in which this table’s data is managed.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>fields</code> - <strong>Map of String → <a href="Fields{relfilesuffix}">QQQ Field</a>, Required</strong> - The columns of data that make up all records in this table.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>primaryKeyField</code> - <strong>String, Conditional</strong> - Name of a <a href="Fields{relfilesuffix}">QQQ Field</a> that serves as the primary key (e.g., unique identifier) for records in this table.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>uniqueKeys</code> - <strong>List of UniqueKey</strong> - Definition of additional unique constraints (from an RDBMS point of view) from the table.
|
||||
e.g., sets of columns which must have unique values for each record in the table.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>backendDetails</code> - <strong>QTableBackendDetails or subclass</strong> - Additional data to configure the table within its <a href="Backends{relfilesuffix}">QQQ Backend</a>.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>automationDetails</code> - <strong>QTableAutomationDetails</strong> - Configuration of automated jobs that run against records in the table, e.g., upon insert or update.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>customizers</code> - <strong>Map of String → QCodeReference</strong> - References to custom code that are injected into standard table actions, that allow applications to customize certain parts of how the table works.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>parentAppName</code> - <strong>String</strong> - Name of a <a href="Apps{relfilesuffix}">QQQ App</a> that this table exists within.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>icon</code> - <strong>QIcon</strong> - Icon associated with this table in certain user interfaces.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>recordLabelFormat</code> - <strong>String</strong> - Java Format String, used with <code>recordLabelFields</code> to produce a label shown for records from the table.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>recordLabelFields</code> - <strong>List of String, Conditional</strong> - Used with <code>recordLabelFormat</code> to provide values for any format specifiers in the format string.
|
||||
These strings must be field names within the table.</p>
|
||||
<div class="ulist">
|
||||
<ul>
|
||||
<li>
|
||||
<p>Example of using <code>recordLabelFormat</code> and <code>recordLabelFields</code>:</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="listingblock">
|
||||
<div class="content">
|
||||
<pre class="CodeRay highlight"><code data-lang="java"><span style="color:#777">// given these fields in the table:</span>
|
||||
<span style="color:#080;font-weight:bold">new</span> QFieldMetaData(<span style="background-color:hsla(0,100%,50%,0.05)"><span style="color:#710">"</span><span style="color:#D20">name</span><span style="color:#710">"</span></span>, QFieldType.STRING)
|
||||
<span style="color:#080;font-weight:bold">new</span> QFieldMetaData(<span style="background-color:hsla(0,100%,50%,0.05)"><span style="color:#710">"</span><span style="color:#D20">birthDate</span><span style="color:#710">"</span></span>, QFieldType.DATE)
|
||||
|
||||
<span style="color:#777">// We can produce a record label such as "Darin Kelkhoff (1980-05-31)" via:</span>
|
||||
.withRecordLabelFormat(<span style="background-color:hsla(0,100%,50%,0.05)"><span style="color:#710">"</span><span style="color:#D20">%s (%s)</span><span style="color:#710">"</span></span>)
|
||||
.withRecordLabelFields(<span style="color:#0a8;font-weight:bold">List</span>.of(<span style="background-color:hsla(0,100%,50%,0.05)"><span style="color:#710">"</span><span style="color:#D20">name</span><span style="color:#710">"</span></span>, <span style="background-color:hsla(0,100%,50%,0.05)"><span style="color:#710">"</span><span style="color:#D20">birthDate</span><span style="color:#710">"</span></span>))</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ulist">
|
||||
<ul>
|
||||
<li>
|
||||
<p><code>sections</code> - <strong>List of QFieldSection</strong> - Mechanism to organize fields within user interfaces, into logical sections.
|
||||
If any sections are present in the table meta data, then all fields in the table must be listed in exactly 1 section.
|
||||
If no sections are defined, then instance enrichment will define default sections.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>associatedScripts</code> - <strong>List of AssociatedScript</strong> - Definition of user-defined scripts that can be associated with records within the table.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>enabledCapabilities</code> and <code>disabledCapabilities</code> - <strong>Set of Capability enum values</strong> - Overrides from the backend level, for capabilities that this table does or does not possess.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="footer">
|
||||
<div id="footer-text">
|
||||
Last updated 2022-11-21 09:02:56 -0600
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
51
docs/metaData/Widgets.adoc
Normal file
51
docs/metaData/Widgets.adoc
Normal file
@ -0,0 +1,51 @@
|
||||
[#Widgets]
|
||||
== Widgets
|
||||
include::../variables.adoc[]
|
||||
|
||||
Widgets are the most customizable UI components in QQQ.
|
||||
They can be used either on App Home Screens (e.g., as Dashboard screens),
|
||||
or they can be included into Record View screens.
|
||||
|
||||
QQQ defines several types of widgets, such as charts (pie, bar, line),
|
||||
numeric displays, application-populated tables, or even fully custom HTML.
|
||||
|
||||
=== QWidgetMetaData
|
||||
A Widget is defined in a QQQ Instance in a `*QWidgetMetaData*` object.
|
||||
|
||||
*QWidgetMetaData Properties:*
|
||||
|
||||
* `name` - *String, Required* - Unique name for the widget within the QQQ Instance.
|
||||
* `type` - *String, Required* - Specifies the UI & data type for the widget.
|
||||
* `label` - *String* - User-facing header or title for a widget.
|
||||
* `tooltip` - *String* - Text contents to be placed in a tooltip associated with the widget's label in the UI.
|
||||
** Values should come from the `WidgetType` enum's `getType()` method (e.g., `WidgetType.BAR_CHART.getType()`)
|
||||
* `gridColumns` - *Integer* - for a desktop-sized screen, in a 12-based grid,
|
||||
how many columns the widget should consume.
|
||||
* `codeReference` - *QCodeReference, Required* - Reference to the custom code,
|
||||
a subclass of `AbstractWidgetRenderer`, which is responsible for loading data to render the widget.
|
||||
* `footerHTML` - *String* - HTML String, which, if present, will be displayed in the
|
||||
footer of the widget (not supported by all widget types).
|
||||
* `isCard` - *boolean, default false* #TODO#
|
||||
* `showReloadButton` - *boolean, default true* #TODO#
|
||||
* `showExportButton` - *boolean, default false* #TODO#
|
||||
* `dropdowns` - #TODO#
|
||||
* `storeDropdownSelections` - *boolean* #TODO#
|
||||
* `icons` - *Map<String, QIcon>* #TODO#
|
||||
* `defaultValues` - *Map<String, Serializable>* #TODO#
|
||||
|
||||
There are also some subclasses of `QWidgetMetaData`, for some specific widget types:
|
||||
|
||||
*ParentWidgetMetaData Properties:*
|
||||
|
||||
* `title` - *String* #TODO - how does this differ from label?#
|
||||
* `childWidgetNameList` - *List<String>, Required*
|
||||
* `childProcessNameList` - *List<String>* #TODO appears unused - check, and delete#
|
||||
* `laytoutType` - *enum of GRID or TABS, default GRID*
|
||||
|
||||
*QNoCodeWidgetMetaData Properties:*
|
||||
|
||||
* `values` - *List<AbstractWidgetValueSource>* #TODO#
|
||||
* `outputs` - *List<AbstractWidgetOutput>* #TODO#
|
||||
|
||||
#TODO - Examples#
|
||||
|
155
docs/misc/ProcessBackendSteps.adoc
Normal file
155
docs/misc/ProcessBackendSteps.adoc
Normal file
@ -0,0 +1,155 @@
|
||||
== Process Backend Steps
|
||||
include::../variables.adoc[]
|
||||
|
||||
In many QQQ applications, much of the code that engineers write will take the form of Backend Steps for {link-processes}.
|
||||
Such code is defined in classes which implement the interface `BackendStep`.
|
||||
This interface defines only a single method:
|
||||
[source,java]
|
||||
.BackendStep.java
|
||||
----
|
||||
public interface BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Execute the backend step - using the request as input, and the result as output.
|
||||
**
|
||||
*******************************************************************************/
|
||||
void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException;
|
||||
}
|
||||
|
||||
----
|
||||
|
||||
Process backend steps have access to state information - specifically, a list of records, and a map of name=value pairs - in the input & output objects.
|
||||
This state data is persisted by QQQ between steps (e.g., if a frontend step is presented to a user between backend steps).
|
||||
|
||||
|
||||
=== RunBackendStepInput
|
||||
All input data to the step is available in the `RunBackendStepInput` object.
|
||||
Key methods in this class are:
|
||||
|
||||
* `getRecords()` - Returns the List of <<QRecords>> that are currently being acted on in the process.
|
||||
* `getValues()` - Returns a Map of String -> Serializable; name=value pairs that are the state-data of the process.
|
||||
** Values can be added to this state from a process's meta-data, from a screen, or from another backend step.
|
||||
* `getValue(String fieldName)` - Returns a specific value, by name, from the process's state.
|
||||
** This method has several variations that return the value as a specific type, such as `getValueString`, `getValueInteger`, `getValueBoolean`...
|
||||
* `getAsyncJobCallback()` - Accessor for an `AsyncJobCallback` object, which provides a way for the process backend step to communicate about its status or progress with a user running the process in a frontend.
|
||||
Provides methods:
|
||||
** `updateStatus(String message)` - Where general status messages can be given.
|
||||
For example, `"Loading census data"`
|
||||
** `updateStatus(int current, int total)` - For updating a progress meter.
|
||||
e.g., "47 of 1701" would be display by calling `.updateStatus(47, 1701)`
|
||||
* `getFrontendStepBehavior()` - Enum, indicating what should happen when a frontend step is encountered as the process's next step to run.
|
||||
Possible values are:
|
||||
** `BREAK` - Indicates that the process's execution should be suspended, so that the screen represented by the frontend step can be presented to a user.
|
||||
This would be the expected behavior if a process is being run by a user from a UI.
|
||||
** `SKIP` - Indicates that frontend steps should be skipped.
|
||||
This would be the expected behavior if a process is running from a scheduled job (without a user present to drive it), for example.
|
||||
** `FAIL` - Indicates that the process should end with an exception if a frontend step is encountered.
|
||||
** A backend step may want to act differently based on its frontendStepBehavior.
|
||||
For example, additional data may be looked up for displaying to a user if the behavior is `BREAK`.
|
||||
* `getBasepullLastRunTime()` - For <<BasepullConfiguration,Basepull>> processes, this is the `Instant` stored in the basepull table as the process's last run time.
|
||||
|
||||
=== RunBackendStepOutput
|
||||
All output from a process step should be placed in its `RunBackendStepOutput` object (and/or stored to a backend, as appropriate).
|
||||
Key methods in this class are:
|
||||
|
||||
* `addValue(String fieldName, Serializable value)` - Adds a single named value to the process's state, overwriting it the value if it already exists.
|
||||
* `addRecord(QRecord record)` - Add a `<<QRecord>>` to the process's output.
|
||||
* `addAuditSingleInput(AuditSingleInput auditSingleInput)` - Add a new entry to the process's list of audit inputs, to be stored at the completion of the process.
|
||||
** An `AuditSingleInput` object can most easily be built with the constructor: `AuditSingleInput(String tableName, QRecord record, String auditMessage)`.
|
||||
** Additional audit details messages (sub-bullets that accompany the primary `auditMessage`) can be added to an `AuditSingleInput` via the `addDetail(String message)` method.
|
||||
** _Note that at this time, the automatic storing of these audits is only provided by the execute step of a StreamedETLWithFrontendProcesses._
|
||||
|
||||
=== Example
|
||||
|
||||
[source,java]
|
||||
.Example of a BackendStep
|
||||
----
|
||||
/*******************************************************************************
|
||||
** For the "person" table's "Add Age" process -
|
||||
** For each input person record, add the specified yearsToAdd to their age.
|
||||
*******************************************************************************/
|
||||
public class AddAge implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// get the yearsToAdd input field value from the process input //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
Integer yearsToAdd = runBackendStepInput.getValueInteger("yearsToAdd");
|
||||
int totalYearsAdded = 0;
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// loop over the records passed into the process //
|
||||
///////////////////////////////////////////////////
|
||||
for(QRecord record : runBackendStepInput.getRecords())
|
||||
{
|
||||
Integer age = record.getValueInteger("age");
|
||||
age += yearsToAdd;
|
||||
totalYearsAdded += yearsToAdd;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// update the record with the new "age" value. //
|
||||
// note that this update record object will implicitly be available to the process's next //
|
||||
// backend step, via the sharing of the processState object. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
record.setValue("age", age);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
// set an output value for the process //
|
||||
/////////////////////////////////////////
|
||||
runBackendStepOutput.addValue("totalYearsAdded", totalYearsAdded);
|
||||
}
|
||||
}
|
||||
|
||||
----
|
||||
|
||||
|
||||
=== Backend Steps for StreamedETLWithFrontendProcesses
|
||||
|
||||
For <<StreamedETLWithFrontendProcess>> type processes, backend steps are defined a little bit differently than they are for other process types.
|
||||
In this type of process, the process meta-data defines 3 backend steps which are built-in to QQQ, and which do not have any custom application logic.
|
||||
These steps are:
|
||||
|
||||
* `StreamedETLPreviewStep`
|
||||
* `StreamedETLValidateStep`
|
||||
* `StreamedETLExecuteStep`
|
||||
|
||||
For custom application logic to be implemented in a StreamedETLWithFrontendProcesses, an application engineer must define (up to) 3 backend step classes which are loaded by the steps listed above.
|
||||
These application-defined steps must extend specific classes (which themselves implement the `BackendStep` interface), to provide the needed logic of this style of process.
|
||||
These steps are:
|
||||
|
||||
* *Extract* - a subclass of `AbstractExtractStep` - is responsible for Extracting records from the source table.
|
||||
** For this step, we can often use the QQQ-provided class `ExtractViaQueryStep`, or sometimes a subclass of it.
|
||||
** The Extract step is called before the Preview, Validate, and Result screens, though for the Preview screen, it is set to only extract a small number of records (10).
|
||||
* *Transform* - a subclass of `AbstractTransformStep` - is responsible for applying the majority of the business logic of the process.
|
||||
In ETL terminology, this is the "Transform" action - which means applying some type of logical transformation an input record (found by the Extract step) to generate an output record (stored by the Load step).
|
||||
** A Transform step's `run` method will be called, potentially, multiple times, each time with a page of records in the `runBackendStepInput` parameter.
|
||||
** This method is responsible for adding records to the `runBackendStepOutput`, which will then be passed to the *Load* step.
|
||||
** This class is also responsible for implementing the method `getProcessSummary`, which provides the data to the *Validate* screen.
|
||||
** The run method will generally update ProcessSummaryLine objects to facilitate this functionality.
|
||||
** The Transform step is called before the Preview, Validate, and Result screens, consuming all records selected by the Extract step.
|
||||
* *Load* - a subclass of `AbstractLoadStep` - is responsible for the Load function of the ETL job.
|
||||
_A quick word on terminology - this step is actually doing what we are more likely to think of as storing data - which feels like the opposite of “loading” - but we use the name Load to keep in line with the ETL naming convention…_
|
||||
** The Load step is ONLY called before the Result screen is presented (possibly after Preview, if the user chose to skip validation, otherwise, after validation).
|
||||
** Similar to the Transform step, the Load step's `run` method will be called potentially multiple times, with pages of records in its input.
|
||||
** As such, the Load step is generally the only step where data writes should occur.
|
||||
*** e.g., a Transform step should not do any writes, as it will be called when the user is going to the Preview & Validate screens - e.g., before the user confirmed that they want to execute the action!
|
||||
** A common pattern is that the Load step just needs to insert or update the list of records output by the Transform step, in which case the QQQ-provided `LoadViaInsertStep` or `LoadViaUpdateStep` can be used, but custom use-cases can be built as well.
|
||||
|
||||
Another distinction between StreamedELTWithFrontendProcess steps and general QQQ process backend steps, is that the list of records in the input & output objects is NOT shared for StreamedELTWithFrontendProcess steps.
|
||||
The direct implication of this is, that a Transform step MUST explicitly call `output.addRecord()` for any records that it wants to pass along to the Load step.
|
||||
|
||||
==== Example
|
||||
|
||||
[source,java]
|
||||
.Examples of a Transform and Load step for a StreamedELTWithFrontendProcess
|
||||
----
|
||||
// todo!
|
||||
----
|
||||
|
||||
#todo: more details on these 3 specialized types of process steps (e.g., method to overload, when stuff like pre-action is called; how summaries work).#
|
30
docs/misc/QContext.adoc
Normal file
30
docs/misc/QContext.adoc
Normal file
@ -0,0 +1,30 @@
|
||||
== QContext
|
||||
include::../variables.adoc[]
|
||||
|
||||
The class `QContext` contains a collection of thread-local variables, to define the current context of the QQQ code that is currently running.
|
||||
For example, what `QInstance` (meta-data container) is being used, what `QSession` (user attributes) is active, etc.
|
||||
|
||||
Most of the time, main-line application code does not need to worry about setting up the `QContext` - although unit-test code can is a common exception to that rule.
|
||||
This is because all of QQQ's entry-points into execution (e.g., web context handlers, CLI startup methods, schedulers, and multi-threaded executors) take care of initializing the context.
|
||||
|
||||
It is more common though, for application code to need to get data from the context, such as the current session or any piece of meta-data from the QInstance.
|
||||
The methods to access data from the `QContext` are fairly straightforward:
|
||||
|
||||
=== Examples
|
||||
==== Get a QTableMetaData from the active QInstance
|
||||
[source,java]
|
||||
----
|
||||
QTableMeataData myTable = QContext.getQInstance().getTable("myTable");
|
||||
for(QFieldMeataData field : myTable.getFields().values())
|
||||
{
|
||||
// ...
|
||||
}
|
||||
----
|
||||
|
||||
==== Get a security key value for the current user session
|
||||
[source,java]
|
||||
----
|
||||
QSession session = QContext.getQSession();
|
||||
List<Serializable> clientIds = session.getSecurityKeyValues("clientId");
|
||||
----
|
||||
|
116
docs/misc/QRecordEntities.adoc
Normal file
116
docs/misc/QRecordEntities.adoc
Normal file
@ -0,0 +1,116 @@
|
||||
== QRecordEntities
|
||||
include::../variables.adoc[]
|
||||
|
||||
While `<<QRecords>>` provide a flexible mechanism for passing around record data in a QQQ Application, they have one big disadvantage from the point-of-view of a Java Application:
|
||||
They do not provide a mechanism to ensure compile-time checks of field names or field types.
|
||||
|
||||
As such, an alternative mechanism exists, which allows records in a QQQ application to be worked with following a more familiar Java Bean (Entity Bean) like pattern.
|
||||
This mechanism is known as a `QRecordEntity`.
|
||||
|
||||
Specifically speaking, `QRecordEntity` is an abstract base class, whose purpose is to be the base class for entity-bean classes.
|
||||
Using reflection, `QRecordEntity` is able to convert objects back and forth from `QRecord` to specific entity-bean subtypes.
|
||||
|
||||
For example, the method `QRecordEntity::toQRecord()` converts a record entity object into a `QRecord`.
|
||||
|
||||
Inversely, `QRecordEntity::populateFromQRecord(QRecord record)` sets fields in a record entity object, based on the values in the supplied `QRecord`.
|
||||
It is conventional for a subclass of `QRecordEntity` to have both a no-arg (default) constructor, and a constructor that takes a `QRecord` as a parameter, and calls `populateFromQRecord`.
|
||||
|
||||
In addition to these constructors, a `QRecordEntity` subclass will generally contain:
|
||||
|
||||
* A `public static final String TABLE_NAME`, used throughout the application as a constant reference to the name for the {link-table}.
|
||||
* A series of `private` fields, corresponding to the fields in the table that the entity represents.
|
||||
** If these fields are annotated as `@QField()`, then the {link-table} meta-data for the table that the entity represents can in part be inferred by QQQ, by using the method `QTableMetaData::withFieldsFromEntity`.
|
||||
* `getX()`, `setX()`, and `withX()` methods for all of the entity's fields.
|
||||
|
||||
=== Examples
|
||||
[source,java]
|
||||
.Example Definition of a QRecordEntity subclass: Person.java
|
||||
----
|
||||
/*******************************************************************************
|
||||
** QRecordEntity for the person table.
|
||||
*******************************************************************************/
|
||||
public class Person extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "person";
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer id;
|
||||
|
||||
@QField()
|
||||
private String firstName;
|
||||
|
||||
@QField()
|
||||
private String lastName;
|
||||
|
||||
@QField()
|
||||
private Integer age;
|
||||
|
||||
// all other fields
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor
|
||||
*******************************************************************************/
|
||||
public Person()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that takes a QRecord
|
||||
*******************************************************************************/
|
||||
public Person(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Custom method to concatenate firstName and lastName
|
||||
*******************************************************************************/
|
||||
public String getFullName()
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// if there were more than an example, we could do some null-checks //
|
||||
// here to avoid silly output like "Bobby null" :) //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
return (firstName + " " + lastName);
|
||||
}
|
||||
|
||||
// all getter, setter, and fluent setter (wither) methods
|
||||
}
|
||||
----
|
||||
|
||||
The core ORM actions and process-execution actions of QQQ work with `QRecords`.
|
||||
However, application engineers may want to apply patterns like the following example, to gain the compile-time safety of `QRecordEntities`:
|
||||
|
||||
[source,java]
|
||||
.Example Usage of a QRecordEntity
|
||||
----
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// assume we're working with the "person" table & entity from above //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
List<QRecord> recordsToUpdate = new ArrayList<>();
|
||||
for(QRecord record : inputRecordList)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// call the constructor that copies values from the record into the entity //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
Person person = new Person(record);
|
||||
|
||||
////////////////////////////////////////////////
|
||||
// call a custom method defined in the entity //
|
||||
////////////////////////////////////////////////
|
||||
LOG.info("Processing: " + person.getFullName());
|
||||
|
||||
/////////////////////////////////////////////////////////////
|
||||
// age is an Integer, so no type-conversion is needed here //
|
||||
/////////////////////////////////////////////////////////////
|
||||
person.setAge(person.getAge() + 1);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// to pass the updated records to UpdateAction, convert them to QRecords //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
recordsToUpdate.add(person.toQRecord());
|
||||
}
|
||||
----
|
||||
|
68
docs/misc/QRecords.adoc
Normal file
68
docs/misc/QRecords.adoc
Normal file
@ -0,0 +1,68 @@
|
||||
== QRecords
|
||||
include::../variables.adoc[]
|
||||
|
||||
Almost all code inside a QQQ application will be dealing with *Records* (aka Tuples or Rows).
|
||||
That is: a collection of named values, representing a single Entity, Fact, or, Row from a {link-table}.
|
||||
|
||||
The class that QQQ uses to work with records is called: `QRecord`.
|
||||
|
||||
=== Values
|
||||
At its core, a `QRecord` is a wrapper around a `Map<String, Serializable> values`.
|
||||
These are the *actual* values for the fields in the table for the record.
|
||||
That is, direct representations of the values as they are stored in the {link-backend}.
|
||||
|
||||
The keys in the `values` map are names from the {link-fields} in the {link-table}.
|
||||
|
||||
The values in `values` map are declared as `Serializable` (to help ensure the serializability of the `QRecord` as a whole).
|
||||
In practice, their types will be based on the `QFieldType` of the {link-field} that they correspond to.
|
||||
That will typically be one of: `String`, `Integer`, `Boolean`, `BigDecimal`, `Instant`, `LocalDate`, `LocalTime`, or `byte[]`.
|
||||
Be aware that `null` values may be in the `values` map, especially/per if the backend/table support `null`.
|
||||
|
||||
To work with the `values` map, the following methods are provided:
|
||||
|
||||
* `setValue(String fieldName, Serializable value)` - Sets a value for the specified field in the record.
|
||||
** Overloaded as `setValue(String fieldName, Object value)` - For some cases where the value may not be known to be `Serializable`.
|
||||
In this overload, if the value is `null` or `Serializable`, the primary version of `setValue` is called.
|
||||
Otherwise, the `value` is passed through `String::valueOf`, and the result is stored.
|
||||
** Overloaded as `setValue(QFieldMetaData field, Serializable value)` - which simply defers to the primary version of `setValue`, passing `field.getName()` as the first parameter.
|
||||
* `removeValue(String fieldName)` - Remove the given field from the `values` map.
|
||||
** Note that in some situations this is an important distinction from having a `null` value in the map (See <<UpdateAction)>>).
|
||||
* `setValues(Map<String, Serializable> values)` - Sets the full map of `values`.
|
||||
* `getValues()` - Returns the full map of `values`.
|
||||
* `getValue(String fieldName)` - Returns the value for the named field - possibly `null` - as a `Serializable`.
|
||||
* Several type-specific variations of `getValueX(String fieldName)`, where internally, values will be not exactly type-cast, but effectively converted (if possible) to the requested type.
|
||||
These conversions are done using the `ValueUtils.getValueAsX(Object)` methods.
|
||||
These methods are generally the preferred/cleanest way to get record values in application code, when it is needed in a type-specific way .
|
||||
** `getValueString(String fieldName)`
|
||||
** `getValueInteger(String fieldName)`
|
||||
** `getValueBoolean(String fieldName)`
|
||||
** `getValueBigDecimal(String fieldName)`
|
||||
** `getValueInstant(String fieldName)`
|
||||
** `getValueLocalDate(String fieldName)`
|
||||
** `getValueLocalTime(String fieldName)`
|
||||
** `getValueByteArray(String fieldName)`
|
||||
|
||||
=== Display Values
|
||||
In addition to the `values` map, a `QRecord` contains another map called `displayValues`, which only stores `String` values.
|
||||
That is to say, values for other types are stringified, based on their {link-field}'s type and `displayFormat` property.
|
||||
In addition, fields which have a `possibleValueSource` property will have their translated values set in the `displayValues` map.
|
||||
|
||||
By default, a `QRecord` will not have its `displayValues` populated.
|
||||
To populate `displayValues`, the <<QueryAction>> and <<GetAction>> classes take a property in their inputs called `shouldGenerateDisplayValues`, which must be set to `true` to generate `displayValues`.
|
||||
In addition, these two actions also have a property `shouldTranslatePossibleValues` in their inputs, which needs to be set to `true` if possible-value lookups are to be performed.
|
||||
|
||||
As an alternative to the `shouldGenerateDisplayValues` and `shouldTranslatePossibleValues` inputs to <<QueryAction>> and <<GetAction>>, one can directly call the `QValueFormatter.setDisplayValuesInRecords` and/or `qPossibleValueTranslator.translatePossibleValuesInRecords` methods.
|
||||
Or, for special cases, `setDisplayValue(String fieldName, String displayValue)` or `setDisplayValues(Map<String, String> displayValues)` can be called directly.
|
||||
|
||||
=== Backend Details
|
||||
Sometimes a backend may want to place additional data in a `QRecord` that doesn't exactly correspond to a field.
|
||||
To do this, the `Map<String, Serializable> backendDetails` member is used.
|
||||
|
||||
For example, an API backend may store the full JSON `String` that came from the API as a backend detail in a `QRecord`.
|
||||
Or fields that are marked as `isHeavy`, if the full (heavy) value of the field hasn't been fetched, then the lengths of any such heavy fields may be stored in `backendDetails`.
|
||||
|
||||
=== Errors and Warnings
|
||||
#todo#
|
||||
|
||||
=== Associated Records
|
||||
#todo#
|
224
docs/misc/RenderingWidgets.adoc
Normal file
224
docs/misc/RenderingWidgets.adoc
Normal file
@ -0,0 +1,224 @@
|
||||
== Rendering Widgets
|
||||
include::../variables.adoc[]
|
||||
|
||||
=== WidgetRenderer classes
|
||||
In general, to fully implement a Widget, you must define its `QWidgetMetaData`,
|
||||
and supply a subclass of `AbstractWidgetRenderer`, to provide the data to the widget.
|
||||
(Note the "No Code" category of widgets, which are an exception to this generalization).
|
||||
|
||||
The only method required in a subclass of `AbstractWidgetRenderer` is:
|
||||
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
|
||||
The fields available in `RenderWidgetInput` are:
|
||||
|
||||
- `Map<String, String> queryParams` - These are parameters supplied by the frontend, for example,
|
||||
if a user selected values from dropdowns to control a dimension of your widget, those name/value
|
||||
pairs would be in this map. Similarly, if your widget is being included on a record view screen, then
|
||||
the record's primary key will be in this map.
|
||||
- `QWidgetMetaDataInterface widgetMetaData` - This is the meta-data for the widget being
|
||||
rendered. This can be useful in case you are using the same renderer class for multiple widgets.
|
||||
|
||||
The only field in `RenderWidgetOutput` is:
|
||||
|
||||
- `QWidgetData widgetData` - This is a base class, with several attributes, and more importantly,
|
||||
several subclasses, specific to the type of widget that is being rendered.
|
||||
|
||||
==== Widget-Type Specific Rendering Details
|
||||
Different widget types expect & require different types of values to be set in the `RenderWidgetOutput` by their renderers.
|
||||
|
||||
===== Pie Chart
|
||||
The `WidgetType.PIE_CHART` requires an object of type `ChartData`.
|
||||
The fields on this type are:
|
||||
|
||||
* `chartData` an instance of `ChartData.Data`, which has the following fields:
|
||||
** `labels` - *List<String>, required* - the labels for the slices of the pie.
|
||||
** `datasets` - *List<Dataset> required* - the data for each slice of the pie.
|
||||
For a Pie chart, only a single entry in this list is used
|
||||
(other chart types using `ChartData` may support more than 1 entry in this list).
|
||||
Fields in this object are:
|
||||
*** `label` - *String, required* - a label to describe the dataset as a whole.
|
||||
e.g., "Orders" for a pie showing orders of different statuses.
|
||||
*** `data` - *List<Number>, required* - the data points for each slice of the pie.
|
||||
*** `color` - *String* - HTML color for the slice
|
||||
*** `urls` - *List<String>* - Optional URLs for slices of the pie to link to when clicked.
|
||||
*** `backgroundColors` - *List<String>* - Optional HTML color codes for each slice of the pie.
|
||||
|
||||
[source,java]
|
||||
.Pie chart widget example
|
||||
----
|
||||
// meta data
|
||||
new QWidgetMetaData()
|
||||
.withName("pieChartExample")
|
||||
.withType(WidgetType.PIE_CHART.getType())
|
||||
.withGridColumns(4)
|
||||
.withIsCard(true)
|
||||
.withLabel("Pie Chart Example")
|
||||
.withCodeReference(new QCodeReference(PieChartExampleRenderer.class));
|
||||
|
||||
// renderer
|
||||
private List<String> labels = new ArrayList<>();
|
||||
private List<String> colors = new ArrayList<>();
|
||||
private List<Number> data = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** helper method - to add values for a slice to the lists
|
||||
*******************************************************************************/
|
||||
private void addSlice(String label, String color, Number datum)
|
||||
{
|
||||
labels.add(label);
|
||||
colors.add(color);
|
||||
data.add(datum);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** main method of the widget renderer
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
addSlice("Apple", "#FF0000", 100);
|
||||
addSlice("Orange", "#FF8000", 150);
|
||||
addSlice("Banana", "#FFFF00", 75);
|
||||
addSlice("Lime", "#00FF00", 100);
|
||||
addSlice("Blueberry", "#0000FF", 200);
|
||||
|
||||
ChartData chartData = new ChartData()
|
||||
.withChartData(new ChartData.Data()
|
||||
.withLabels(labels)
|
||||
.withDatasets(List.of(
|
||||
new ChartData.Data.Dataset()
|
||||
.withLabel("Flavor")
|
||||
.withData(data)
|
||||
.withBackgroundColors(colors)
|
||||
.withUrls(urls))));
|
||||
|
||||
return (new RenderWidgetOutput(chartData));
|
||||
}
|
||||
|
||||
----
|
||||
|
||||
===== Bar Chart
|
||||
#todo#
|
||||
|
||||
===== Stacked Bar Chart
|
||||
#todo#
|
||||
|
||||
===== Horizontal Bar Chart
|
||||
#todo#
|
||||
|
||||
===== Child Record List
|
||||
#todo#
|
||||
|
||||
===== Line Chart
|
||||
#todo#
|
||||
|
||||
===== Small Line Chart
|
||||
#todo#
|
||||
|
||||
===== Statistics
|
||||
#todo#
|
||||
|
||||
===== Parent Widget
|
||||
#todo#
|
||||
|
||||
===== Composite
|
||||
A `WidgetType.COMPOSITE` is built by using one or more smaller elements, known as `Blocks`.
|
||||
Note that `Blocks` can also be used in the data of some other widget types
|
||||
(specifically, within a cell of a Table-type widget, or (in the future?) as a header above a pie or bar chart).
|
||||
|
||||
A composite widget renderer must return data of type `CompositeWidgetData`,
|
||||
which has the following fields:
|
||||
|
||||
* `blocks` - *List<AbstractBlockWidgetData>, required* - The blocks (1 or more) being composited together to make the widget.
|
||||
See below for details on the specific Block types.
|
||||
* `styleOverrides` - *Map<String, Serializable>* - Optional map of CSS attributes
|
||||
(named following javascriptStyleCamelCase) to apply to the `<div>` element that wraps the rendered blocks.
|
||||
* `layout` - *Layout enum* - Optional specifier for how the blocks should be laid out.
|
||||
e.g., predefined sets of CSS attributes to achieve specific layouts.
|
||||
** Note that some blocks are designed to work within composites with specific layouts.
|
||||
Look for matching names, such as `Layout.BADGES_WRAPPER` to go with `NumberIconBadgeBlock`.
|
||||
|
||||
[source,java]
|
||||
.Composite widget example - consisting of 3 Progress Bar Blocks, and one Divider Block
|
||||
----
|
||||
// meta data
|
||||
new QWidgetMetaData()
|
||||
.withName("compositeExample")
|
||||
.withType(WidgetType.COMPOSITE.getType())
|
||||
.withGridColumns(4)
|
||||
.withIsCard(true)
|
||||
.withLabel("Composite Example")
|
||||
.withCodeReference(new QCodeReference(CompositeExampleRenderer.class));
|
||||
|
||||
// renderer
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
CompositeWidgetData data = new CompositeWidgetData();
|
||||
|
||||
data.addBlock(new ProgressBarBlockData()
|
||||
.withValues(new ProgressBarValues()
|
||||
.withHeading("Blocks")
|
||||
.withPercent(new BigDecimal("78.5"))));
|
||||
|
||||
data.addBlock(new ProgressBarBlockData()
|
||||
.withValues(new ProgressBarValues()
|
||||
.withHeading("Progress")
|
||||
.withPercent(new BigDecimal(0))));
|
||||
|
||||
data.addBlock(new DividerBlockData());
|
||||
|
||||
data.addBlock(new ProgressBarBlockData()
|
||||
.withStyles(new ProgressBarStyles().withBarColor("#C0C000"))
|
||||
.withValues(new ProgressBarValues()
|
||||
.withHeading("Custom Color")
|
||||
.withPercent(new BigDecimal("75.3"))));
|
||||
|
||||
return (new RenderWidgetOutput(data));
|
||||
}
|
||||
----
|
||||
|
||||
|
||||
===== Table
|
||||
#todo#
|
||||
|
||||
===== HTML
|
||||
#todo#
|
||||
|
||||
===== Divider
|
||||
#todo#
|
||||
|
||||
===== Process
|
||||
#todo#
|
||||
|
||||
===== Stepper
|
||||
#todo#
|
||||
|
||||
===== Data Bag Viewer
|
||||
#todo#
|
||||
|
||||
===== Script Viewer
|
||||
#todo#
|
||||
|
||||
=== Block-type Specific Rendering Details
|
||||
For Composite-type widgets (or other widgets which can include blocks),
|
||||
there are specific data classes required to be returned by the widget renderer.
|
||||
|
||||
Each block type defines a subclass of `AbstractBlockWidgetData`,
|
||||
which is a generic class with 3 type parameters:
|
||||
|
||||
* `V` - an implementation of `BlockValuesInterface` - to define the type of values that the block uses.
|
||||
* `S` - an implementation of `BlockSlotsInterface` (expected to be an `enum`) - to define the "slots" in the block,
|
||||
that can have Tooltips and/or Links applied to them.
|
||||
* `SX` - an implementation of `BlockStylesInterface` - to define the types of style customizations that the block supports.
|
||||
|
||||
These type parameters are designed to ensure type-safety for the application developer,
|
||||
to ensure that only
|
||||
|
||||
=== Additional Tips
|
||||
|
||||
* To make a Dashboard page (e.g., an App consisting of Widgets) with a parent widget use the parent widget's label as the page's label:
|
||||
** On the `QAppMetaData` that contains the Parent widget, call
|
||||
`.withSupplementalMetaData(new MaterialDashboardAppMetaData().withShowAppLabelOnHomeScreen(false))`.
|
||||
** In the Parent widget's renderer, on the `ParentWidgetData`, call `setLabel("My Label")` and
|
||||
`setIsLabelPageTitle(true)`.
|
141
docs/misc/ScheduledJobs.adoc
Normal file
141
docs/misc/ScheduledJobs.adoc
Normal 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))
|
||||
----
|
||||
|
272
docs/utilities/RecordLookupHelper.adoc
Normal file
272
docs/utilities/RecordLookupHelper.adoc
Normal file
@ -0,0 +1,272 @@
|
||||
== RecordLookupHelper
|
||||
include::../variables.adoc[]
|
||||
|
||||
`RecordLookupHelper` is a utility class that exists to help you... lookup records :)
|
||||
|
||||
OK, I'll try to give a little more context:
|
||||
|
||||
=== Motivation 1: Performance
|
||||
One of the most significant performance optimizations that the team behind QQQ has found time and time again,
|
||||
is to minimize the number of times you have to perform an I/O operation.
|
||||
To just say it more plainly:
|
||||
Make fewer calls to your database (or other backend).
|
||||
|
||||
This is part of why the DML actions in QQQ (InsertAction, UpdateAction, DeleteAction) are all written to work on multiple records:
|
||||
If you've got to insert 1,000 records, the performance difference between doing that as 1,000 SQL INSERT statements vs. just 1 statement cannot be overstated.
|
||||
|
||||
Similarly then, for looking up records:
|
||||
If we can do 1 round-trip to the database backend - that is - 1 query to fetch _n_ records,
|
||||
then in almost all cases it will be significantly faster than doing _n_ queries, one-by-one, for those _n_ records.
|
||||
|
||||
The primary reason why `RecordLookupHelper` exists is to help you cut down on the number of times you have to make a round-trip to a backend data store to fetch records within a process.
|
||||
|
||||
[sidebar]
|
||||
This basically is version of caching, isn't it?
|
||||
Take a set of data from "far away" (e.g., database), and bring it "closer" (local or instance variables), for faster access.
|
||||
So we may describe this as a "cache" through the rest of this document.
|
||||
|
||||
=== Motivation 2: Convenience
|
||||
|
||||
So, given that one wants to try to minimize the number of queries being executed to look up data in a QQQ processes,
|
||||
one can certainly do this "by-hand" in each process that they write.
|
||||
|
||||
Doing this kind of record caching in a QQQ Process `BackendStep` may be done as:
|
||||
|
||||
* Adding a `Map<Integer, QRecord>` as a field in your class.
|
||||
* Setting up and running a `QueryAction`, with a filter based on the collection of the keys you need to look up, then iterating over (or streaming) the results into the map field.
|
||||
* Getting values out of the map when you need to use them (dealing with missing values as needed).
|
||||
|
||||
That's not so bad, but, it does get a little verbose, especially if you're going to have several such caches in your class.
|
||||
|
||||
As such, the second reason that `RecordLookupHelper` exists, is to be a reusable and convenient way to do this kind of optimization,
|
||||
by providing methods to perform the bulk query & map building operation described above,
|
||||
while also providing some convenient methods for accessing such data after it's been fetched.
|
||||
In addition, a single instance of `RecordLookupHelper` can provide this service for multiple tables at once
|
||||
(e.g., so you don't need to add a field to your class for each type of data that you're trying to cache).
|
||||
|
||||
=== Use Cases
|
||||
==== Preload records, then access them
|
||||
Scenario:
|
||||
|
||||
* We're writing a process `BackendStep` that uses `shipment` records as input.
|
||||
* We need to know the `order` record associated with each `shipment` (via an `orderId` foreign key), for some business logic that isn't germaine to the explanation of `RecordLookupHelper`.
|
||||
* We also to access some field on the `shippingPartner` record assigned to each `shipment`.
|
||||
** Note that here, the `shipment` table has a `partnerCode` field, which relates to the `code` unique-key in the `shippingPartner` table.
|
||||
** It's also worth mentioning, we only have a handful of `shippingPartner` records in our database, and we never expect to have very many more than that.
|
||||
|
||||
[source,java]
|
||||
.Example of a process step using a RecordLookupHelper to preload records
|
||||
----
|
||||
public class MyShipmentProcessStep implements BackendStep
|
||||
{
|
||||
// Add a new RecordLookupHelper field, which will "cache" both orders and shippingPartners
|
||||
private RecordLookupHelper recordLookupHelper = new RecordLookupHelper();
|
||||
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException;
|
||||
{
|
||||
// lookup the full shippingPartner table (because it's cheap & easy to do so)
|
||||
// use the partner's "code" as the key field (e.g,. they key in the helper's internal map).
|
||||
recordLookupHelper.preloadRecords("shippingPartner", "code");
|
||||
|
||||
// get all of the orderIds from the input shipments
|
||||
List<Serializable> orderIds = input.getRecords().stream()
|
||||
.map(r -> r.getValue("id")).toList();
|
||||
|
||||
// fetch the orders related to by these shipments
|
||||
recordLookupHelper.preloadRecords("order", "id", orderIds);
|
||||
|
||||
for(QRecord shipment : input.getRecords())
|
||||
{
|
||||
// get someConfigField from the shippingPartner assigned to the shipment
|
||||
Boolean someConfig = recordLookupHelper.getRecordValue("shippingPartner", "someConfigField", "code", shipment.getValue("partnerCode"));
|
||||
|
||||
// get the order record assigned to the shipment
|
||||
QRecord order = recordLookupHelper.getRecordByKey("order", "id", shipment.getValue("orderId"));
|
||||
}
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
==== Lazy fetching records
|
||||
Scenario:
|
||||
|
||||
* We have a `BackendStep` that is taking in `purchaseOrderHeader` records, from an API partner.
|
||||
* For each record, we need to make an API call to the partner to fetch the `purchaseOrderLine` records under that header.
|
||||
** In this contrived example, the partner's API forces us to do these lookups order-by-order...
|
||||
* Each `purchaseOrderLine` that we fetch will have a `sku` on it - a reference to our `item` table.
|
||||
** We need to look up each `item` to apply some business logic.
|
||||
** We assume there are very many item records in the backend database, so we don't want to pre-load the full table.
|
||||
Also, we don't know what `sku` values we will need until we fetch the `purchaseOrderLine`.
|
||||
|
||||
This is a situation where we can use `RecordLookupHelper` to lazily fetch the `item` records as we discover them,
|
||||
and it will take care of not re-fetching ones that it has already loaded.
|
||||
|
||||
[source,java]
|
||||
.Example of a process step using a RecordLookupHelper to lazy fetch records
|
||||
----
|
||||
public class MyPurchaseOrderProcessStep implements BackendStep
|
||||
{
|
||||
// Add a new RecordLookupHelper field, which will "cache" lazy-loaded item records
|
||||
private RecordLookupHelper recordLookupHelper = new RecordLookupHelper();
|
||||
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException;
|
||||
{
|
||||
for(QRecord poHeader : input.getRecords())
|
||||
{
|
||||
// fetch the lines under the header
|
||||
Serializable poNo = poHeader.getValue("poNo");
|
||||
List<QRecord> poLines = new QueryAction().execute(new QueryInput("purchaseOrderLine")
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("poNo", EQUALS, poNo))));
|
||||
|
||||
for(QRecord poLine : poLines)
|
||||
{
|
||||
// use recordLookupHelper to lazy-load item records by SKU.
|
||||
QRecord item = recordLookupHelper.getRecordByKey("item", "sku", poLine.getValue("sku"));
|
||||
|
||||
// business logic related to item performed here.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
In this example, we will be doing exactly 1 query on the `item` table for each unique `sku` that is found across all of the `poLine` records we process.
|
||||
That is to say, if the same `sku` appears on only 1 `poLine`, or if it appears on 100 `poLines`, we will still only query once for that `sku`.
|
||||
|
||||
A slight tweak could be made to the example above, to make 1 `item` table lookup for each `poHeader` record:
|
||||
|
||||
[source,java]
|
||||
.Tweaked example doing 1 item lookup per poLine
|
||||
----
|
||||
// continuing from above, after the List<QRecord> poLines has been built
|
||||
|
||||
// get all of the skus from the lines
|
||||
List<Serializable> skus = poLines.stream().map(r -> r.getValue("sku")).toList();
|
||||
|
||||
// preload the items for the skus
|
||||
recordLookupHelper.preloadRecords("item", "sku", new QQueryFilter(new QFilterCriteria("sku", IN, skus)));
|
||||
|
||||
for(QRecord poLine : poLines)
|
||||
{
|
||||
// get the items from the helper
|
||||
QRecord item = recordLookupHelper.getRecordByKey("item", "sku", poLine.getValue("sku"));
|
||||
|
||||
----
|
||||
|
||||
In this example, we've made a trade-off: We will query the `item` table exactly 1 time for each `poHeader` that we process.
|
||||
However, if the same `sku` is on every PO that we process, we will end up fetching it multiple times.
|
||||
|
||||
This could end up being better or worse than the previous example, depending on the distribution of the data we are dealing with.
|
||||
|
||||
A further tweak, a hybrid approach, could potentially reap the benefits of both of these examples (at the tradeoff of, more code, more complexity):
|
||||
|
||||
[source,java]
|
||||
.Tweaked example doing 1 item lookup per poLine, but only for not-previously-encountered skus
|
||||
----
|
||||
// Critically - we must tell our recordLookupHelper to NOT do any one-off lookups in this table
|
||||
recordLookupHelper.setMayNotDoOneOffLookups("item", "sku");
|
||||
|
||||
// continuing from above, after the List<QRecord> poLines has been built
|
||||
|
||||
// get all of the skus from the lines
|
||||
List<Serializable> skus = poLines.stream().map(r -> r.getValue("sku")).toList();
|
||||
|
||||
// determine which skus have not yet been loaded - e.g., they are not in the recordLookupHelper.
|
||||
// this is why we needed to tell it above not to do one-off lookups; else it would lazy-load each sku here.
|
||||
List<Serializable> skusToLoad = new ArrayList<>();
|
||||
for(Serializable sku : skus)
|
||||
{
|
||||
if(recordLookupHelper.getRecordByKey("item", "sku", sku) == null)
|
||||
{
|
||||
skusToLoad.add(sku);
|
||||
}
|
||||
}
|
||||
|
||||
// preload the item records for any skus that are still needed
|
||||
if(!skusToLoad.isEmpty())
|
||||
{
|
||||
recordLookupHelper.preloadRecords("item", "sku",
|
||||
new QQueryFilter(new QFilterCriteria("sku", IN, skusToLoad)));
|
||||
}
|
||||
|
||||
// continue as above
|
||||
|
||||
----
|
||||
|
||||
In this example, we will start by querying the `item` table once for each `poHeader`, but,
|
||||
if we eventually encounter a PO where all of its `skus` have already been loaded, then we may be able to avoid any `item` queries for such a PO.
|
||||
|
||||
=== Implementation Details
|
||||
|
||||
* Internally, an instance of `RecordLookupHelper` maintains a number of `Maps`,
|
||||
with QQQ table names and field names as keys.
|
||||
* The accessing/lazy-fetching methods (e.g., any method whose name starts with `getRecord`)
|
||||
all begin by looking in these internal maps for the `tableName` and `keyFieldName` that they take as parameters.
|
||||
** If they find an entry in the maps, then it is used for producing a return value.
|
||||
** If they do not find an entry, then they will perform the a `QueryAction`,
|
||||
to try to fetch the requested record from the table's backend.
|
||||
*** Unless the `setMayNotDoOneOffLookups` method has been called for the `(tableName, keyFieldName)` pair.
|
||||
|
||||
=== Full API
|
||||
|
||||
==== Methods for accessing and lazy-fetching
|
||||
* `getRecordByKey(String tableName, String keyFieldName, Serializable key)`
|
||||
|
||||
Get a `QRecord` from `tableName`, where `keyFieldName` = `key`.
|
||||
|
||||
* `getRecordValue(String tableName, String requestedField, String keyFieldName, Serializable key)`
|
||||
|
||||
Get the field `requestedField` from the record in `tableName`, where `keyFieldName` = `key`, as a `Serializable`.
|
||||
If the record is not found, `null` is returned.
|
||||
|
||||
* `getRecordValue(String tableName, String requestedField, String keyFieldName, Serializable key, Class<T> type)`
|
||||
|
||||
Get the field `requestedField` from the record in `tableName`, where `keyFieldName` = `key`, as an instance of `type`.
|
||||
If the record is not found, `null` is returned.
|
||||
|
||||
* `getRecordId(String tableName, String keyFieldName, Serializable key)`
|
||||
|
||||
Get the primary key of the record in `tableName`, where `keyFieldName` = `key`, as a `Serializable`.
|
||||
If the record is not found, `null` is returned.
|
||||
|
||||
* `getRecordId(String tableName, String keyFieldName, Serializable key, Class<T> type)`
|
||||
|
||||
Get the primary key of the record in `tableName`, where `keyFieldName` = `key`, as an instance of `type`.
|
||||
If the record is not found, `null` is returned.
|
||||
|
||||
* `getRecordByUniqueKey(String tableName, Map<String, Serializable> uniqueKey)`
|
||||
|
||||
Get a `QRecord` from `tableName`, where the record matches the field/value pairs in `uniqueKey`.
|
||||
|
||||
_Note: this method does not use the same internal map as the rest of the class.
|
||||
As such, it does not take advantage of any data fetched via the preload methods.
|
||||
It is only used for caching lazy-fetches._
|
||||
|
||||
==== Methods for preloading
|
||||
* `preloadRecords(String tableName, String keyFieldName)`
|
||||
|
||||
Query for all records from `tableName`, storing them in an internal map keyed by the field `keyFieldName`.
|
||||
|
||||
* `preloadRecords(String tableName, String keyFieldName, QQueryFilter filter)`
|
||||
|
||||
Query for records matching `filter` from `tableName`,
|
||||
storing them in an internal map keyed by the field `keyFieldName`.
|
||||
|
||||
* `preloadRecords(String tableName, String keyFieldName, List<Serializable> inList)`
|
||||
|
||||
Query for records with the field `keyFieldName` having a value in `inList` from `tableName`,
|
||||
storing them in an internal map keyed by the field `keyFieldName`.
|
||||
|
||||
==== Config Methods
|
||||
* `setMayNotDoOneOffLookups(String tableName, String fieldName)`
|
||||
|
||||
For cases where you know that you have preloaded records for `tableName`, keyed by `fieldName`,
|
||||
and you know that some of the keys may not have been found,
|
||||
so you want to avoid doing a query when a missed key is found in one of the `getRecord...` methods,
|
||||
then if you call this method, an internal flag is set, which will prevent any such one-off lookups.
|
||||
|
||||
In other words, if this method has been called for a `(tableName, fieldName)` pair,
|
||||
then the `getRecord...` methods will only look in the internal map for records,
|
||||
and no queries will be performed to look for records.
|
@ -1,13 +1,25 @@
|
||||
ifdef::env-name[:relfilesuffix: .adoc]
|
||||
:link-backend: link:Backends{relfilesuffix}[QQQ Backend]
|
||||
:link-backends: link:Backends{relfilesuffix}[QQQ Backends]
|
||||
:link-table: link:Tables{relfilesuffix}[QQQ Table]
|
||||
:link-tables: link:Tables{relfilesuffix}[QQQ Tables]
|
||||
:link-join: link:Joins{relfilesuffix}[QQQ Join]
|
||||
:link-joins: link:Joins{relfilesuffix}[QQQ Joins]
|
||||
:link-field: link:Fields{relfilesuffix}[QQQ Field]
|
||||
:link-fields: link:Fields{relfilesuffix}[QQQ Fields]
|
||||
:link-process: link:Processes{relfilesuffix}[QQQ Process]
|
||||
:link-processes: link:Processes{relfilesuffix}[QQQ Processes]
|
||||
:link-app: link:Apps{relfilesuffix}[QQQ App]
|
||||
:link-apps: link:Apps{relfilesuffix}[QQQ Apps]
|
||||
:link-app: <<Apps,QQQ App>>
|
||||
:link-apps: <<Apps,QQQ Apps>>
|
||||
:link-backend: <<Backends,QQQ Backend>>
|
||||
:link-backends: <<Backends,QQQ Backends>>
|
||||
:link-field: <<Fields,QQQ Field>>
|
||||
:link-fields: <<Fields,QQQ Fields>>
|
||||
:link-icon: <<Icons,Icon>>
|
||||
:link-icons: <<Icons,Icons>>
|
||||
:link-instance: <<QInstance,QInstance>>
|
||||
:link-join: <<Joins,QQQ Join>>
|
||||
:link-joins: <<Joins,QQQ Joins>>
|
||||
:link-permissionRule: <<PermissionRules,Permission Rule>>
|
||||
:link-permissionRules: <<PermissionRules,Permission Rules>>
|
||||
:link-possibleValueSource: <<PossibleValueSources,QQQ Possible Value Source>>
|
||||
:link-possibleValueSources: <<PossibleValueSources,QQQ Possible Value Sources>>
|
||||
:link-process: <<Processes,QQQ Process>>
|
||||
:link-processes: <<Processes,QQQ Processes>>
|
||||
:link-report: <<Reports,QQQ Report>>
|
||||
:link-reports: <<Reports,QQQ Reports>>
|
||||
:link-securityKeyType: <<SecurityKeyTypes,Security Key Type>>
|
||||
:link-securityKeyTypes: <<SecurityKeyTypes,Security Key Types>>
|
||||
:link-table: <<Tables,QQQ Table>>
|
||||
:link-tables: <<Tables,QQQ Tables>>
|
||||
:link-widget: <<Widgets,QQQ Widget>>
|
||||
:link-widgets: <<Widgets,QQQ Widgets>>
|
||||
|
41
pom.xml
41
pom.xml
@ -33,6 +33,7 @@
|
||||
<module>qqq-backend-module-api</module>
|
||||
<module>qqq-backend-module-filesystem</module>
|
||||
<module>qqq-backend-module-rdbms</module>
|
||||
<module>qqq-backend-module-mongodb</module>
|
||||
<module>qqq-language-support-javascript</module>
|
||||
<module>qqq-middleware-picocli</module>
|
||||
<module>qqq-middleware-javalin</module>
|
||||
@ -79,12 +80,12 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-api</artifactId>
|
||||
<version>2.17.1</version>
|
||||
<version>2.23.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-core</artifactId>
|
||||
<version>2.17.1</version>
|
||||
<version>2.23.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
@ -108,6 +109,16 @@
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/java</directory>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<!-- plugins specifically for this module -->
|
||||
<!-- none at this time -->
|
||||
@ -317,6 +328,32 @@ fi
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<!-- mvn javadoc:aggregate -->
|
||||
<reporting>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-javadoc-plugin</artifactId>
|
||||
<version>3.6.2</version>
|
||||
<reportSets>
|
||||
<reportSet>
|
||||
<id>aggregate</id>
|
||||
<inherited>false</inherited>
|
||||
<reports>
|
||||
<report>aggregate</report>
|
||||
</reports>
|
||||
</reportSet>
|
||||
<reportSet>
|
||||
<id>default</id>
|
||||
<reports>
|
||||
<report>javadoc</report>
|
||||
</reports>
|
||||
</reportSet>
|
||||
</reportSets>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</reporting>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>github-qqq-maven-registry</id>
|
||||
|
@ -163,6 +163,19 @@
|
||||
<version>1.12.321</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.quartz-scheduler</groupId>
|
||||
<artifactId>quartz</artifactId>
|
||||
<version>2.3.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Many of the deps we bring in use slf4j. This dep maps slf4j to our logger, log4j -->
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-slf4j-impl</artifactId>
|
||||
<version>2.23.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Common deps for all qqq modules -->
|
||||
<dependency>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
|
@ -23,6 +23,9 @@ package com.kingsrook.qqq.backend.core.actions;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -30,12 +33,26 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
** part of a transaction.
|
||||
**
|
||||
** Most obvious use-case would be a JDBC Connection. See subclass in rdbms module.
|
||||
** Ditto MongoDB.
|
||||
**
|
||||
** Note: One would imagine that this class shouldn't ever implement Serializable...
|
||||
*******************************************************************************/
|
||||
public class QBackendTransaction
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static QBackendTransaction openFor(AbstractTableActionInput input) throws QException
|
||||
{
|
||||
QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher();
|
||||
QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(input.getBackend());
|
||||
QBackendTransaction transaction = qModule.openTransaction(input);
|
||||
return (transaction);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Commit the transaction.
|
||||
*******************************************************************************/
|
||||
|
@ -159,7 +159,7 @@ public class AsyncJobManager
|
||||
private <T extends Serializable> T runAsyncJob(String jobName, AsyncJob<T> asyncJob, UUIDAndTypeStateKey uuidAndTypeStateKey, AsyncJobStatus asyncJobStatus)
|
||||
{
|
||||
String originalThreadName = Thread.currentThread().getName();
|
||||
Thread.currentThread().setName("Job:" + jobName + ":" + uuidAndTypeStateKey.getUuid().toString().substring(0, 8));
|
||||
Thread.currentThread().setName("Job:" + jobName);
|
||||
try
|
||||
{
|
||||
LOG.debug("Starting job " + uuidAndTypeStateKey.getUuid());
|
||||
|
@ -83,6 +83,15 @@ public class AsyncRecordPipeLoop
|
||||
long jobStartTime = System.currentTimeMillis();
|
||||
boolean everCalledConsumer = false;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// in case the pipe capacity has been made very small (useful in tests!), //
|
||||
// then make the minRecordsToConsume match it. //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
if(recordPipe.getCapacity() < minRecordsToConsume)
|
||||
{
|
||||
minRecordsToConsume = recordPipe.getCapacity();
|
||||
}
|
||||
|
||||
while(jobState.equals(AsyncJobState.RUNNING))
|
||||
{
|
||||
if(recordPipe.countAvailableRecords() < minRecordsToConsume)
|
||||
|
@ -295,7 +295,7 @@ public class AuditAction extends AbstractQActionFunction<AuditInput, AuditOutput
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.error("Error performing an audit", e);
|
||||
LOG.warn("Error performing an audit", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -30,10 +30,12 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.AbstractQActionFunction;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.QPossibleValueTranslator;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
|
||||
@ -73,6 +75,8 @@ public class DMLAuditAction extends AbstractQActionFunction<DMLAuditInput, DMLAu
|
||||
|
||||
public static final String AUDIT_CONTEXT_FIELD_NAME = "auditContext";
|
||||
|
||||
private static Set<String> loggedUnauditableTableNames = new HashSet<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -102,6 +106,21 @@ public class DMLAuditAction extends AbstractQActionFunction<DMLAuditInput, DMLAu
|
||||
return (output);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// currently, the table's primary key must be integer... so, log (once) and return early if not that //
|
||||
// (or, if no primary key!) //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
QFieldMetaData field = table.getFields().get(table.getPrimaryKeyField());
|
||||
if(field == null || !QFieldType.INTEGER.equals(field.getType()))
|
||||
{
|
||||
if(!loggedUnauditableTableNames.contains(table.getName()))
|
||||
{
|
||||
LOG.info("Cannot audit table without integer as its primary key", logPair("tableName", table.getName()));
|
||||
loggedUnauditableTableNames.add(table.getName());
|
||||
}
|
||||
return (output);
|
||||
}
|
||||
|
||||
String contextSuffix = getContentSuffix(input);
|
||||
|
||||
AuditInput auditInput = new AuditInput();
|
||||
@ -168,7 +187,7 @@ public class DMLAuditAction extends AbstractQActionFunction<DMLAuditInput, DMLAu
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.error("Error performing DML audit", e, logPair("type", String.valueOf(dmlType)), logPair("table", table.getName()));
|
||||
LOG.warn("Error performing DML audit", e, logPair("type", String.valueOf(dmlType)), logPair("table", table.getName()));
|
||||
}
|
||||
|
||||
return (output);
|
||||
@ -192,6 +211,19 @@ public class DMLAuditAction extends AbstractQActionFunction<DMLAuditInput, DMLAu
|
||||
contextSuffix.append(" ").append(input.getAuditContext());
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////
|
||||
// look for a context value place directly into the session //
|
||||
//////////////////////////////////////////////////////////////
|
||||
QSession qSession = QContext.getQSession();
|
||||
if(qSession != null)
|
||||
{
|
||||
String sessionContext = qSession.getValue(AUDIT_CONTEXT_FIELD_NAME);
|
||||
if(StringUtils.hasContent(sessionContext))
|
||||
{
|
||||
contextSuffix.append(" ").append(sessionContext);
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// note process label (and a possible context from the process's state) if present //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -215,17 +247,20 @@ public class DMLAuditAction extends AbstractQActionFunction<DMLAuditInput, DMLAu
|
||||
///////////////////////////////////////////////////
|
||||
// use api label & version if present in session //
|
||||
///////////////////////////////////////////////////
|
||||
QSession qSession = QContext.getQSession();
|
||||
String apiVersion = qSession.getValue("apiVersion");
|
||||
if(apiVersion != null)
|
||||
if(qSession != null)
|
||||
{
|
||||
String apiLabel = qSession.getValue("apiLabel");
|
||||
if(!StringUtils.hasContent(apiLabel))
|
||||
String apiVersion = qSession.getValue("apiVersion");
|
||||
if(apiVersion != null)
|
||||
{
|
||||
apiLabel = "API";
|
||||
String apiLabel = qSession.getValue("apiLabel");
|
||||
if(!StringUtils.hasContent(apiLabel))
|
||||
{
|
||||
apiLabel = "API";
|
||||
}
|
||||
contextSuffix.append(" via ").append(apiLabel).append(" Version: ").append(apiVersion);
|
||||
}
|
||||
contextSuffix.append(" via ").append(apiLabel).append(" Version: ").append(apiVersion);
|
||||
}
|
||||
|
||||
return (contextSuffix.toString());
|
||||
}
|
||||
|
||||
|
@ -22,6 +22,7 @@
|
||||
package com.kingsrook.qqq.backend.core.actions.automation;
|
||||
|
||||
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
|
||||
|
||||
@ -55,6 +56,30 @@ public enum AutomationStatus implements PossibleValueEnum<Integer>
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get instance by id
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static AutomationStatus getById(Integer id)
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
for(AutomationStatus value : AutomationStatus.values())
|
||||
{
|
||||
if(Objects.equals(value.id, id))
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
**
|
||||
@ -106,10 +131,10 @@ public enum AutomationStatus implements PossibleValueEnum<Integer>
|
||||
public String getInsertOrUpdate()
|
||||
{
|
||||
return switch(this)
|
||||
{
|
||||
case PENDING_INSERT_AUTOMATIONS, RUNNING_INSERT_AUTOMATIONS, FAILED_INSERT_AUTOMATIONS -> "Insert";
|
||||
case PENDING_UPDATE_AUTOMATIONS, RUNNING_UPDATE_AUTOMATIONS, FAILED_UPDATE_AUTOMATIONS -> "Update";
|
||||
case OK -> "";
|
||||
};
|
||||
{
|
||||
case PENDING_INSERT_AUTOMATIONS, RUNNING_INSERT_AUTOMATIONS, FAILED_INSERT_AUTOMATIONS -> "Insert";
|
||||
case PENDING_UPDATE_AUTOMATIONS, RUNNING_UPDATE_AUTOMATIONS, FAILED_UPDATE_AUTOMATIONS -> "Update";
|
||||
case OK -> "";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -22,30 +22,40 @@
|
||||
package com.kingsrook.qqq.backend.core.actions.automation;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
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.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.automation.TableTrigger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.AutomationStatusTrackingType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.QTableAutomationDetails;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.TableAutomationAction;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.TriggerEvent;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
|
||||
import org.apache.commons.lang.NotImplementedException;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -55,19 +65,37 @@ public class RecordAutomationStatusUpdater
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(RecordAutomationStatusUpdater.class);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// feature flag - by default, will be true - before setting records to PENDING_UPDATE_AUTOMATIONS, //
|
||||
// we will fetch them (if we didn't take them in from the caller, which, UpdateAction does if its //
|
||||
// backend supports it), to check their current automationStatus - and if they are currently PENDING //
|
||||
// or RUNNING inserts or updates, we won't update them. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static boolean allowPreUpdateFetch = new QMetaDataVariableInterpreter().getBooleanFromPropertyOrEnvironment("qqq.recordAutomationStatusUpdater.allowPreUpdateFetch", "QQQ_RECORD_AUTOMATION_STATUS_UPDATER_ALLOW_PRE_UPDATE_FETCH", true);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// feature flag - by default, we'll memoize the check for triggers - but we can turn it off. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static boolean memoizeCheckForTriggers = new QMetaDataVariableInterpreter().getBooleanFromPropertyOrEnvironment("qqq.recordAutomationStatusUpdater.memoizeCheckForTriggers", "QQQ_RECORD_AUTOMATION_STATUS_UPDATER_MEMOIZE_CHECK_FOR_TRIGGERS", true);
|
||||
|
||||
private static Memoization<Key, Boolean> areThereTableTriggersForTableMemoization = new Memoization<Key, Boolean>().withTimeout(Duration.of(60, ChronoUnit.SECONDS));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** for a list of records from a table, set their automation status - based on
|
||||
** how the table is configured.
|
||||
*******************************************************************************/
|
||||
public static boolean setAutomationStatusInRecords(QSession session, QTableMetaData table, List<QRecord> records, AutomationStatus automationStatus)
|
||||
public static boolean setAutomationStatusInRecords(QTableMetaData table, List<QRecord> records, AutomationStatus automationStatus, QBackendTransaction transaction, List<QRecord> oldRecordList)
|
||||
{
|
||||
if(table == null || table.getAutomationDetails() == null || CollectionUtils.nullSafeIsEmpty(records))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
QTableAutomationDetails automationDetails = table.getAutomationDetails();
|
||||
Set<Serializable> pkeysWeMayNotUpdate = new HashSet<>();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// In case an automation is running, and it updates records - don't let those records be marked //
|
||||
// as PENDING_UPDATE_AUTOMATIONS... this is meant to avoid having a record's automation update //
|
||||
@ -81,12 +109,60 @@ public class RecordAutomationStatusUpdater
|
||||
for(StackTraceElement stackTraceElement : e.getStackTrace())
|
||||
{
|
||||
String className = stackTraceElement.getClassName();
|
||||
if(className.contains("com.kingsrook.qqq.backend.core.actions.automation") && !className.equals(RecordAutomationStatusUpdater.class.getName()) && !className.endsWith("Test"))
|
||||
if(className.contains(RecordAutomationStatusUpdater.class.getPackageName()) && !className.equals(RecordAutomationStatusUpdater.class.getName()) && !className.endsWith("Test") && !className.contains("Test$"))
|
||||
{
|
||||
LOG.debug("Avoiding re-setting automation status to PENDING_UPDATE while running an automation");
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// if table uses field-in-table status tracking, then check the old records, //
|
||||
// before we set them to pending-updates, to avoid losing other pending or //
|
||||
// running status information. We will allow moving from OK or the 2 //
|
||||
// failed statuses into pending-updates - which seems right. //
|
||||
// This is added to fix cases where an update that comes in before insert //
|
||||
// -automations have run, will cause the pending-insert status to be missed. //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
if(automationDetails.getStatusTracking() != null && AutomationStatusTrackingType.FIELD_IN_TABLE.equals(automationDetails.getStatusTracking().getType()))
|
||||
{
|
||||
try
|
||||
{
|
||||
if(CollectionUtils.nullSafeIsEmpty(oldRecordList))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we didn't get the oldRecordList as input (though UpdateAction should usually pass it?) //
|
||||
// then check feature-flag if we're allowed to do a lookup here & now. If so, then do. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(allowPreUpdateFetch)
|
||||
{
|
||||
List<Serializable> pkeysToLookup = records.stream().map(r -> r.getValue(table.getPrimaryKeyField())).toList();
|
||||
oldRecordList = new QueryAction().execute(new QueryInput(table.getName())
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria(table.getPrimaryKeyField(), QCriteriaOperator.IN, pkeysToLookup)))
|
||||
.withTransaction(transaction)
|
||||
).getRecords();
|
||||
}
|
||||
}
|
||||
|
||||
for(QRecord freshRecord : CollectionUtils.nonNullList(oldRecordList))
|
||||
{
|
||||
Serializable recordStatus = freshRecord.getValue(automationDetails.getStatusTracking().getFieldName());
|
||||
if(AutomationStatus.PENDING_INSERT_AUTOMATIONS.getId().equals(recordStatus)
|
||||
|| AutomationStatus.PENDING_UPDATE_AUTOMATIONS.getId().equals(recordStatus)
|
||||
|| AutomationStatus.RUNNING_INSERT_AUTOMATIONS.getId().equals(recordStatus)
|
||||
|| AutomationStatus.RUNNING_UPDATE_AUTOMATIONS.getId().equals(recordStatus))
|
||||
{
|
||||
Serializable primaryKey = freshRecord.getValue(table.getPrimaryKeyField());
|
||||
LOG.debug("May not update automation status", logPair("table", table.getName()), logPair("id", primaryKey), logPair("currentStatus", recordStatus), logPair("requestedStatus", automationStatus.getId()));
|
||||
pkeysWeMayNotUpdate.add(primaryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(QException qe)
|
||||
{
|
||||
LOG.error("Error checking existing automation status before setting new automation status - more records will be updated than maybe should be...", qe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -98,19 +174,15 @@ public class RecordAutomationStatusUpdater
|
||||
automationStatus = AutomationStatus.OK;
|
||||
}
|
||||
|
||||
QTableAutomationDetails automationDetails = table.getAutomationDetails();
|
||||
if(automationDetails.getStatusTracking() != null && AutomationStatusTrackingType.FIELD_IN_TABLE.equals(automationDetails.getStatusTracking().getType()))
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// todo - seems like there's some case here, where if an order was in PENDING_INSERT, but then some other job updated the record, that we'd //
|
||||
// lose that pending status, which would be a Bad Thing™... //
|
||||
// problem is - we may not have the full record in here, so we can't necessarily check the record to see what status it's currently in... //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
record.setValue(automationDetails.getStatusTracking().getFieldName(), automationStatus.getId());
|
||||
// todo - another field - for the automation timestamp??
|
||||
if(!pkeysWeMayNotUpdate.contains(record.getValue(table.getPrimaryKeyField())))
|
||||
{
|
||||
record.setValue(automationDetails.getStatusTracking().getFieldName(), automationStatus.getId());
|
||||
// todo - another field - for the automation timestamp??
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,11 +260,29 @@ public class RecordAutomationStatusUpdater
|
||||
return (false);
|
||||
}
|
||||
|
||||
if(memoizeCheckForTriggers)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// as within the lookup method, error on the side of "yes, maybe there are triggers" //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
Optional<Boolean> result = areThereTableTriggersForTableMemoization.getResult(new Key(table, triggerEvent), key -> lookupIfThereAreTriggersForTable(table, triggerEvent));
|
||||
return result.orElse(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
return lookupIfThereAreTriggersForTable(table, triggerEvent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Boolean lookupIfThereAreTriggersForTable(QTableMetaData table, TriggerEvent triggerEvent)
|
||||
{
|
||||
try
|
||||
{
|
||||
///////////////////
|
||||
// todo - cache? //
|
||||
///////////////////
|
||||
CountInput countInput = new CountInput();
|
||||
countInput.setTableName(TableTrigger.TABLE_NAME);
|
||||
countInput.setFilter(new QQueryFilter(
|
||||
@ -207,6 +297,7 @@ public class RecordAutomationStatusUpdater
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the count query failed, we're a bit safer to err on the side of "yeah, there might be automations" //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
LOG.warn("Error looking if there are triggers for table", e, logPair("tableName", table.getName()));
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
@ -217,12 +308,12 @@ public class RecordAutomationStatusUpdater
|
||||
** for a list of records, update their automation status and actually Update the
|
||||
** backend as well.
|
||||
*******************************************************************************/
|
||||
public static void setAutomationStatusInRecordsAndUpdate(QInstance instance, QSession session, QTableMetaData table, List<QRecord> records, AutomationStatus automationStatus) throws QException
|
||||
public static void setAutomationStatusInRecordsAndUpdate(QTableMetaData table, List<QRecord> records, AutomationStatus automationStatus, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
QTableAutomationDetails automationDetails = table.getAutomationDetails();
|
||||
if(automationDetails != null && AutomationStatusTrackingType.FIELD_IN_TABLE.equals(automationDetails.getStatusTracking().getType()))
|
||||
{
|
||||
boolean didSetStatusField = setAutomationStatusInRecords(session, table, records, automationStatus);
|
||||
boolean didSetStatusField = setAutomationStatusInRecords(table, records, automationStatus, transaction, null);
|
||||
if(didSetStatusField)
|
||||
{
|
||||
UpdateInput updateInput = new UpdateInput();
|
||||
@ -237,6 +328,7 @@ public class RecordAutomationStatusUpdater
|
||||
.withValue(table.getPrimaryKeyField(), r.getValue(table.getPrimaryKeyField()))
|
||||
.withValue(automationDetails.getStatusTracking().getFieldName(), r.getValue(automationDetails.getStatusTracking().getFieldName()))).toList());
|
||||
updateInput.setAreAllValuesBeingUpdatedTheSame(true);
|
||||
updateInput.setTransaction(transaction);
|
||||
updateInput.setOmitDmlAudit(true);
|
||||
|
||||
new UpdateAction().execute(updateInput);
|
||||
@ -250,4 +342,8 @@ public class RecordAutomationStatusUpdater
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private record Key(QTableMetaData table, TriggerEvent triggerEvent) {}
|
||||
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qqq.backend.core.actions.async.AsyncRecordPipeLoop;
|
||||
@ -43,6 +44,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.LogPair;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
@ -59,18 +61,21 @@ import com.kingsrook.qqq.backend.core.model.automation.TableTrigger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.DynamicDefaultValueBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.AutomationStatusTrackingType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.QTableAutomationDetails;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.TableAutomationAction;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.TriggerEvent;
|
||||
import com.kingsrook.qqq.backend.core.model.savedfilters.SavedFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.savedviews.SavedView;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
import org.apache.commons.lang.NotImplementedException;
|
||||
import org.json.JSONObject;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@ -87,8 +92,9 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(PollingAutomationPerTableRunner.class);
|
||||
|
||||
private final TableActions tableActions;
|
||||
private final String name;
|
||||
private final TableActionsInterface tableActions;
|
||||
|
||||
private String name;
|
||||
|
||||
private QInstance instance;
|
||||
private Supplier<QSession> sessionSupplier;
|
||||
@ -116,10 +122,56 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Interface to be used by 2 records in this class - normal TableActions, and
|
||||
** ShardedTableActions.
|
||||
*******************************************************************************/
|
||||
public record TableActions(String tableName, AutomationStatus status)
|
||||
public interface TableActionsInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
String tableName();
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
QTableAutomationDetails tableAutomationDetails();
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
AutomationStatus status();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wrapper for a pair of (tableName, automationStatus)
|
||||
*******************************************************************************/
|
||||
public record TableActions(String tableName, QTableAutomationDetails tableAutomationDetails, AutomationStatus status) implements TableActionsInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void noopToFakeTestCoverage()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** extended version of TableAction, for sharding use-case - adds the shard
|
||||
** details.
|
||||
*******************************************************************************/
|
||||
public record ShardedTableActions(String tableName, QTableAutomationDetails tableAutomationDetails, AutomationStatus status, String shardByFieldName, Serializable shardValue, String shardLabel) implements TableActionsInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void noopToFakeTestCoverage()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -128,16 +180,46 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
** basically just get a list of tables which at least *could* have automations
|
||||
** run - either meta-data automations, or table-triggers (data/user defined).
|
||||
*******************************************************************************/
|
||||
public static List<TableActions> getTableActions(QInstance instance, String providerName)
|
||||
public static List<TableActionsInterface> getTableActions(QInstance instance, String providerName)
|
||||
{
|
||||
List<TableActions> tableActionList = new ArrayList<>();
|
||||
List<TableActionsInterface> tableActionList = new ArrayList<>();
|
||||
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
if(table.getAutomationDetails() != null && providerName.equals(table.getAutomationDetails().getProviderName()))
|
||||
QTableAutomationDetails automationDetails = table.getAutomationDetails();
|
||||
if(automationDetails != null && providerName.equals(automationDetails.getProviderName()))
|
||||
{
|
||||
tableActionList.add(new TableActions(table.getName(), AutomationStatus.PENDING_INSERT_AUTOMATIONS));
|
||||
tableActionList.add(new TableActions(table.getName(), AutomationStatus.PENDING_UPDATE_AUTOMATIONS));
|
||||
if(StringUtils.hasContent(automationDetails.getShardByFieldName()))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// for sharded automations, add a tableAction (of the sharded subtype) for each shard-value //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
try
|
||||
{
|
||||
QueryInput queryInput = new QueryInput();
|
||||
queryInput.setTableName(automationDetails.getShardSourceTableName());
|
||||
QueryOutput queryOutput = new QueryAction().execute(queryInput);
|
||||
for(QRecord record : queryOutput.getRecords())
|
||||
{
|
||||
Serializable shardId = record.getValue(automationDetails.getShardIdFieldName());
|
||||
String label = record.getValueString(automationDetails.getShardLabelFieldName());
|
||||
tableActionList.add(new ShardedTableActions(table.getName(), automationDetails, AutomationStatus.PENDING_INSERT_AUTOMATIONS, automationDetails.getShardByFieldName(), shardId, label));
|
||||
tableActionList.add(new ShardedTableActions(table.getName(), automationDetails, AutomationStatus.PENDING_UPDATE_AUTOMATIONS, automationDetails.getShardByFieldName(), shardId, label));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.error("Error getting sharded table automation actions for a table", e, new LogPair("tableName", table.getName()));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// for non-sharded, we just need table name & automation status //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
tableActionList.add(new TableActions(table.getName(), automationDetails, AutomationStatus.PENDING_INSERT_AUTOMATIONS));
|
||||
tableActionList.add(new TableActions(table.getName(), automationDetails, AutomationStatus.PENDING_UPDATE_AUTOMATIONS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,12 +231,17 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public PollingAutomationPerTableRunner(QInstance instance, String providerName, Supplier<QSession> sessionSupplier, TableActions tableActions)
|
||||
public PollingAutomationPerTableRunner(QInstance instance, String providerName, Supplier<QSession> sessionSupplier, TableActionsInterface tableActions)
|
||||
{
|
||||
this.instance = instance;
|
||||
this.sessionSupplier = sessionSupplier;
|
||||
this.tableActions = tableActions;
|
||||
this.name = providerName + ">" + tableActions.tableName() + ">" + tableActions.status().getInsertOrUpdate();
|
||||
|
||||
if(tableActions instanceof ShardedTableActions shardedTableActions)
|
||||
{
|
||||
this.name += ":" + shardedTableActions.shardLabel();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -173,12 +260,11 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
|
||||
try
|
||||
{
|
||||
QSession session = sessionSupplier != null ? sessionSupplier.get() : new QSession();
|
||||
processTableInsertOrUpdate(instance.getTable(tableActions.tableName()), session, tableActions.status());
|
||||
processTableInsertOrUpdate(instance.getTable(tableActions.tableName()), tableActions.status());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error running automations", e);
|
||||
LOG.warn("Error running automations", e, logPair("tableName", tableActions.tableName()), logPair("status", tableActions.status()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -192,7 +278,7 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
/*******************************************************************************
|
||||
** Query for and process records that have a PENDING_INSERT or PENDING_UPDATE status on a given table.
|
||||
*******************************************************************************/
|
||||
public void processTableInsertOrUpdate(QTableMetaData table, QSession session, AutomationStatus automationStatus) throws QException
|
||||
public void processTableInsertOrUpdate(QTableMetaData table, AutomationStatus automationStatus) throws QException
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// get the actions to run against this table in this automation status //
|
||||
@ -222,19 +308,30 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
AutomationStatusTrackingType statusTrackingType = automationDetails.getStatusTracking().getType();
|
||||
if(AutomationStatusTrackingType.FIELD_IN_TABLE.equals(statusTrackingType))
|
||||
{
|
||||
queryInput.setFilter(new QQueryFilter().withCriteria(new QFilterCriteria(automationDetails.getStatusTracking().getFieldName(), QCriteriaOperator.EQUALS, List.of(automationStatus.getId()))));
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria(new QFilterCriteria(automationDetails.getStatusTracking().getFieldName(), QCriteriaOperator.EQUALS, List.of(automationStatus.getId())));
|
||||
addOrderByToQueryFilter(table, automationStatus, filter);
|
||||
queryInput.setFilter(filter);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw (new NotImplementedException("Automation Status Tracking type [" + statusTrackingType + "] is not yet implemented in here."));
|
||||
}
|
||||
|
||||
if(tableActions instanceof ShardedTableActions shardedTableActions)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////
|
||||
// for sharded actions, add the shardBy field as a criteria //
|
||||
//////////////////////////////////////////////////////////////
|
||||
QQueryFilter filter = queryInput.getFilter();
|
||||
filter.addCriteria(new QFilterCriteria(shardedTableActions.shardByFieldName(), QCriteriaOperator.EQUALS, shardedTableActions.shardValue()));
|
||||
}
|
||||
|
||||
queryInput.setRecordPipe(recordPipe);
|
||||
return (new QueryAction().execute(queryInput));
|
||||
}, () ->
|
||||
{
|
||||
List<QRecord> records = recordPipe.consumeAvailableRecords();
|
||||
applyActionsToRecords(session, table, records, actions, automationStatus);
|
||||
applyActionsToRecords(table, records, actions, automationStatus);
|
||||
return (records.size());
|
||||
}
|
||||
);
|
||||
@ -242,6 +339,38 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static void addOrderByToQueryFilter(QTableMetaData table, AutomationStatus automationStatus, QQueryFilter filter)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// look for a field in the table with either create-date or modify-date behavior, //
|
||||
// based on if doing insert or update automations //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
DynamicDefaultValueBehavior dynamicDefaultValueBehavior = automationStatus.equals(AutomationStatus.PENDING_INSERT_AUTOMATIONS) ? DynamicDefaultValueBehavior.CREATE_DATE : DynamicDefaultValueBehavior.MODIFY_DATE;
|
||||
Optional<QFieldMetaData> field = table.getFields().values().stream()
|
||||
.filter(f -> dynamicDefaultValueBehavior.equals(f.getBehaviorOrDefault(QContext.getQInstance(), DynamicDefaultValueBehavior.class)))
|
||||
.findFirst();
|
||||
|
||||
if(field.isPresent())
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// if a create/modify date field was found, order by it (ascending) //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
filter.addOrderBy(new QFilterOrderBy(field.get().getName()));
|
||||
}
|
||||
else
|
||||
{
|
||||
////////////////////////////////////
|
||||
// else, order by the primary key //
|
||||
////////////////////////////////////
|
||||
filter.addOrderBy(new QFilterOrderBy(table.getPrimaryKeyField()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** get the actions to run against a table in an automation status. both from
|
||||
** metaData and tableTriggers/data.
|
||||
@ -258,7 +387,23 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
{
|
||||
if(action.getTriggerEvent().equals(triggerEvent))
|
||||
{
|
||||
rs.add(action);
|
||||
///////////////////////////////////////////////////////////
|
||||
// for sharded configs, only run if the shard id matches //
|
||||
///////////////////////////////////////////////////////////
|
||||
if(tableActions instanceof ShardedTableActions shardedTableActions)
|
||||
{
|
||||
if(shardedTableActions.shardValue().equals(action.getShardId()))
|
||||
{
|
||||
rs.add(action);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
////////////////////////////////////////////
|
||||
// for non-sharded, always add the action //
|
||||
////////////////////////////////////////////
|
||||
rs.add(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -285,13 +430,15 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
if(filterId != null)
|
||||
{
|
||||
GetInput getInput = new GetInput();
|
||||
getInput.setTableName(SavedFilter.TABLE_NAME);
|
||||
getInput.setTableName(SavedView.TABLE_NAME);
|
||||
getInput.setPrimaryKey(filterId);
|
||||
GetOutput getOutput = new GetAction().execute(getInput);
|
||||
if(getOutput.getRecord() != null)
|
||||
{
|
||||
SavedFilter savedFilter = new SavedFilter(getOutput.getRecord());
|
||||
filter = JsonUtils.toObject(savedFilter.getFilterJson(), QQueryFilter.class);
|
||||
SavedView savedView = new SavedView(getOutput.getRecord());
|
||||
JSONObject viewJson = new JSONObject(savedView.getViewJson());
|
||||
JSONObject queryFilter = viewJson.getJSONObject("queryFilter");
|
||||
filter = JsonUtils.toObject(queryFilter.toString(), QQueryFilter.class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -324,7 +471,7 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
** table's actions against them - IF they are found to match the action's filter
|
||||
** (assuming it has one - if it doesn't, then all records match).
|
||||
*******************************************************************************/
|
||||
private void applyActionsToRecords(QSession session, QTableMetaData table, List<QRecord> records, List<TableAutomationAction> actions, AutomationStatus automationStatus) throws QException
|
||||
private void applyActionsToRecords(QTableMetaData table, List<QRecord> records, List<TableAutomationAction> actions, AutomationStatus automationStatus) throws QException
|
||||
{
|
||||
if(CollectionUtils.nullSafeIsEmpty(records))
|
||||
{
|
||||
@ -334,7 +481,7 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
///////////////////////////////////////////////////
|
||||
// mark the records as RUNNING their automations //
|
||||
///////////////////////////////////////////////////
|
||||
RecordAutomationStatusUpdater.setAutomationStatusInRecordsAndUpdate(instance, session, table, records, pendingToRunningStatusMap.get(automationStatus));
|
||||
RecordAutomationStatusUpdater.setAutomationStatusInRecordsAndUpdate(table, records, pendingToRunningStatusMap.get(automationStatus), null);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// foreach action - run it against the records (but only if they match the action's filter, if there is one) //
|
||||
@ -352,13 +499,15 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
////////////////////////////////////////
|
||||
// update status on all these records //
|
||||
////////////////////////////////////////
|
||||
if(anyActionsFailed)
|
||||
AutomationStatus statusToUpdateTo = anyActionsFailed ? pendingToFailedStatusMap.get(automationStatus) : AutomationStatus.OK;
|
||||
try
|
||||
{
|
||||
RecordAutomationStatusUpdater.setAutomationStatusInRecordsAndUpdate(instance, session, table, records, pendingToFailedStatusMap.get(automationStatus));
|
||||
RecordAutomationStatusUpdater.setAutomationStatusInRecordsAndUpdate(table, records, statusToUpdateTo, null);
|
||||
}
|
||||
else
|
||||
catch(Exception e)
|
||||
{
|
||||
RecordAutomationStatusUpdater.setAutomationStatusInRecordsAndUpdate(instance, session, table, records, AutomationStatus.OK);
|
||||
LOG.warn("Error updating automationStatus after running automations", logPair("tableName", table), logPair("count", records.size()), logPair("status", statusToUpdateTo));
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -377,7 +526,7 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
// note - this method - will re-query the objects, so we should have confidence that their data is fresh... //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
List<QRecord> matchingQRecords = getRecordsMatchingActionFilter(table, records, action);
|
||||
LOG.debug("Of the {} records that were pending automations, {} of them match the filter on the action {}", records.size(), matchingQRecords.size(), action);
|
||||
LOG.debug("Of the [" + records.size() + "] records that were pending automations, [" + matchingQRecords.size() + "] of them match the filter on the action:" + action);
|
||||
if(CollectionUtils.nullSafeHasContents(matchingQRecords))
|
||||
{
|
||||
LOG.debug(" Processing " + matchingQRecords.size() + " records in " + table + " for action " + action);
|
||||
@ -388,7 +537,7 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Caught exception processing records on " + table + " for action " + action, e);
|
||||
LOG.warn("Caught exception processing automations", e, logPair("tableName", table), logPair("action", action.getName()));
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
@ -452,7 +601,7 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
|
||||
/*******************************************************************************
|
||||
** Finally, actually run action code against a list of known matching records.
|
||||
** todo not commit - move to somewhere genericer
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static void applyActionToMatchingRecords(QTableMetaData table, List<QRecord> records, TableAutomationAction action) throws Exception
|
||||
{
|
||||
@ -471,7 +620,7 @@ public class PollingAutomationPerTableRunner implements Runnable
|
||||
@Override
|
||||
public QQueryFilter getQueryFilter()
|
||||
{
|
||||
List<Serializable> recordIds = records.stream().map(r -> r.getValueInteger(table.getPrimaryKeyField())).collect(Collectors.toList());
|
||||
List<Serializable> recordIds = records.stream().map(r -> r.getValue(table.getPrimaryKeyField())).collect(Collectors.toList());
|
||||
return (new QQueryFilter().withCriteria(new QFilterCriteria(table.getPrimaryKeyField(), QCriteriaOperator.IN, recordIds)));
|
||||
}
|
||||
});
|
||||
|
@ -47,12 +47,24 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
** records that the delete action marked in error - the user might want to do
|
||||
** something special with them (idk, try some other way to delete them?)
|
||||
*******************************************************************************/
|
||||
public abstract class AbstractPostDeleteCustomizer
|
||||
public abstract class AbstractPostDeleteCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
protected DeleteInput deleteInput;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
this.deleteInput = deleteInput;
|
||||
return apply(records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -42,12 +42,24 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
**
|
||||
** Note that the full insertInput is available as a field in this class.
|
||||
*******************************************************************************/
|
||||
public abstract class AbstractPostInsertCustomizer
|
||||
public abstract class AbstractPostInsertCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
protected InsertInput insertInput;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
|
||||
{
|
||||
this.insertInput = insertInput;
|
||||
return (apply(records));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -23,16 +23,29 @@ package com.kingsrook.qqq.backend.core.actions.customizers;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public abstract class AbstractPostQueryCustomizer
|
||||
public abstract class AbstractPostQueryCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
protected AbstractTableActionInput input;
|
||||
protected QueryOrGetInputInterface input;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface queryInput, List<QRecord> records) throws QException
|
||||
{
|
||||
input = queryInput;
|
||||
return apply(records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -47,7 +60,7 @@ public abstract class AbstractPostQueryCustomizer
|
||||
** Getter for input
|
||||
**
|
||||
*******************************************************************************/
|
||||
public AbstractTableActionInput getInput()
|
||||
public QueryOrGetInputInterface getInput()
|
||||
{
|
||||
return (input);
|
||||
}
|
||||
@ -58,7 +71,7 @@ public abstract class AbstractPostQueryCustomizer
|
||||
** Setter for input
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void setInput(AbstractTableActionInput input)
|
||||
public void setInput(QueryOrGetInputInterface input)
|
||||
{
|
||||
this.input = input;
|
||||
}
|
||||
|
@ -26,6 +26,7 @@ import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@ -48,7 +49,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
** available (if the backend supports it) - both as a list (`getOldRecordList`)
|
||||
** and as a memoized (by this class) map of primaryKey to record (`getOldRecordMap`).
|
||||
*******************************************************************************/
|
||||
public abstract class AbstractPostUpdateCustomizer
|
||||
public abstract class AbstractPostUpdateCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
protected UpdateInput updateInput;
|
||||
protected List<QRecord> oldRecordList;
|
||||
@ -57,6 +58,19 @@ public abstract class AbstractPostUpdateCustomizer
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
this.updateInput = updateInput;
|
||||
this.oldRecordList = oldRecordList.orElse(null);
|
||||
return apply(records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -50,7 +50,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
** Note that the full deleteInput is available as a field in this class.
|
||||
**
|
||||
*******************************************************************************/
|
||||
public abstract class AbstractPreDeleteCustomizer
|
||||
public abstract class AbstractPreDeleteCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
protected DeleteInput deleteInput;
|
||||
|
||||
@ -58,6 +58,19 @@ public abstract class AbstractPreDeleteCustomizer
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
this.deleteInput = deleteInput;
|
||||
this.isPreview = isPreview;
|
||||
return apply(records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -47,7 +47,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
**
|
||||
** Note that the full insertInput is available as a field in this class.
|
||||
*******************************************************************************/
|
||||
public abstract class AbstractPreInsertCustomizer
|
||||
public abstract class AbstractPreInsertCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
protected InsertInput insertInput;
|
||||
|
||||
@ -70,6 +70,30 @@ public abstract class AbstractPreInsertCustomizer
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
this.insertInput = insertInput;
|
||||
this.isPreview = isPreview;
|
||||
return (apply(records));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public WhenToRun whenToRunPreInsert(InsertInput insertInput, boolean isPreview)
|
||||
{
|
||||
return getWhenToRun();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -26,6 +26,7 @@ import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@ -53,7 +54,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
** available (if the backend supports it) - both as a list (`getOldRecordList`)
|
||||
** and as a memoized (by this class) map of primaryKey to record (`getOldRecordMap`).
|
||||
*******************************************************************************/
|
||||
public abstract class AbstractPreUpdateCustomizer
|
||||
public abstract class AbstractPreUpdateCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
protected UpdateInput updateInput;
|
||||
protected List<QRecord> oldRecordList;
|
||||
@ -63,6 +64,20 @@ public abstract class AbstractPreUpdateCustomizer
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
this.updateInput = updateInput;
|
||||
this.isPreview = isPreview;
|
||||
this.oldRecordList = oldRecordList.orElse(null);
|
||||
return apply(records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -22,6 +22,7 @@
|
||||
package com.kingsrook.qqq.backend.core.actions.customizers;
|
||||
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationHandler;
|
||||
@ -34,42 +35,35 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.TableAutomationAction;
|
||||
import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeFunction;
|
||||
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility to load code for running QQQ customizers.
|
||||
**
|
||||
** TODO - redo all to go through method that memoizes class & constructor
|
||||
** lookup. That memoziation causes 1,000,000 such calls to go from ~500ms
|
||||
** to ~100ms.
|
||||
*******************************************************************************/
|
||||
public class QCodeLoader
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(QCodeLoader.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static <T, R> Optional<Function<T, R>> getTableCustomizerFunction(QTableMetaData table, String customizerName)
|
||||
{
|
||||
Optional<QCodeReference> codeReference = table.getCustomizer(customizerName);
|
||||
if(codeReference.isPresent())
|
||||
{
|
||||
return (Optional.ofNullable(QCodeLoader.getFunction(codeReference.get())));
|
||||
}
|
||||
return (Optional.empty());
|
||||
}
|
||||
private static Memoization<String, Constructor<?>> constructorMemoization = new Memoization<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static <T> Optional<T> getTableCustomizer(Class<T> expectedClass, QTableMetaData table, String customizerName)
|
||||
public static Optional<TableCustomizerInterface> getTableCustomizer(QTableMetaData table, String customizerName)
|
||||
{
|
||||
Optional<QCodeReference> codeReference = table.getCustomizer(customizerName);
|
||||
if(codeReference.isPresent())
|
||||
{
|
||||
return (Optional.ofNullable(QCodeLoader.getAdHoc(expectedClass, codeReference.get())));
|
||||
return (Optional.ofNullable(QCodeLoader.getAdHoc(TableCustomizerInterface.class, codeReference.get())));
|
||||
}
|
||||
return (Optional.empty());
|
||||
}
|
||||
@ -102,7 +96,7 @@ public class QCodeLoader
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.error("Error initializing customizer", logPair("codeReference", codeReference), e);
|
||||
LOG.error("Error initializing customizer", e, logPair("codeReference", codeReference));
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// return null here - under the assumption that during normal run-time operations, we'll never hit here //
|
||||
@ -141,7 +135,7 @@ public class QCodeLoader
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.error("Error initializing customizer", logPair("codeReference", codeReference), e);
|
||||
LOG.error("Error initializing customizer", e, logPair("codeReference", codeReference));
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// return null here - under the assumption that during normal run-time operations, we'll never hit here //
|
||||
@ -175,12 +169,25 @@ public class QCodeLoader
|
||||
|
||||
try
|
||||
{
|
||||
Class<?> customizerClass = Class.forName(codeReference.getName());
|
||||
return ((T) customizerClass.getConstructor().newInstance());
|
||||
Optional<Constructor<?>> constructor = constructorMemoization.getResultThrowing(codeReference.getName(), (UnsafeFunction<String, Constructor<?>, Exception>) s ->
|
||||
{
|
||||
Class<?> customizerClass = Class.forName(codeReference.getName());
|
||||
return customizerClass.getConstructor();
|
||||
});
|
||||
|
||||
if(constructor.isPresent())
|
||||
{
|
||||
return ((T) constructor.get().newInstance());
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG.error("Could not get constructor for code reference", logPair("codeReference", codeReference));
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.error("Error initializing customizer", logPair("codeReference", codeReference), e);
|
||||
LOG.error("Error initializing customizer", e, logPair("codeReference", codeReference));
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// return null here - under the assumption that during normal run-time operations, we'll never hit here //
|
||||
|
@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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.actions.customizers;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
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.data.QRecord;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Common interface used by all (core) TableCustomizer types (e.g., post-query,
|
||||
** and {pre,post}-{insert,update,delete}.
|
||||
**
|
||||
** Note that the abstract-base classes for each action still exist, though have
|
||||
** been back-ported to be implementors of this interface. The action classes
|
||||
** will now expect this type, and call this type's methods.
|
||||
**
|
||||
*******************************************************************************/
|
||||
public interface TableCustomizerInterface
|
||||
{
|
||||
QLogger LOG = QLogger.getLogger(TableCustomizerInterface.class);
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** custom actions to run after a query (or get!) takes place.
|
||||
**
|
||||
*******************************************************************************/
|
||||
default List<QRecord> postQuery(QueryOrGetInputInterface queryInput, List<QRecord> records) throws QException
|
||||
{
|
||||
LOG.info("A default implementation of postQuery is running... Probably not expected!", logPair("tableName", queryInput.getTableName()));
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** custom actions before an insert takes place.
|
||||
**
|
||||
** It's important for implementations to be aware of the isPreview field, which
|
||||
** is set to true when the code is running to give users advice, e.g., on a review
|
||||
** screen - vs. being false when the action is ACTUALLY happening. So, if you're doing
|
||||
** things like storing data, you don't want to do that if isPreview is true!!
|
||||
**
|
||||
** General implementation would be, to iterate over the records (the inputs to
|
||||
** the insert action), and look at their values:
|
||||
** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records
|
||||
** - possibly manipulating values (`setValue`)
|
||||
** - possibly throwing an exception - if you really don't want the insert operation to continue.
|
||||
** - doing "whatever else" you may want to do.
|
||||
** - returning the list of records (can be the input list) that you want to go on to the backend implementation class.
|
||||
*******************************************************************************/
|
||||
default List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
LOG.info("A default implementation of preInsert is running... Probably not expected!", logPair("tableName", insertInput.getTableName()));
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
default AbstractPreInsertCustomizer.WhenToRun whenToRunPreInsert(InsertInput insertInput, boolean isPreview)
|
||||
{
|
||||
return (AbstractPreInsertCustomizer.WhenToRun.AFTER_ALL_VALIDATIONS);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** custom actions after an insert takes place.
|
||||
**
|
||||
** General implementation would be, to iterate over the records (the outputs of
|
||||
** the insert action), and look at their values:
|
||||
** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records
|
||||
** - possibly throwing an exception - though doing so won't stop the update, and instead
|
||||
** will just set a warning on all of the updated records...
|
||||
** - doing "whatever else" you may want to do.
|
||||
** - returning the list of records (can be the input list) that you want to go back to the caller.
|
||||
*******************************************************************************/
|
||||
default List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
|
||||
{
|
||||
LOG.info("A default implementation of postInsert is running... Probably not expected!", logPair("tableName", insertInput.getTableName()));
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** custom actions before an update takes place.
|
||||
**
|
||||
** It's important for implementations to be aware of the isPreview field, which
|
||||
** is set to true when the code is running to give users advice, e.g., on a review
|
||||
** screen - vs. being false when the action is ACTUALLY happening. So, if you're doing
|
||||
** things like storing data, you don't want to do that if isPreview is true!!
|
||||
**
|
||||
** General implementation would be, to iterate over the records (the inputs to
|
||||
** the update action), and look at their values:
|
||||
** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records
|
||||
** - possibly manipulating values (`setValue`)
|
||||
** - possibly throwing an exception - if you really don't want the update operation to continue.
|
||||
** - doing "whatever else" you may want to do.
|
||||
** - returning the list of records (can be the input list) that you want to go on to the backend implementation class.
|
||||
**
|
||||
** Note, "old records" (e.g., with values freshly fetched from the backend) will be
|
||||
** available (if the backend supports it)
|
||||
*******************************************************************************/
|
||||
default List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
LOG.info("A default implementation of preUpdate is running... Probably not expected!", logPair("tableName", updateInput.getTableName()));
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** custom actions after an update takes place.
|
||||
**
|
||||
** General implementation would be, to iterate over the records (the outputs of
|
||||
** the update action), and look at their values:
|
||||
** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records?
|
||||
** - possibly throwing an exception - though doing so won't stop the update, and instead
|
||||
** will just set a warning on all of the updated records...
|
||||
** - doing "whatever else" you may want to do.
|
||||
** - returning the list of records (can be the input list) that you want to go back to the caller.
|
||||
**
|
||||
** Note, "old records" (e.g., with values freshly fetched from the backend) will be
|
||||
** available (if the backend supports it).
|
||||
*******************************************************************************/
|
||||
default List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
LOG.info("A default implementation of postUpdate is running... Probably not expected!", logPair("tableName", updateInput.getTableName()));
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Custom actions before a delete takes place.
|
||||
**
|
||||
** It's important for implementations to be aware of the isPreview param, which
|
||||
** is set to true when the code is running to give users advice, e.g., on a review
|
||||
** screen - vs. being false when the action is ACTUALLY happening. So, if you're doing
|
||||
** things like storing data, you don't want to do that if isPreview is true!!
|
||||
**
|
||||
** General implementation would be, to iterate over the records (which the DeleteAction
|
||||
** would look up based on the inputs to the delete action), and look at their values:
|
||||
** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records
|
||||
** - possibly throwing an exception - if you really don't want the delete operation to continue.
|
||||
** - doing "whatever else" you may want to do.
|
||||
** - returning the list of records (can be the input list) - this is how errors
|
||||
** and warnings are propagated to the DeleteAction. Note that any records with
|
||||
** an error will NOT proceed to the backend's delete interface - but those with
|
||||
** warnings will.
|
||||
*******************************************************************************/
|
||||
default List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
LOG.info("A default implementation of preDelete is running... Probably not expected!", logPair("tableName", deleteInput.getTableName()));
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Custom actions after a delete takes place.
|
||||
**
|
||||
** General implementation would be, to iterate over the records (ones which didn't
|
||||
** have a delete error), and look at their values:
|
||||
** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records?
|
||||
** - possibly throwing an exception - though doing so won't stop the delete, and instead
|
||||
** will just set a warning on all of the deleted records...
|
||||
** - doing "whatever else" you may want to do.
|
||||
** - returning the list of records (can be the input list) that you want to go back
|
||||
** to the caller - this is how errors and warnings are propagated .
|
||||
*******************************************************************************/
|
||||
default List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
LOG.info("A default implementation of postDelete is running... Probably not expected!", logPair("tableName", deleteInput.getTableName()));
|
||||
return (records);
|
||||
}
|
||||
|
||||
}
|
@ -29,13 +29,13 @@ package com.kingsrook.qqq.backend.core.actions.customizers;
|
||||
*******************************************************************************/
|
||||
public enum TableCustomizers
|
||||
{
|
||||
POST_QUERY_RECORD("postQueryRecord", AbstractPostQueryCustomizer.class),
|
||||
PRE_INSERT_RECORD("preInsertRecord", AbstractPreInsertCustomizer.class),
|
||||
POST_INSERT_RECORD("postInsertRecord", AbstractPostInsertCustomizer.class),
|
||||
PRE_UPDATE_RECORD("preUpdateRecord", AbstractPreUpdateCustomizer.class),
|
||||
POST_UPDATE_RECORD("postUpdateRecord", AbstractPostUpdateCustomizer.class),
|
||||
PRE_DELETE_RECORD("preDeleteRecord", AbstractPreDeleteCustomizer.class),
|
||||
POST_DELETE_RECORD("postDeleteRecord", AbstractPostDeleteCustomizer.class);
|
||||
POST_QUERY_RECORD("postQueryRecord", TableCustomizerInterface.class),
|
||||
PRE_INSERT_RECORD("preInsertRecord", TableCustomizerInterface.class),
|
||||
POST_INSERT_RECORD("postInsertRecord", TableCustomizerInterface.class),
|
||||
PRE_UPDATE_RECORD("preUpdateRecord", TableCustomizerInterface.class),
|
||||
POST_UPDATE_RECORD("postUpdateRecord", TableCustomizerInterface.class),
|
||||
PRE_DELETE_RECORD("preDeleteRecord", TableCustomizerInterface.class),
|
||||
POST_DELETE_RECORD("postDeleteRecord", TableCustomizerInterface.class);
|
||||
|
||||
|
||||
private final String role;
|
||||
|
@ -41,6 +41,7 @@ import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.QQueryFilterDeduper;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
@ -176,11 +177,13 @@ public abstract class AbstractHTMLWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
return (totalString);
|
||||
}
|
||||
filter = QQueryFilterDeduper.dedupeFilter(filter);
|
||||
return ("<a href='" + tablePath + "?filter=" + JsonUtils.toJson(filter) + "'>" + totalString + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -192,6 +195,7 @@ public abstract class AbstractHTMLWidgetRenderer extends AbstractWidgetRenderer
|
||||
return;
|
||||
}
|
||||
|
||||
filter = QQueryFilterDeduper.dedupeFilter(filter);
|
||||
urls.add(tablePath + "?filter=" + JsonUtils.toJson(filter));
|
||||
}
|
||||
|
||||
@ -208,6 +212,7 @@ public abstract class AbstractHTMLWidgetRenderer extends AbstractWidgetRenderer
|
||||
return (null);
|
||||
}
|
||||
|
||||
filter = QQueryFilterDeduper.dedupeFilter(filter);
|
||||
return (tablePath + "?filter=" + JsonUtils.toJson(filter));
|
||||
}
|
||||
|
||||
@ -224,6 +229,7 @@ public abstract class AbstractHTMLWidgetRenderer extends AbstractWidgetRenderer
|
||||
return (null);
|
||||
}
|
||||
|
||||
filter = QQueryFilterDeduper.dedupeFilter(filter);
|
||||
return (tablePath + "?filter=" + URLEncoder.encode(JsonUtils.toJson(filter), Charset.defaultCharset()));
|
||||
}
|
||||
|
||||
@ -326,6 +332,7 @@ public abstract class AbstractHTMLWidgetRenderer extends AbstractWidgetRenderer
|
||||
}
|
||||
|
||||
String tablePath = QContext.getQInstance().getTablePath(tableName);
|
||||
filter = QQueryFilterDeduper.dedupeFilter(filter);
|
||||
return (tablePath + "/" + processName + "?recordsParam=filterJSON&filterJSON=" + URLEncoder.encode(JsonUtils.toJson(filter), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.WidgetDropdownData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.WidgetDropdownType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
@ -72,80 +73,104 @@ public abstract class AbstractWidgetRenderer
|
||||
*******************************************************************************/
|
||||
protected boolean setupDropdowns(RenderWidgetInput input, QWidgetMetaData metaData, QWidgetData widgetData) throws QException
|
||||
{
|
||||
List<List<Map<String, String>>> pvsData = new ArrayList<>();
|
||||
List<String> pvsLabels = new ArrayList<>();
|
||||
List<String> pvsNames = new ArrayList<>();
|
||||
List<List<Map<String, String>>> dataList = new ArrayList<>();
|
||||
List<String> labelList = new ArrayList<>();
|
||||
List<String> nameList = new ArrayList<>();
|
||||
List<String> missingRequiredSelections = new ArrayList<>();
|
||||
for(WidgetDropdownData dropdownData : CollectionUtils.nonNullList(metaData.getDropdowns()))
|
||||
{
|
||||
String possibleValueSourceName = dropdownData.getPossibleValueSourceName();
|
||||
QPossibleValueSource possibleValueSource = input.getInstance().getPossibleValueSource(possibleValueSourceName);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this looks complicated, but is just look for a label in the dropdown data and if found use it, //
|
||||
// otherwise look for label in PVS and if found use that, otherwise just use the PVS name //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
String pvsLabel = dropdownData.getLabel() != null ? dropdownData.getLabel() : (possibleValueSource.getLabel() != null ? possibleValueSource.getLabel() : possibleValueSourceName);
|
||||
pvsLabels.add(pvsLabel);
|
||||
pvsNames.add(possibleValueSourceName);
|
||||
|
||||
SearchPossibleValueSourceInput pvsInput = new SearchPossibleValueSourceInput();
|
||||
pvsInput.setPossibleValueSourceName(possibleValueSourceName);
|
||||
|
||||
if(dropdownData.getForeignKeyFieldName() != null)
|
||||
if(WidgetDropdownType.DATE_PICKER.equals(dropdownData.getType()))
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// look for an id in the query params //
|
||||
////////////////////////////////////////
|
||||
Integer id = null;
|
||||
if(input.getQueryParams() != null && input.getQueryParams().containsKey("id") && StringUtils.hasContent(input.getQueryParams().get("id")))
|
||||
String name = dropdownData.getName();
|
||||
nameList.add(name);
|
||||
labelList.add(dropdownData.getLabel());
|
||||
dataList.add(new ArrayList<>());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// sure that something has been selected, and if not, display a message that a selection needs made //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(dropdownData.getIsRequired())
|
||||
{
|
||||
id = Integer.parseInt(input.getQueryParams().get("id"));
|
||||
}
|
||||
if(id != null)
|
||||
{
|
||||
pvsInput.setDefaultQueryFilter(new QQueryFilter().withCriteria(
|
||||
new QFilterCriteria(
|
||||
dropdownData.getForeignKeyFieldName(),
|
||||
QCriteriaOperator.EQUALS,
|
||||
id)));
|
||||
if(!input.getQueryParams().containsKey(name) || !StringUtils.hasContent(input.getQueryParams().get(name)))
|
||||
{
|
||||
missingRequiredSelections.add(dropdownData.getLabel());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SearchPossibleValueSourceOutput output = new SearchPossibleValueSourceAction().execute(pvsInput);
|
||||
|
||||
List<Map<String, String>> dropdownOptionList = new ArrayList<>();
|
||||
pvsData.add(dropdownOptionList);
|
||||
|
||||
//////////////////////////////////////////
|
||||
// sort results, dedupe, and add to map //
|
||||
//////////////////////////////////////////
|
||||
Set<String> exists = new HashSet<>();
|
||||
output.getResults().removeIf(pvs -> !exists.add(pvs.getLabel()));
|
||||
for(QPossibleValue<?> possibleValue : output.getResults())
|
||||
else
|
||||
{
|
||||
dropdownOptionList.add(MapBuilder.of(
|
||||
"id", String.valueOf(possibleValue.getId()),
|
||||
"label", possibleValue.getLabel()
|
||||
));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// because we know the dropdowns and what the field names will be when something is selected, we can make //
|
||||
// sure that something has been selected, and if not, display a message that a selection needs made //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(dropdownData.getIsRequired())
|
||||
{
|
||||
if(!input.getQueryParams().containsKey(possibleValueSourceName) || !StringUtils.hasContent(input.getQueryParams().get(possibleValueSourceName)))
|
||||
String possibleValueSourceName = dropdownData.getPossibleValueSourceName();
|
||||
if(possibleValueSourceName != null)
|
||||
{
|
||||
missingRequiredSelections.add(pvsLabel);
|
||||
QPossibleValueSource possibleValueSource = input.getInstance().getPossibleValueSource(possibleValueSourceName);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this looks complicated, but is just look for a label in the dropdown data and if found use it, //
|
||||
// otherwise look for label in PVS and if found use that, otherwise just use the PVS name //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
String pvsLabel = dropdownData.getLabel() != null ? dropdownData.getLabel() : (possibleValueSource.getLabel() != null ? possibleValueSource.getLabel() : possibleValueSourceName);
|
||||
labelList.add(pvsLabel);
|
||||
nameList.add(possibleValueSourceName);
|
||||
|
||||
SearchPossibleValueSourceInput pvsInput = new SearchPossibleValueSourceInput();
|
||||
pvsInput.setPossibleValueSourceName(possibleValueSourceName);
|
||||
|
||||
if(dropdownData.getForeignKeyFieldName() != null)
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// look for an id in the query params //
|
||||
////////////////////////////////////////
|
||||
Integer id = null;
|
||||
if(input.getQueryParams() != null && input.getQueryParams().containsKey("id") && StringUtils.hasContent(input.getQueryParams().get("id")))
|
||||
{
|
||||
id = Integer.parseInt(input.getQueryParams().get("id"));
|
||||
}
|
||||
if(id != null)
|
||||
{
|
||||
pvsInput.setDefaultQueryFilter(new QQueryFilter().withCriteria(
|
||||
new QFilterCriteria(
|
||||
dropdownData.getForeignKeyFieldName(),
|
||||
QCriteriaOperator.EQUALS,
|
||||
id)));
|
||||
}
|
||||
}
|
||||
|
||||
SearchPossibleValueSourceOutput output = new SearchPossibleValueSourceAction().execute(pvsInput);
|
||||
|
||||
List<Map<String, String>> dropdownOptionList = new ArrayList<>();
|
||||
dataList.add(dropdownOptionList);
|
||||
|
||||
//////////////////////////////////////////
|
||||
// sort results, dedupe, and add to map //
|
||||
//////////////////////////////////////////
|
||||
Set<String> exists = new HashSet<>();
|
||||
output.getResults().removeIf(pvs -> !exists.add(pvs.getLabel()));
|
||||
for(QPossibleValue<?> possibleValue : output.getResults())
|
||||
{
|
||||
dropdownOptionList.add(MapBuilder.of(
|
||||
"id", String.valueOf(possibleValue.getId()),
|
||||
"label", possibleValue.getLabel()
|
||||
));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// because we know the dropdowns and what the field names will be when something is selected, we can make //
|
||||
// sure that something has been selected, and if not, display a message that a selection needs made //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(dropdownData.getIsRequired())
|
||||
{
|
||||
if(!input.getQueryParams().containsKey(possibleValueSourceName) || !StringUtils.hasContent(input.getQueryParams().get(possibleValueSourceName)))
|
||||
{
|
||||
missingRequiredSelections.add(pvsLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
widgetData.setDropdownNameList(pvsNames);
|
||||
widgetData.setDropdownLabelList(pvsLabels);
|
||||
widgetData.setDropdownDataList(pvsData);
|
||||
widgetData.setDropdownNameList(nameList);
|
||||
widgetData.setDropdownLabelList(labelList);
|
||||
widgetData.setDropdownDataList(dataList);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// if there are any missing required dropdowns, build up a message to display //
|
||||
|
@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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.actions.dashboard.widgets;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByAggregate;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByGroupBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Generic widget that does an aggregate query, and presents its results
|
||||
** as a table, using group-by values as both row & column labels.
|
||||
*******************************************************************************/
|
||||
public class Aggregate2DTableWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(Aggregate2DTableWidgetRenderer.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
Map<String, Serializable> values = input.getWidgetMetaData().getDefaultValues();
|
||||
|
||||
String tableName = ValueUtils.getValueAsString(values.get("tableName"));
|
||||
String valueField = ValueUtils.getValueAsString(values.get("valueField"));
|
||||
String rowField = ValueUtils.getValueAsString(values.get("rowField"));
|
||||
String columnField = ValueUtils.getValueAsString(values.get("columnField"));
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
|
||||
AggregateInput aggregateInput = new AggregateInput();
|
||||
aggregateInput.setTableName(tableName);
|
||||
|
||||
// todo - allow input of "list of columns" (e.g., in case some miss sometimes, or as a version of filter)
|
||||
// todo - max rows, max cols?
|
||||
|
||||
// todo - from input map
|
||||
QQueryFilter filter = new QQueryFilter();
|
||||
aggregateInput.setFilter(filter);
|
||||
|
||||
Aggregate aggregate = new Aggregate(valueField, AggregateOperator.COUNT);
|
||||
aggregateInput.withAggregate(aggregate);
|
||||
|
||||
GroupBy rowGroupBy = new GroupBy(table.getField(rowField));
|
||||
GroupBy columnGroupBy = new GroupBy(table.getField(columnField));
|
||||
aggregateInput.withGroupBy(rowGroupBy);
|
||||
aggregateInput.withGroupBy(columnGroupBy);
|
||||
|
||||
String orderBys = ValueUtils.getValueAsString(values.get("orderBys"));
|
||||
if(StringUtils.hasContent(orderBys))
|
||||
{
|
||||
for(String orderBy : orderBys.split(","))
|
||||
{
|
||||
switch(orderBy)
|
||||
{
|
||||
case "row" -> filter.addOrderBy(new QFilterOrderByGroupBy(rowGroupBy));
|
||||
case "column" -> filter.addOrderBy(new QFilterOrderByGroupBy(columnGroupBy));
|
||||
case "value" -> filter.addOrderBy(new QFilterOrderByAggregate(aggregate));
|
||||
default -> LOG.warn("Unrecognized orderBy: " + orderBy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AggregateOutput aggregateOutput = new AggregateAction().execute(aggregateInput);
|
||||
|
||||
Map<Serializable, Map<Serializable, Serializable>> data = new LinkedHashMap<>();
|
||||
Set<Serializable> columnsSet = new LinkedHashSet<>();
|
||||
|
||||
for(AggregateResult result : aggregateOutput.getResults())
|
||||
{
|
||||
Serializable column = result.getGroupByValue(columnGroupBy);
|
||||
Serializable row = result.getGroupByValue(rowGroupBy);
|
||||
Serializable value = result.getAggregateValue(aggregate);
|
||||
|
||||
Map<Serializable, Serializable> rowMap = data.computeIfAbsent(row, (k) -> new LinkedHashMap<>());
|
||||
rowMap.put(column, value);
|
||||
columnsSet.add(column);
|
||||
}
|
||||
|
||||
// todo - possible values from rows, cols
|
||||
|
||||
////////////////////////////////////
|
||||
// setup datastructures for table //
|
||||
////////////////////////////////////
|
||||
List<Map<String, Object>> tableRows = new ArrayList<>();
|
||||
List<TableData.Column> tableColumns = new ArrayList<>();
|
||||
tableColumns.add(new TableData.Column("default", table.getField(rowField).getLabel(), "_row", "2fr", "left"));
|
||||
|
||||
for(Serializable column : columnsSet)
|
||||
{
|
||||
tableColumns.add(new TableData.Column("default", String.valueOf(column) /* todo display value */, String.valueOf(column), "1fr", "right"));
|
||||
}
|
||||
|
||||
tableColumns.add(new TableData.Column("default", "Total", "_total", "1fr", "right"));
|
||||
|
||||
TableData tableData = new TableData(null, tableColumns, tableRows)
|
||||
.withRowsPerPage(100)
|
||||
.withFixedStickyLastRow(false)
|
||||
.withHidePaginationDropdown(true);
|
||||
|
||||
Map<Serializable, Integer> columnSums = new HashMap<>();
|
||||
int grandTotal = 0;
|
||||
for(Map.Entry<Serializable, Map<Serializable, Serializable>> rowEntry : data.entrySet())
|
||||
{
|
||||
Map<String, Object> rowMap = new HashMap<>();
|
||||
tableRows.add(rowMap);
|
||||
|
||||
rowMap.put("_row", rowEntry.getKey() /* todo display value */);
|
||||
int rowTotal = 0;
|
||||
for(Serializable column : columnsSet)
|
||||
{
|
||||
Serializable value = rowEntry.getValue().get(column);
|
||||
if(value == null)
|
||||
{
|
||||
value = 0; // todo?
|
||||
}
|
||||
|
||||
Integer valueAsInteger = Objects.requireNonNullElse(ValueUtils.getValueAsInteger(value), 0);
|
||||
rowTotal += valueAsInteger;
|
||||
columnSums.putIfAbsent(column, 0);
|
||||
columnSums.put(column, columnSums.get(column) + valueAsInteger);
|
||||
|
||||
rowMap.put(String.valueOf(column), value); // todo format commas?
|
||||
}
|
||||
|
||||
rowMap.put("_total", rowTotal);
|
||||
grandTotal += rowTotal;
|
||||
}
|
||||
|
||||
///////////////
|
||||
// total row //
|
||||
///////////////
|
||||
Map<String, Object> totalRowMap = new HashMap<>();
|
||||
tableRows.add(totalRowMap);
|
||||
|
||||
totalRowMap.put("_row", "Total");
|
||||
int rowTotal = 0;
|
||||
for(Serializable column : columnsSet)
|
||||
{
|
||||
Serializable value = columnSums.get(column);
|
||||
if(value == null)
|
||||
{
|
||||
value = 0; // todo?
|
||||
}
|
||||
|
||||
totalRowMap.put(String.valueOf(column), value); // todo format commas?
|
||||
}
|
||||
|
||||
totalRowMap.put("_total", grandTotal);
|
||||
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
@ -137,7 +137,7 @@ public class ChildRecordListRenderer extends AbstractWidgetRenderer
|
||||
*******************************************************************************/
|
||||
public Builder withCanAddChildRecord(boolean b)
|
||||
{
|
||||
widgetMetaData.withDefaultValue("canAddChildRecord", true);
|
||||
widgetMetaData.withDefaultValue("canAddChildRecord", b);
|
||||
return (this);
|
||||
}
|
||||
|
||||
@ -151,6 +151,17 @@ public class ChildRecordListRenderer extends AbstractWidgetRenderer
|
||||
widgetMetaData.withDefaultValue("disabledFieldsForNewChildRecords", new HashSet<>(disabledFieldsForNewChildRecords));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Builder withManageAssociationName(String manageAssociationName)
|
||||
{
|
||||
widgetMetaData.withDefaultValue("manageAssociationName", manageAssociationName);
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -178,52 +189,60 @@ public class ChildRecordListRenderer extends AbstractWidgetRenderer
|
||||
maxRows = ValueUtils.getValueAsInteger(input.getWidgetMetaData().getDefaultValues().containsKey("maxRows"));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// fetch the record that we're getting children for. //
|
||||
// e.g., the left-side of the join, with the input id //
|
||||
////////////////////////////////////////////////////////
|
||||
GetInput getInput = new GetInput();
|
||||
getInput.setTableName(join.getLeftTable());
|
||||
getInput.setPrimaryKey(id);
|
||||
GetOutput getOutput = new GetAction().execute(getInput);
|
||||
QRecord record = getOutput.getRecord();
|
||||
|
||||
if(record == null)
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// fetch the record that we're getting children for. e.g., the left-side of the join, with the input id //
|
||||
// but - only try this if we were given an id. note, this widget could be called for on an INSERT screen, where we don't have a record yet //
|
||||
// but we still want to be able to return all the other data in here that otherwise comes from the widget meta data, join, etc. //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
int totalRows = 0;
|
||||
QRecord primaryRecord = null;
|
||||
QQueryFilter filter = null;
|
||||
QueryOutput queryOutput = new QueryOutput(new QueryInput());
|
||||
if(StringUtils.hasContent(id))
|
||||
{
|
||||
throw (new QNotFoundException("Could not find " + (leftTable == null ? "" : leftTable.getLabel()) + " with primary key " + id));
|
||||
}
|
||||
GetInput getInput = new GetInput();
|
||||
getInput.setTableName(join.getLeftTable());
|
||||
getInput.setPrimaryKey(id);
|
||||
GetOutput getOutput = new GetAction().execute(getInput);
|
||||
primaryRecord = getOutput.getRecord();
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// set up the query - for the table on the right side of the join //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
QQueryFilter filter = new QQueryFilter();
|
||||
for(JoinOn joinOn : join.getJoinOns())
|
||||
{
|
||||
filter.addCriteria(new QFilterCriteria(joinOn.getRightField(), QCriteriaOperator.EQUALS, List.of(record.getValue(joinOn.getLeftField()))));
|
||||
}
|
||||
filter.setOrderBys(join.getOrderBys());
|
||||
filter.setLimit(maxRows);
|
||||
if(primaryRecord == null)
|
||||
{
|
||||
throw (new QNotFoundException("Could not find " + (leftTable == null ? "" : leftTable.getLabel()) + " with primary key " + id));
|
||||
}
|
||||
|
||||
QueryInput queryInput = new QueryInput();
|
||||
queryInput.setTableName(join.getRightTable());
|
||||
queryInput.setShouldTranslatePossibleValues(true);
|
||||
queryInput.setShouldGenerateDisplayValues(true);
|
||||
queryInput.setFilter(filter);
|
||||
QueryOutput queryOutput = new QueryAction().execute(queryInput);
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// set up the query - for the table on the right side of the join //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
filter = new QQueryFilter();
|
||||
for(JoinOn joinOn : join.getJoinOns())
|
||||
{
|
||||
filter.addCriteria(new QFilterCriteria(joinOn.getRightField(), QCriteriaOperator.EQUALS, List.of(primaryRecord.getValue(joinOn.getLeftField()))));
|
||||
}
|
||||
filter.setOrderBys(join.getOrderBys());
|
||||
filter.setLimit(maxRows);
|
||||
|
||||
QValueFormatter.setBlobValuesToDownloadUrls(rightTable, queryOutput.getRecords());
|
||||
QueryInput queryInput = new QueryInput();
|
||||
queryInput.setTableName(join.getRightTable());
|
||||
queryInput.setShouldTranslatePossibleValues(true);
|
||||
queryInput.setShouldGenerateDisplayValues(true);
|
||||
queryInput.setFilter(filter);
|
||||
queryOutput = new QueryAction().execute(queryInput);
|
||||
|
||||
int totalRows = queryOutput.getRecords().size();
|
||||
if(maxRows != null && (queryOutput.getRecords().size() == maxRows))
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the input said to only do some max, and the # of results we got is that max, //
|
||||
// then do a count query, for displaying 1-n of <count> //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
CountInput countInput = new CountInput();
|
||||
countInput.setTableName(join.getRightTable());
|
||||
countInput.setFilter(filter);
|
||||
totalRows = new CountAction().execute(countInput).getCount();
|
||||
QValueFormatter.setBlobValuesToDownloadUrls(rightTable, queryOutput.getRecords());
|
||||
|
||||
totalRows = queryOutput.getRecords().size();
|
||||
if(maxRows != null && (queryOutput.getRecords().size() == maxRows))
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the input said to only do some max, and the # of results we got is that max, //
|
||||
// then do a count query, for displaying 1-n of <count> //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
CountInput countInput = new CountInput();
|
||||
countInput.setTableName(join.getRightTable());
|
||||
countInput.setFilter(filter);
|
||||
totalRows = new CountAction().execute(countInput).getCount();
|
||||
}
|
||||
}
|
||||
|
||||
String tablePath = input.getInstance().getTablePath(rightTable.getName());
|
||||
@ -239,10 +258,14 @@ public class ChildRecordListRenderer extends AbstractWidgetRenderer
|
||||
// new child records must have values from the join-ons //
|
||||
//////////////////////////////////////////////////////////
|
||||
Map<String, Serializable> defaultValuesForNewChildRecords = new HashMap<>();
|
||||
for(JoinOn joinOn : join.getJoinOns())
|
||||
if(primaryRecord != null)
|
||||
{
|
||||
defaultValuesForNewChildRecords.put(joinOn.getRightField(), record.getValue(joinOn.getLeftField()));
|
||||
for(JoinOn joinOn : join.getJoinOns())
|
||||
{
|
||||
defaultValuesForNewChildRecords.put(joinOn.getRightField(), primaryRecord.getValue(joinOn.getLeftField()));
|
||||
}
|
||||
}
|
||||
|
||||
widgetData.setDefaultValuesForNewChildRecords(defaultValuesForNewChildRecords);
|
||||
|
||||
Map<String, Serializable> widgetValues = input.getWidgetMetaData().getDefaultValues();
|
||||
@ -250,6 +273,22 @@ public class ChildRecordListRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
widgetData.setDisabledFieldsForNewChildRecords((Set<String>) widgetValues.get("disabledFieldsForNewChildRecords"));
|
||||
}
|
||||
else
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if there are no disabled fields specified - then normally any fields w/ a default value get implicitly disabled //
|
||||
// but - if we didn't look-up the primary record, then we'll want to explicit disable fields from joins //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(primaryRecord == null)
|
||||
{
|
||||
Set<String> implicitlyDisabledFields = new HashSet<>();
|
||||
widgetData.setDisabledFieldsForNewChildRecords(implicitlyDisabledFields);
|
||||
for(JoinOn joinOn : join.getJoinOns())
|
||||
{
|
||||
implicitlyDisabledFields.add(joinOn.getRightField());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (new RenderWidgetOutput(widgetData));
|
||||
|
@ -297,4 +297,21 @@ public enum DateTimeGroupBy
|
||||
ZonedDateTime zoned = instant.atZone(zoneId);
|
||||
return (zoned.plus(noOfChronoUnitsToAdd, chronoUnitToAdd).toInstant());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static DateTimeFormatter sqlDateFormatToSelectedDateTimeFormatter(String sqlDateFormat)
|
||||
{
|
||||
for(DateTimeGroupBy value : values())
|
||||
{
|
||||
if(value.sqlDateFormat.equals(sqlDateFormat))
|
||||
{
|
||||
return (value.selectedStringFormatter);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -31,7 +31,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
** Interface for the Insert action.
|
||||
**
|
||||
*******************************************************************************/
|
||||
public interface InsertInterface extends QActionInterface
|
||||
public interface InsertInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
|
@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.actions.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -150,14 +151,21 @@ public class MetaDataAction
|
||||
}
|
||||
metaDataOutput.setWidgets(widgets);
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
// sort apps - by sortOrder (integer), then by label //
|
||||
///////////////////////////////////////////////////////
|
||||
List<QAppMetaData> sortedApps = metaDataInput.getInstance().getApps().values().stream()
|
||||
.sorted(Comparator.comparing((QAppMetaData a) -> a.getSortOrder())
|
||||
.thenComparing((QAppMetaData a) -> a.getLabel()))
|
||||
.toList();
|
||||
|
||||
///////////////////////////////////
|
||||
// map apps to frontend metadata //
|
||||
///////////////////////////////////
|
||||
Map<String, QFrontendAppMetaData> apps = new LinkedHashMap<>();
|
||||
for(Map.Entry<String, QAppMetaData> entry : metaDataInput.getInstance().getApps().entrySet())
|
||||
for(QAppMetaData app : sortedApps)
|
||||
{
|
||||
String appName = entry.getKey();
|
||||
QAppMetaData app = entry.getValue();
|
||||
String appName = app.getName();
|
||||
|
||||
PermissionCheckResult permissionResult = PermissionsHelper.getPermissionCheckResult(metaDataInput, app);
|
||||
if(permissionResult.equals(PermissionCheckResult.DENY_HIDE))
|
||||
@ -191,7 +199,7 @@ public class MetaDataAction
|
||||
// organize app tree nodes by their hierarchy //
|
||||
////////////////////////////////////////////////
|
||||
List<AppTreeNode> appTree = new ArrayList<>();
|
||||
for(QAppMetaData appMetaData : metaDataInput.getInstance().getApps().values())
|
||||
for(QAppMetaData appMetaData : sortedApps)
|
||||
{
|
||||
if(appMetaData.getParentAppName() == null)
|
||||
{
|
||||
|
@ -34,15 +34,18 @@ import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
@ -333,9 +336,25 @@ public class PermissionsHelper
|
||||
QPermissionRules rules = getEffectivePermissionRules(tableMetaData, instance);
|
||||
String baseName = getEffectivePermissionBaseName(rules, tableMetaData.getName());
|
||||
|
||||
for(TablePermissionSubType permissionSubType : TablePermissionSubType.values())
|
||||
QBackendMetaData backend = instance.getBackend(tableMetaData.getBackendName());
|
||||
if(tableMetaData.isCapabilityEnabled(backend, Capability.TABLE_INSERT))
|
||||
{
|
||||
addEffectiveAvailablePermission(rules, permissionSubType, rs, baseName, tableMetaData, "Table");
|
||||
addEffectiveAvailablePermission(rules, TablePermissionSubType.INSERT, rs, baseName, tableMetaData, "Table");
|
||||
}
|
||||
|
||||
if(tableMetaData.isCapabilityEnabled(backend, Capability.TABLE_UPDATE))
|
||||
{
|
||||
addEffectiveAvailablePermission(rules, TablePermissionSubType.EDIT, rs, baseName, tableMetaData, "Table");
|
||||
}
|
||||
|
||||
if(tableMetaData.isCapabilityEnabled(backend, Capability.TABLE_DELETE))
|
||||
{
|
||||
addEffectiveAvailablePermission(rules, TablePermissionSubType.DELETE, rs, baseName, tableMetaData, "Table");
|
||||
}
|
||||
|
||||
if(tableMetaData.isCapabilityEnabled(backend, Capability.TABLE_QUERY) || tableMetaData.isCapabilityEnabled(backend, Capability.TABLE_GET))
|
||||
{
|
||||
addEffectiveAvailablePermission(rules, TablePermissionSubType.READ, rs, baseName, tableMetaData, "Table");
|
||||
}
|
||||
}
|
||||
|
||||
@ -369,7 +388,10 @@ public class PermissionsHelper
|
||||
{
|
||||
QPermissionRules rules = getEffectivePermissionRules(widgetMetaData, instance);
|
||||
String baseName = getEffectivePermissionBaseName(rules, widgetMetaData.getName());
|
||||
addEffectiveAvailablePermission(rules, PrivatePermissionSubType.HAS_ACCESS, rs, baseName, widgetMetaData, "Widget");
|
||||
if(!rules.getLevel().equals(PermissionLevel.NOT_PROTECTED))
|
||||
{
|
||||
addEffectiveAvailablePermission(rules, PrivatePermissionSubType.HAS_ACCESS, rs, baseName, widgetMetaData, "Widget");
|
||||
}
|
||||
}
|
||||
|
||||
return (rs);
|
||||
|
@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.actions.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor for commonly used QProcessCallback's
|
||||
*******************************************************************************/
|
||||
public class QProcessCallbackFactory
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static QProcessCallback forFilter(QQueryFilter filter)
|
||||
{
|
||||
return new QProcessCallback()
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QQueryFilter getQueryFilter()
|
||||
{
|
||||
return (filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Serializable> getFieldValues(List<QFieldMetaData> fields)
|
||||
{
|
||||
return (Collections.emptyMap());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
@ -72,7 +72,7 @@ import org.apache.commons.lang.BooleanUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Action handler for running q-processes (which are a sequence of q-functions).
|
||||
** Action handler for running q-processes (which are a sequence of q-steps).
|
||||
*
|
||||
*******************************************************************************/
|
||||
public class RunProcessAction
|
||||
@ -82,6 +82,7 @@ public class RunProcessAction
|
||||
public static final String BASEPULL_THIS_RUNTIME_KEY = "basepullThisRuntimeKey";
|
||||
public static final String BASEPULL_LAST_RUNTIME_KEY = "basepullLastRuntimeKey";
|
||||
public static final String BASEPULL_TIMESTAMP_FIELD = "basepullTimestampField";
|
||||
public static final String BASEPULL_CONFIGURATION = "basepullConfiguration";
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// indicator that the timestamp field should be updated - e.g., the execute step is finished. //
|
||||
@ -496,10 +497,10 @@ public class RunProcessAction
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if backend specifies that it uses variants, look for that data in the session and append to our basepull key //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(process.getSchedule() != null && process.getSchedule().getVariantBackend() != null)
|
||||
if(process.getSchedule() != null && process.getVariantBackend() != null)
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
QBackendMetaData backendMetaData = QContext.getQInstance().getBackend(process.getSchedule().getVariantBackend());
|
||||
QBackendMetaData backendMetaData = QContext.getQInstance().getBackend(process.getVariantBackend());
|
||||
if(session.getBackendVariants() == null || !session.getBackendVariants().containsKey(backendMetaData.getVariantOptionsTableTypeValue()))
|
||||
{
|
||||
LOG.info("Could not find Backend Variant information for Backend '" + backendMetaData.getName() + "'");
|
||||
@ -633,5 +634,6 @@ public class RunProcessAction
|
||||
|
||||
runProcessInput.getValues().put(BASEPULL_LAST_RUNTIME_KEY, lastRunTime);
|
||||
runProcessInput.getValues().put(BASEPULL_TIMESTAMP_FIELD, basepullConfiguration.getTimestampField());
|
||||
runProcessInput.getValues().put(BASEPULL_CONFIGURATION, basepullConfiguration);
|
||||
}
|
||||
}
|
||||
|
@ -232,6 +232,7 @@ public class ExportAction
|
||||
}
|
||||
queryInput.getFilter().setLimit(exportInput.getLimit());
|
||||
queryInput.setShouldTranslatePossibleValues(true);
|
||||
queryInput.withQueryHint(QueryInput.QueryHint.POTENTIALLY_LARGE_NUMBER_OF_RESULTS);
|
||||
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// tell this query that it needs to put its output into a pipe //
|
||||
|
@ -73,6 +73,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.aggregates.AggregatesInterface;
|
||||
import com.kingsrook.qqq.backend.core.utils.aggregates.BigDecimalAggregates;
|
||||
import com.kingsrook.qqq.backend.core.utils.aggregates.IntegerAggregates;
|
||||
import com.kingsrook.qqq.backend.core.utils.aggregates.LongAggregates;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -273,7 +274,7 @@ public class GenerateReportAction
|
||||
RunBackendStepOutput transformStepOutput = null;
|
||||
if(tableView != null && tableView.getRecordTransformStep() != null)
|
||||
{
|
||||
transformStep = QCodeLoader.getBackendStep(AbstractTransformStep.class, tableView.getRecordTransformStep());
|
||||
transformStep = QCodeLoader.getAdHoc(AbstractTransformStep.class, tableView.getRecordTransformStep());
|
||||
|
||||
transformStepInput = new RunBackendStepInput();
|
||||
transformStepInput.setValues(reportInput.getInputValues());
|
||||
@ -553,6 +554,12 @@ public class GenerateReportAction
|
||||
AggregatesInterface<Integer> fieldAggregates = (AggregatesInterface<Integer>) aggregatesMap.computeIfAbsent(field.getName(), (name) -> new IntegerAggregates());
|
||||
fieldAggregates.add(record.getValueInteger(field.getName()));
|
||||
}
|
||||
else if(field.getType().equals(QFieldType.LONG))
|
||||
{
|
||||
@SuppressWarnings("unchecked")
|
||||
AggregatesInterface<Long> fieldAggregates = (AggregatesInterface<Long>) aggregatesMap.computeIfAbsent(field.getName(), (name) -> new LongAggregates());
|
||||
fieldAggregates.add(record.getValueLong(field.getName()));
|
||||
}
|
||||
else if(field.getType().equals(QFieldType.DECIMAL))
|
||||
{
|
||||
@SuppressWarnings("unchecked")
|
||||
|
@ -44,7 +44,8 @@ public class RecordPipe
|
||||
private static final long BLOCKING_SLEEP_MILLIS = 100;
|
||||
private static final long MAX_SLEEP_LOOP_MILLIS = 300_000; // 5 minutes
|
||||
|
||||
private ArrayBlockingQueue<QRecord> queue = new ArrayBlockingQueue<>(1_000);
|
||||
private int capacity = 1_000;
|
||||
private ArrayBlockingQueue<QRecord> queue = new ArrayBlockingQueue<>(capacity);
|
||||
|
||||
private boolean isTerminated = false;
|
||||
|
||||
@ -72,6 +73,7 @@ public class RecordPipe
|
||||
*******************************************************************************/
|
||||
public RecordPipe(Integer overrideCapacity)
|
||||
{
|
||||
this.capacity = overrideCapacity;
|
||||
queue = new ArrayBlockingQueue<>(overrideCapacity);
|
||||
}
|
||||
|
||||
@ -136,7 +138,7 @@ public class RecordPipe
|
||||
{
|
||||
if(now - sleepLoopStartTime > MAX_SLEEP_LOOP_MILLIS)
|
||||
{
|
||||
LOG.warn("Giving up adding record to pipe, due to pipe being full for more than {} millis", MAX_SLEEP_LOOP_MILLIS);
|
||||
LOG.warn("Giving up adding record to pipe, due to pipe being full for more than " + MAX_SLEEP_LOOP_MILLIS + " millis");
|
||||
throw (new IllegalStateException("Giving up adding record to pipe, due to pipe staying full too long."));
|
||||
}
|
||||
LOG.trace("Record pipe.add failed (due to full pipe). Blocking.");
|
||||
@ -213,4 +215,14 @@ public class RecordPipe
|
||||
this.postRecordActions = postRecordActions;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for capacity
|
||||
**
|
||||
*******************************************************************************/
|
||||
public int getCapacity()
|
||||
{
|
||||
return capacity;
|
||||
}
|
||||
}
|
||||
|
@ -30,6 +30,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.actions.ActionHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@ -47,12 +48,14 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.AdHocScriptCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.scripts.Script;
|
||||
import com.kingsrook.qqq.backend.core.model.scripts.ScriptRevision;
|
||||
import com.kingsrook.qqq.backend.core.model.scripts.ScriptsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -65,6 +68,8 @@ public class RunAdHocRecordScriptAction
|
||||
private Map<Integer, ScriptRevision> scriptRevisionCacheByScriptRevisionId = new HashMap<>();
|
||||
private Map<Integer, ScriptRevision> scriptRevisionCacheByScriptId = new HashMap<>();
|
||||
|
||||
private static Memoization<Integer, Script> scriptMemoizationById = new Memoization<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -85,6 +90,12 @@ public class RunAdHocRecordScriptAction
|
||||
throw (new QException("Script revision was not found."));
|
||||
}
|
||||
|
||||
Optional<Script> script = getScript(scriptRevision);
|
||||
|
||||
QContext.getQSession().setValue(DMLAuditAction.AUDIT_CONTEXT_FIELD_NAME, script.isPresent()
|
||||
? "via Script \"%s\"".formatted(script.get().getName())
|
||||
: "via Script id " + scriptRevision.getScriptId());
|
||||
|
||||
////////////////////////////
|
||||
// figure out the records //
|
||||
////////////////////////////
|
||||
@ -124,6 +135,10 @@ public class RunAdHocRecordScriptAction
|
||||
{
|
||||
output.setException(Optional.of(e));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.getQSession().removeValue(DMLAuditAction.AUDIT_CONTEXT_FIELD_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -217,4 +232,44 @@ public class RunAdHocRecordScriptAction
|
||||
throw (new QException("Code reference did not contain a scriptRevision, scriptRevisionId, or scriptId"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Optional<Script> getScript(ScriptRevision scriptRevision)
|
||||
{
|
||||
if(scriptRevision == null || scriptRevision.getScriptId() == null)
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return scriptMemoizationById.getResult(scriptRevision.getScriptId(), scriptId ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QRecord scriptRecord = new GetAction().executeForRecord(new GetInput(Script.TABLE_NAME).withPrimaryKey(scriptRevision.getScriptId()));
|
||||
if(scriptRecord != null)
|
||||
{
|
||||
Script script = new Script(scriptRecord);
|
||||
scriptMemoizationById.storeResult(scriptRevision.getScriptId(), script);
|
||||
return (script);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.info("");
|
||||
}
|
||||
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -35,9 +35,8 @@ import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qqq.backend.core.actions.ActionHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostDeleteCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
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.interfaces.DeleteInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.helpers.ValidateRecordSecurityLockHelper;
|
||||
@ -250,7 +249,7 @@ public class DeleteAction
|
||||
//////////////////////////////////////////////////////////////
|
||||
// finally, run the post-delete customizer, if there is one //
|
||||
//////////////////////////////////////////////////////////////
|
||||
Optional<AbstractPostDeleteCustomizer> postDeleteCustomizer = QCodeLoader.getTableCustomizer(AbstractPostDeleteCustomizer.class, table, TableCustomizers.POST_DELETE_RECORD.getRole());
|
||||
Optional<TableCustomizerInterface> postDeleteCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.POST_DELETE_RECORD.getRole());
|
||||
if(postDeleteCustomizer.isPresent() && oldRecordList.isPresent())
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
@ -260,8 +259,7 @@ public class DeleteAction
|
||||
|
||||
try
|
||||
{
|
||||
postDeleteCustomizer.get().setDeleteInput(deleteInput);
|
||||
List<QRecord> postCustomizerResult = postDeleteCustomizer.get().apply(recordsForCustomizer);
|
||||
List<QRecord> postCustomizerResult = postDeleteCustomizer.get().postDelete(deleteInput, recordsForCustomizer);
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
// check if any records got errors in the customizer //
|
||||
@ -327,13 +325,11 @@ public class DeleteAction
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// after all validations, run the pre-delete customizer, if there is one //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
Optional<AbstractPreDeleteCustomizer> preDeleteCustomizer = QCodeLoader.getTableCustomizer(AbstractPreDeleteCustomizer.class, table, TableCustomizers.PRE_DELETE_RECORD.getRole());
|
||||
Optional<TableCustomizerInterface> preDeleteCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.PRE_DELETE_RECORD.getRole());
|
||||
List<QRecord> customizerResult = oldRecordList.get();
|
||||
if(preDeleteCustomizer.isPresent())
|
||||
{
|
||||
preDeleteCustomizer.get().setDeleteInput(deleteInput);
|
||||
preDeleteCustomizer.get().setIsPreview(isPreview);
|
||||
customizerResult = preDeleteCustomizer.get().apply(oldRecordList.get());
|
||||
customizerResult = preDeleteCustomizer.get().preDelete(deleteInput, oldRecordList.get(), isPreview);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
|
@ -27,8 +27,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.actions.ActionHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
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.interfaces.GetInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.helpers.GetActionCacheHelper;
|
||||
@ -49,6 +49,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
|
||||
import com.kingsrook.qqq.backend.core.utils.ObjectUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -57,7 +58,7 @@ import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
|
||||
*******************************************************************************/
|
||||
public class GetAction
|
||||
{
|
||||
private Optional<AbstractPostQueryCustomizer> postGetRecordCustomizer;
|
||||
private Optional<TableCustomizerInterface> postGetRecordCustomizer;
|
||||
|
||||
private GetInput getInput;
|
||||
private QPossibleValueTranslator qPossibleValueTranslator;
|
||||
@ -87,7 +88,7 @@ public class GetAction
|
||||
throw (new QException("Requested to Get a record from an unrecognized table: " + getInput.getTableName()));
|
||||
}
|
||||
|
||||
postGetRecordCustomizer = QCodeLoader.getTableCustomizer(AbstractPostQueryCustomizer.class, table, TableCustomizers.POST_QUERY_RECORD.getRole());
|
||||
postGetRecordCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.POST_QUERY_RECORD.getRole());
|
||||
this.getInput = getInput;
|
||||
|
||||
QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher();
|
||||
@ -107,9 +108,11 @@ public class GetAction
|
||||
}
|
||||
|
||||
GetOutput getOutput;
|
||||
boolean usingDefaultGetInterface = false;
|
||||
if(getInterface == null)
|
||||
{
|
||||
getInterface = new DefaultGetInterface();
|
||||
usingDefaultGetInterface = true;
|
||||
}
|
||||
|
||||
getInterface.validateInput(getInput);
|
||||
@ -123,10 +126,11 @@ public class GetAction
|
||||
new GetActionCacheHelper().handleCaching(getInput, getOutput);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// if the record is found, perform post-actions on it //
|
||||
////////////////////////////////////////////////////////
|
||||
if(getOutput.getRecord() != null)
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the record is found, perform post-actions on it //
|
||||
// unless the defaultGetInterface was used - as it just does a query, and the query will do the post-actions. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(getOutput.getRecord() != null && !usingDefaultGetInterface)
|
||||
{
|
||||
getOutput.setRecord(postRecordActions(getOutput.getRecord()));
|
||||
}
|
||||
@ -202,7 +206,7 @@ public class GetAction
|
||||
}
|
||||
else
|
||||
{
|
||||
throw (new QException("No primaryKey or uniqueKey was passed to Get"));
|
||||
throw (new QException("Unable to get " + ObjectUtils.tryElse(() -> queryInput.getTable().getLabel(), queryInput.getTableName()) + ". Missing required input."));
|
||||
}
|
||||
|
||||
queryInput.setFilter(filter);
|
||||
@ -216,12 +220,12 @@ public class GetAction
|
||||
** Run the necessary actions on a record. This may include setting display values,
|
||||
** translating possible values, and running post-record customizations.
|
||||
*******************************************************************************/
|
||||
public QRecord postRecordActions(QRecord record)
|
||||
public QRecord postRecordActions(QRecord record) throws QException
|
||||
{
|
||||
QRecord returnRecord = record;
|
||||
if(this.postGetRecordCustomizer.isPresent())
|
||||
{
|
||||
returnRecord = postGetRecordCustomizer.get().apply(List.of(record)).get(0);
|
||||
returnRecord = postGetRecordCustomizer.get().postQuery(getInput, List.of(record)).get(0);
|
||||
}
|
||||
|
||||
if(getInput.getShouldTranslatePossibleValues())
|
||||
|
@ -28,6 +28,7 @@ import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@ -37,9 +38,9 @@ import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationStatusUpdater;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
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.interfaces.InsertInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.helpers.UniqueKeyHelper;
|
||||
@ -52,6 +53,7 @@ import com.kingsrook.qqq.backend.core.model.actions.audits.DMLAuditInput;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
@ -116,21 +118,24 @@ public class InsertAction extends AbstractQActionFunction<InsertInput, InsertOut
|
||||
|
||||
setAutomationStatusField(insertInput);
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// load the backend module and its insert interface //
|
||||
//////////////////////////////////////////////////////
|
||||
QBackendModuleInterface qModule = getBackendModuleInterface(insertInput);
|
||||
InsertInterface insertInterface = qModule.getInsertInterface();
|
||||
|
||||
/////////////////////////////
|
||||
// run standard validators //
|
||||
/////////////////////////////
|
||||
performValidations(insertInput, false);
|
||||
|
||||
////////////////////////////////////
|
||||
// have the backend do the insert //
|
||||
////////////////////////////////////
|
||||
InsertOutput insertOutput = insertInterface.execute(insertInput);
|
||||
//////////////////////////////////////////////////////
|
||||
// use the backend module to actually do the insert //
|
||||
//////////////////////////////////////////////////////
|
||||
InsertOutput insertOutput = runInsertInBackend(insertInput);
|
||||
|
||||
if(insertOutput.getRecords() == null)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// in case the module failed to set record in the output, put an empty list there //
|
||||
// to avoid so many downstream NPE's //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
insertOutput.setRecords(new ArrayList<>());
|
||||
}
|
||||
|
||||
//////////////////////////////
|
||||
// log if there were errors //
|
||||
@ -164,13 +169,12 @@ public class InsertAction extends AbstractQActionFunction<InsertInput, InsertOut
|
||||
//////////////////////////////////////////////////////////////
|
||||
// finally, run the post-insert customizer, if there is one //
|
||||
//////////////////////////////////////////////////////////////
|
||||
Optional<AbstractPostInsertCustomizer> postInsertCustomizer = QCodeLoader.getTableCustomizer(AbstractPostInsertCustomizer.class, table, TableCustomizers.POST_INSERT_RECORD.getRole());
|
||||
Optional<TableCustomizerInterface> postInsertCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.POST_INSERT_RECORD.getRole());
|
||||
if(postInsertCustomizer.isPresent())
|
||||
{
|
||||
try
|
||||
{
|
||||
postInsertCustomizer.get().setInsertInput(insertInput);
|
||||
insertOutput.setRecords(postInsertCustomizer.get().apply(insertOutput.getRecords()));
|
||||
insertOutput.setRecords(postInsertCustomizer.get().postInsert(insertInput, insertOutput.getRecords()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@ -186,6 +190,37 @@ public class InsertAction extends AbstractQActionFunction<InsertInput, InsertOut
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private InsertOutput runInsertInBackend(InsertInput insertInput) throws QException
|
||||
{
|
||||
///////////////////////////////////
|
||||
// exit early if 0 input records //
|
||||
///////////////////////////////////
|
||||
if(CollectionUtils.nullSafeIsEmpty(insertInput.getRecords()))
|
||||
{
|
||||
LOG.debug("Insert request called with 0 records. Returning with no-op", logPair("tableName", insertInput.getTableName()));
|
||||
InsertOutput rs = new InsertOutput();
|
||||
rs.setRecords(new ArrayList<>());
|
||||
return (rs);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// load the backend module and its insert interface //
|
||||
//////////////////////////////////////////////////////
|
||||
QBackendModuleInterface qModule = getBackendModuleInterface(insertInput.getBackend());
|
||||
InsertInterface insertInterface = qModule.getInsertInterface();
|
||||
|
||||
////////////////////////////////////
|
||||
// have the backend do the insert //
|
||||
////////////////////////////////////
|
||||
InsertOutput insertOutput = insertInterface.execute(insertInput);
|
||||
return insertOutput;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -197,31 +232,29 @@ public class InsertAction extends AbstractQActionFunction<InsertInput, InsertOut
|
||||
// load the pre-insert customizer and set it up, if there is one //
|
||||
// then we'll run it based on its WhenToRun value //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
Optional<AbstractPreInsertCustomizer> preInsertCustomizer = QCodeLoader.getTableCustomizer(AbstractPreInsertCustomizer.class, table, TableCustomizers.PRE_INSERT_RECORD.getRole());
|
||||
Optional<TableCustomizerInterface> preInsertCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.PRE_INSERT_RECORD.getRole());
|
||||
if(preInsertCustomizer.isPresent())
|
||||
{
|
||||
preInsertCustomizer.get().setInsertInput(insertInput);
|
||||
preInsertCustomizer.get().setIsPreview(isPreview);
|
||||
runPreInsertCustomizerIfItIsTime(insertInput, preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun.BEFORE_ALL_VALIDATIONS);
|
||||
runPreInsertCustomizerIfItIsTime(insertInput, isPreview, preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun.BEFORE_ALL_VALIDATIONS);
|
||||
}
|
||||
|
||||
setDefaultValuesInRecords(table, insertInput.getRecords());
|
||||
|
||||
ValueBehaviorApplier.applyFieldBehaviors(insertInput.getInstance(), table, insertInput.getRecords());
|
||||
ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.INSERT, insertInput.getInstance(), table, insertInput.getRecords(), null);
|
||||
|
||||
runPreInsertCustomizerIfItIsTime(insertInput, preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun.BEFORE_UNIQUE_KEY_CHECKS);
|
||||
runPreInsertCustomizerIfItIsTime(insertInput, isPreview, preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun.BEFORE_UNIQUE_KEY_CHECKS);
|
||||
setErrorsIfUniqueKeyErrors(insertInput, table);
|
||||
|
||||
runPreInsertCustomizerIfItIsTime(insertInput, preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun.BEFORE_REQUIRED_FIELD_CHECKS);
|
||||
runPreInsertCustomizerIfItIsTime(insertInput, isPreview, preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun.BEFORE_REQUIRED_FIELD_CHECKS);
|
||||
if(insertInput.getInputSource().shouldValidateRequiredFields())
|
||||
{
|
||||
validateRequiredFields(insertInput);
|
||||
}
|
||||
|
||||
runPreInsertCustomizerIfItIsTime(insertInput, preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun.BEFORE_SECURITY_CHECKS);
|
||||
runPreInsertCustomizerIfItIsTime(insertInput, isPreview, preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun.BEFORE_SECURITY_CHECKS);
|
||||
ValidateRecordSecurityLockHelper.validateSecurityFields(insertInput.getTable(), insertInput.getRecords(), ValidateRecordSecurityLockHelper.Action.INSERT);
|
||||
|
||||
runPreInsertCustomizerIfItIsTime(insertInput, preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun.AFTER_ALL_VALIDATIONS);
|
||||
runPreInsertCustomizerIfItIsTime(insertInput, isPreview, preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun.AFTER_ALL_VALIDATIONS);
|
||||
}
|
||||
|
||||
|
||||
@ -255,13 +288,13 @@ public class InsertAction extends AbstractQActionFunction<InsertInput, InsertOut
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void runPreInsertCustomizerIfItIsTime(InsertInput insertInput, Optional<AbstractPreInsertCustomizer> preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun whenToRun) throws QException
|
||||
private void runPreInsertCustomizerIfItIsTime(InsertInput insertInput, boolean isPreview, Optional<TableCustomizerInterface> preInsertCustomizer, AbstractPreInsertCustomizer.WhenToRun whenToRun) throws QException
|
||||
{
|
||||
if(preInsertCustomizer.isPresent())
|
||||
{
|
||||
if(whenToRun.equals(preInsertCustomizer.get().getWhenToRun()))
|
||||
if(whenToRun.equals(preInsertCustomizer.get().whenToRunPreInsert(insertInput, isPreview)))
|
||||
{
|
||||
insertInput.setRecords(preInsertCustomizer.get().apply(insertInput.getRecords()));
|
||||
insertInput.setRecords(preInsertCustomizer.get().preInsert(insertInput, insertInput.getRecords(), isPreview));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -286,7 +319,7 @@ public class InsertAction extends AbstractQActionFunction<InsertInput, InsertOut
|
||||
{
|
||||
if(record.getValue(requiredField.getName()) == null || (requiredField.getType().isStringLike() && record.getValueString(requiredField.getName()).trim().equals("")))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Missing value in required field: " + requiredField.getLabel()));
|
||||
record.addError(new BadInputStatusMessage("Missing value in required field: " + Objects.requireNonNullElse(requiredField.getLabel(), requiredField.getName())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -409,7 +442,7 @@ public class InsertAction extends AbstractQActionFunction<InsertInput, InsertOut
|
||||
*******************************************************************************/
|
||||
private void setAutomationStatusField(InsertInput insertInput)
|
||||
{
|
||||
RecordAutomationStatusUpdater.setAutomationStatusInRecords(insertInput.getSession(), insertInput.getTable(), insertInput.getRecords(), AutomationStatus.PENDING_INSERT_AUTOMATIONS);
|
||||
RecordAutomationStatusUpdater.setAutomationStatusInRecords(insertInput.getTable(), insertInput.getRecords(), AutomationStatus.PENDING_INSERT_AUTOMATIONS, insertInput.getTransaction(), null);
|
||||
}
|
||||
|
||||
|
||||
@ -417,23 +450,11 @@ public class InsertAction extends AbstractQActionFunction<InsertInput, InsertOut
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private QBackendModuleInterface getBackendModuleInterface(InsertInput insertInput) throws QException
|
||||
private QBackendModuleInterface getBackendModuleInterface(QBackendMetaData backend) throws QException
|
||||
{
|
||||
ActionHelper.validateSession(insertInput);
|
||||
QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher();
|
||||
QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(insertInput.getBackend());
|
||||
QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(backend);
|
||||
return (qModule);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public QBackendTransaction openTransaction(InsertInput insertInput) throws QException
|
||||
{
|
||||
QBackendModuleInterface qModule = getBackendModuleInterface(insertInput);
|
||||
return (qModule.getInsertInterface().openTransaction(insertInput));
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -31,8 +31,8 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.ActionHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
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.interfaces.QueryInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.reporting.BufferedRecordPipe;
|
||||
@ -73,7 +73,7 @@ public class QueryAction
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(QueryAction.class);
|
||||
|
||||
private Optional<AbstractPostQueryCustomizer> postQueryRecordCustomizer;
|
||||
private Optional<TableCustomizerInterface> postQueryRecordCustomizer;
|
||||
|
||||
private QueryInput queryInput;
|
||||
private QueryInterface queryInterface;
|
||||
@ -100,7 +100,7 @@ public class QueryAction
|
||||
}
|
||||
|
||||
QBackendMetaData backend = queryInput.getBackend();
|
||||
postQueryRecordCustomizer = QCodeLoader.getTableCustomizer(AbstractPostQueryCustomizer.class, table, TableCustomizers.POST_QUERY_RECORD.getRole());
|
||||
postQueryRecordCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.POST_QUERY_RECORD.getRole());
|
||||
this.queryInput = queryInput;
|
||||
|
||||
if(queryInput.getRecordPipe() != null)
|
||||
@ -264,7 +264,7 @@ public class QueryAction
|
||||
{
|
||||
if(this.postQueryRecordCustomizer.isPresent())
|
||||
{
|
||||
records = postQueryRecordCustomizer.get().apply(records);
|
||||
records = postQueryRecordCustomizer.get().postQuery(queryInput, records);
|
||||
}
|
||||
|
||||
if(queryInput.getShouldTranslatePossibleValues())
|
||||
|
@ -47,6 +47,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import org.apache.commons.lang.BooleanUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -79,14 +80,14 @@ public class ReplaceAction extends AbstractQActionFunction<ReplaceInput, Replace
|
||||
|
||||
try
|
||||
{
|
||||
QTableMetaData table = input.getTable();
|
||||
UniqueKey uniqueKey = input.getKey();
|
||||
String primaryKeyField = table.getPrimaryKeyField();
|
||||
QTableMetaData table = input.getTable();
|
||||
UniqueKey uniqueKey = input.getKey();
|
||||
String primaryKeyField = table.getPrimaryKeyField();
|
||||
boolean allowNullKeyValuesToEqual = BooleanUtils.isTrue(input.getAllowNullKeyValuesToEqual());
|
||||
|
||||
if(transaction == null)
|
||||
{
|
||||
InsertInput insertInput = new InsertInput();
|
||||
insertInput.setTableName(input.getTableName());
|
||||
transaction = new InsertAction().openTransaction(insertInput);
|
||||
transaction = QBackendTransaction.openFor(new InsertInput(input.getTableName()));
|
||||
weOwnTheTransaction = true;
|
||||
}
|
||||
|
||||
@ -100,10 +101,11 @@ public class ReplaceAction extends AbstractQActionFunction<ReplaceInput, Replace
|
||||
// originally it was thought that we'd need to pass the filter in here //
|
||||
// but, it's been decided not to. the filter only applies to what we can delete //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
Map<List<Serializable>, Serializable> existingKeys = UniqueKeyHelper.getExistingKeys(transaction, table, page, uniqueKey);
|
||||
Map<List<Serializable>, Serializable> existingKeys = UniqueKeyHelper.getExistingKeys(transaction, table, page, uniqueKey, allowNullKeyValuesToEqual);
|
||||
|
||||
for(QRecord record : page)
|
||||
{
|
||||
Optional<List<Serializable>> keyValues = UniqueKeyHelper.getKeyValues(table, uniqueKey, record);
|
||||
Optional<List<Serializable>> keyValues = UniqueKeyHelper.getKeyValues(table, uniqueKey, record, allowNullKeyValuesToEqual);
|
||||
if(keyValues.isPresent())
|
||||
{
|
||||
if(existingKeys.containsKey(keyValues.get()))
|
||||
@ -138,19 +140,22 @@ public class ReplaceAction extends AbstractQActionFunction<ReplaceInput, Replace
|
||||
UpdateOutput updateOutput = new UpdateAction().execute(updateInput);
|
||||
output.setUpdateOutput(updateOutput);
|
||||
|
||||
QQueryFilter deleteFilter = new QQueryFilter(new QFilterCriteria(primaryKeyField, QCriteriaOperator.NOT_IN, primaryKeysToKeep));
|
||||
if(input.getFilter() != null)
|
||||
if(input.getPerformDeletes())
|
||||
{
|
||||
deleteFilter.addSubFilter(input.getFilter());
|
||||
}
|
||||
QQueryFilter deleteFilter = new QQueryFilter(new QFilterCriteria(primaryKeyField, QCriteriaOperator.NOT_IN, primaryKeysToKeep));
|
||||
if(input.getFilter() != null)
|
||||
{
|
||||
deleteFilter.addSubFilter(input.getFilter());
|
||||
}
|
||||
|
||||
DeleteInput deleteInput = new DeleteInput();
|
||||
deleteInput.setTableName(table.getName());
|
||||
deleteInput.setQueryFilter(deleteFilter);
|
||||
deleteInput.setTransaction(transaction);
|
||||
deleteInput.setOmitDmlAudit(input.getOmitDmlAudit());
|
||||
DeleteOutput deleteOutput = new DeleteAction().execute(deleteInput);
|
||||
output.setDeleteOutput(deleteOutput);
|
||||
DeleteInput deleteInput = new DeleteInput();
|
||||
deleteInput.setTableName(table.getName());
|
||||
deleteInput.setQueryFilter(deleteFilter);
|
||||
deleteInput.setTransaction(transaction);
|
||||
deleteInput.setOmitDmlAudit(input.getOmitDmlAudit());
|
||||
DeleteOutput deleteOutput = new DeleteAction().execute(deleteInput);
|
||||
output.setDeleteOutput(deleteOutput);
|
||||
}
|
||||
|
||||
if(weOwnTheTransaction)
|
||||
{
|
||||
|
@ -34,9 +34,8 @@ import com.kingsrook.qqq.backend.core.actions.ActionHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationStatusUpdater;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostUpdateCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
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.interfaces.UpdateInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.helpers.ValidateRecordSecurityLockHelper;
|
||||
@ -57,6 +56,8 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
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.fields.DynamicDefaultValueBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
@ -72,6 +73,7 @@ import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import org.apache.commons.lang.BooleanUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@ -113,7 +115,6 @@ public class UpdateAction
|
||||
public UpdateOutput execute(UpdateInput updateInput) throws QException
|
||||
{
|
||||
ActionHelper.validateSession(updateInput);
|
||||
setAutomationStatusField(updateInput);
|
||||
|
||||
QTableMetaData table = updateInput.getTable();
|
||||
|
||||
@ -130,12 +131,31 @@ public class UpdateAction
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
Optional<List<QRecord>> oldRecordList = fetchOldRecords(updateInput, updateInterface);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// allow caller to specify that we don't want to trigger automations. this isn't //
|
||||
// isn't expected to be used much - by design, only for the process that is meant to //
|
||||
// heal automation status, so that it can force us into status=Pending-inserts //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
if(!updateInput.getOmitTriggeringAutomations())
|
||||
{
|
||||
setAutomationStatusField(updateInput, oldRecordList);
|
||||
}
|
||||
|
||||
performValidations(updateInput, oldRecordList, false);
|
||||
|
||||
////////////////////////////////////
|
||||
// have the backend do the update //
|
||||
////////////////////////////////////
|
||||
UpdateOutput updateOutput = updateInterface.execute(updateInput);
|
||||
UpdateOutput updateOutput = runUpdateInBackend(updateInput, updateInterface);
|
||||
|
||||
if(updateOutput.getRecords() == null)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// in case the module failed to set record in the output, put an empty list there //
|
||||
// to avoid so many downstream NPE's //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
updateOutput.setRecords(new ArrayList<>());
|
||||
}
|
||||
|
||||
//////////////////////////////
|
||||
// log if there were errors //
|
||||
@ -171,14 +191,12 @@ public class UpdateAction
|
||||
//////////////////////////////////////////////////////////////
|
||||
// finally, run the post-update customizer, if there is one //
|
||||
//////////////////////////////////////////////////////////////
|
||||
Optional<AbstractPostUpdateCustomizer> postUpdateCustomizer = QCodeLoader.getTableCustomizer(AbstractPostUpdateCustomizer.class, table, TableCustomizers.POST_UPDATE_RECORD.getRole());
|
||||
Optional<TableCustomizerInterface> postUpdateCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.POST_UPDATE_RECORD.getRole());
|
||||
if(postUpdateCustomizer.isPresent())
|
||||
{
|
||||
try
|
||||
{
|
||||
postUpdateCustomizer.get().setUpdateInput(updateInput);
|
||||
oldRecordList.ifPresent(l -> postUpdateCustomizer.get().setOldRecordList(l));
|
||||
updateOutput.setRecords(postUpdateCustomizer.get().apply(updateOutput.getRecords()));
|
||||
updateOutput.setRecords(postUpdateCustomizer.get().postUpdate(updateInput, updateOutput.getRecords(), oldRecordList));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@ -194,6 +212,28 @@ public class UpdateAction
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private UpdateOutput runUpdateInBackend(UpdateInput updateInput, UpdateInterface updateInterface) throws QException
|
||||
{
|
||||
///////////////////////////////////
|
||||
// exit early if 0 input records //
|
||||
///////////////////////////////////
|
||||
if(CollectionUtils.nullSafeIsEmpty(updateInput.getRecords()))
|
||||
{
|
||||
LOG.debug("Update request called with 0 records. Returning with no-op", logPair("tableName", updateInput.getTableName()));
|
||||
UpdateOutput rs = new UpdateOutput();
|
||||
rs.setRecords(new ArrayList<>());
|
||||
return (rs);
|
||||
}
|
||||
|
||||
UpdateOutput updateOutput = updateInterface.execute(updateInput);
|
||||
return updateOutput;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -204,7 +244,13 @@ public class UpdateAction
|
||||
/////////////////////////////
|
||||
// run standard validators //
|
||||
/////////////////////////////
|
||||
ValueBehaviorApplier.applyFieldBehaviors(updateInput.getInstance(), table, updateInput.getRecords());
|
||||
Set<FieldBehavior<?>> behaviorsToOmit = null;
|
||||
if(BooleanUtils.isTrue(updateInput.getOmitModifyDateUpdate()))
|
||||
{
|
||||
behaviorsToOmit = Set.of(DynamicDefaultValueBehavior.MODIFY_DATE);
|
||||
}
|
||||
|
||||
ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.UPDATE, updateInput.getInstance(), table, updateInput.getRecords(), behaviorsToOmit);
|
||||
validatePrimaryKeysAreGiven(updateInput);
|
||||
|
||||
if(oldRecordList.isPresent())
|
||||
@ -224,13 +270,10 @@ public class UpdateAction
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// after all validations, run the pre-update customizer, if there is one //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
Optional<AbstractPreUpdateCustomizer> preUpdateCustomizer = QCodeLoader.getTableCustomizer(AbstractPreUpdateCustomizer.class, table, TableCustomizers.PRE_UPDATE_RECORD.getRole());
|
||||
Optional<TableCustomizerInterface> preUpdateCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD.getRole());
|
||||
if(preUpdateCustomizer.isPresent())
|
||||
{
|
||||
preUpdateCustomizer.get().setUpdateInput(updateInput);
|
||||
preUpdateCustomizer.get().setIsPreview(isPreview);
|
||||
oldRecordList.ifPresent(l -> preUpdateCustomizer.get().setOldRecordList(l));
|
||||
updateInput.setRecords(preUpdateCustomizer.get().apply(updateInput.getRecords()));
|
||||
updateInput.setRecords(preUpdateCustomizer.get().preUpdate(updateInput, updateInput.getRecords(), isPreview, oldRecordList));
|
||||
}
|
||||
}
|
||||
|
||||
@ -530,9 +573,9 @@ public class UpdateAction
|
||||
/*******************************************************************************
|
||||
** If the table being updated uses an automation-status field, populate it now.
|
||||
*******************************************************************************/
|
||||
private void setAutomationStatusField(UpdateInput updateInput)
|
||||
private void setAutomationStatusField(UpdateInput updateInput, Optional<List<QRecord>> oldRecordList)
|
||||
{
|
||||
RecordAutomationStatusUpdater.setAutomationStatusInRecords(updateInput.getSession(), updateInput.getTable(), updateInput.getRecords(), AutomationStatus.PENDING_UPDATE_AUTOMATIONS);
|
||||
RecordAutomationStatusUpdater.setAutomationStatusInRecords(updateInput.getTable(), updateInput.getRecords(), AutomationStatus.PENDING_UPDATE_AUTOMATIONS, updateInput.getTransaction(), oldRecordList.orElse(null));
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ public class UniqueKeyHelper
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static Map<List<Serializable>, Serializable> getExistingKeys(QBackendTransaction transaction, QTableMetaData table, List<QRecord> recordList, UniqueKey uniqueKey) throws QException
|
||||
public static Map<List<Serializable>, Serializable> getExistingKeys(QBackendTransaction transaction, QTableMetaData table, List<QRecord> recordList, UniqueKey uniqueKey, boolean allowNullKeyValuesToEqual) throws QException
|
||||
{
|
||||
List<String> ukFieldNames = uniqueKey.getFieldNames();
|
||||
Map<List<Serializable>, Serializable> existingRecords = new HashMap<>();
|
||||
@ -112,7 +112,7 @@ public class UniqueKeyHelper
|
||||
QueryOutput queryOutput = new QueryAction().execute(queryInput);
|
||||
for(QRecord record : queryOutput.getRecords())
|
||||
{
|
||||
Optional<List<Serializable>> keyValues = getKeyValues(table, uniqueKey, record);
|
||||
Optional<List<Serializable>> keyValues = getKeyValues(table, uniqueKey, record, allowNullKeyValuesToEqual);
|
||||
if(keyValues.isPresent())
|
||||
{
|
||||
existingRecords.put(keyValues.get(), record.getValue(table.getPrimaryKeyField()));
|
||||
@ -128,7 +128,17 @@ public class UniqueKeyHelper
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static Optional<List<Serializable>> getKeyValues(QTableMetaData table, UniqueKey uniqueKey, QRecord record)
|
||||
public static Map<List<Serializable>, Serializable> getExistingKeys(QBackendTransaction transaction, QTableMetaData table, List<QRecord> recordList, UniqueKey uniqueKey) throws QException
|
||||
{
|
||||
return (getExistingKeys(transaction, table, recordList, uniqueKey, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static Optional<List<Serializable>> getKeyValues(QTableMetaData table, UniqueKey uniqueKey, QRecord record, boolean allowNullKeyValuesToEqual)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -138,7 +148,19 @@ public class UniqueKeyHelper
|
||||
QFieldMetaData field = table.getField(fieldName);
|
||||
Serializable value = record.getValue(fieldName);
|
||||
Serializable typedValue = ValueUtils.getValueAsFieldType(field.getType(), value);
|
||||
keyValues.add(typedValue == null ? new NullUniqueKeyValue() : typedValue);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// if null value, look at flag to determine if a null should be used (which will //
|
||||
// allow keys to match), or a NullUniqueKeyValue, (which will never match) //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
if(typedValue == null)
|
||||
{
|
||||
keyValues.add(allowNullKeyValuesToEqual ? null : new NullUniqueKeyValue());
|
||||
}
|
||||
else
|
||||
{
|
||||
keyValues.add(typedValue);
|
||||
}
|
||||
}
|
||||
return (Optional.of(keyValues));
|
||||
}
|
||||
@ -150,6 +172,16 @@ public class UniqueKeyHelper
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static Optional<List<Serializable>> getKeyValues(QTableMetaData table, UniqueKey uniqueKey, QRecord record)
|
||||
{
|
||||
return (getKeyValues(table, uniqueKey, record, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** To make a list of unique key values here behave like they do in an RDBMS
|
||||
** (which is what we're trying to mimic - which is - 2 null values in a field
|
||||
|
@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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.actions.tables.helpers;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
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.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ListingHash;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Helper for backends that want to do their updates on records grouped by the
|
||||
** set of fields that are being changed, and/or by the values those fields are
|
||||
** being set to.
|
||||
**
|
||||
** e.g., RDBMS, for n records where some sub-set of fields are all having values
|
||||
** set the same (say, a status=x), we can do that as 1 query where id in (?,?,...,?).
|
||||
*******************************************************************************/
|
||||
public class UpdateActionRecordSplitHelper
|
||||
{
|
||||
private ListingHash<List<String>, QRecord> recordsByFieldBeingUpdated = new ListingHash<>();
|
||||
private boolean haveAnyWithoutErrors = false;
|
||||
private List<QRecord> outputRecords = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void init(UpdateInput updateInput)
|
||||
{
|
||||
QTableMetaData table = updateInput.getTable();
|
||||
Instant now = Instant.now();
|
||||
|
||||
for(QRecord record : updateInput.getRecords())
|
||||
{
|
||||
List<String> updatableFields = table.getFields().values().stream()
|
||||
.map(QFieldMetaData::getName)
|
||||
// todo - intent here is to avoid non-updateable fields - but this
|
||||
// should be like based on field.isUpdatable once that attribute exists
|
||||
.filter(name -> !name.equals("id"))
|
||||
.filter(name -> record.getValues().containsKey(name))
|
||||
.toList();
|
||||
recordsByFieldBeingUpdated.add(updatableFields, record);
|
||||
|
||||
if(CollectionUtils.nullSafeIsEmpty(record.getErrors()))
|
||||
{
|
||||
haveAnyWithoutErrors = true;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// go ahead and put the record into the output list at this point in time, //
|
||||
// so that the output list's order matches the input list order //
|
||||
// note that if we want to capture updated values (like modify dates), then //
|
||||
// we may want a map of primary key to output record, for easy updating. //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
QRecord outputRecord = new QRecord(record);
|
||||
outputRecords.add(outputRecord);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static boolean areAllValuesBeingUpdatedTheSame(UpdateInput updateInput, List<QRecord> recordList, List<String> fieldsBeingUpdated)
|
||||
{
|
||||
if(updateInput.getAreAllValuesBeingUpdatedTheSame() != null)
|
||||
{
|
||||
////////////////////////////////////////////////////////////
|
||||
// if input told us what value to use here, then trust it //
|
||||
////////////////////////////////////////////////////////////
|
||||
return (updateInput.getAreAllValuesBeingUpdatedTheSame());
|
||||
}
|
||||
else
|
||||
{
|
||||
if(recordList.size() == 1)
|
||||
{
|
||||
//////////////////////////////////////////////////////
|
||||
// if a single record, then yes, that always counts //
|
||||
//////////////////////////////////////////////////////
|
||||
return (true);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// else iterate over the records, comparing them to the first record //
|
||||
// return a false if any diffs are found. if no diffs, return true. //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
QRecord firstRecord = recordList.get(0);
|
||||
for(int i = 1; i < recordList.size(); i++)
|
||||
{
|
||||
QRecord record = recordList.get(i);
|
||||
|
||||
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
{
|
||||
///////////////////////////////////////////////////////
|
||||
// skip records w/ errors (that we won't be updating //
|
||||
///////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
|
||||
for(String fieldName : fieldsBeingUpdated)
|
||||
{
|
||||
if(!Objects.equals(firstRecord.getValue(fieldName), record.getValue(fieldName)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for haveAnyWithoutErrors
|
||||
**
|
||||
*******************************************************************************/
|
||||
public boolean getHaveAnyWithoutErrors()
|
||||
{
|
||||
return haveAnyWithoutErrors;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordsByFieldBeingUpdated
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ListingHash<List<String>, QRecord> getRecordsByFieldBeingUpdated()
|
||||
{
|
||||
return recordsByFieldBeingUpdated;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outputRecords
|
||||
**
|
||||
*******************************************************************************/
|
||||
public List<QRecord> getOutputRecords()
|
||||
{
|
||||
return outputRecords;
|
||||
}
|
||||
}
|
@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.NullValueBehaviorUtil;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLockFilters;
|
||||
@ -230,7 +231,7 @@ public class ValidateRecordSecurityLockHelper
|
||||
{
|
||||
for(QRecord inputRecord : inputRecords)
|
||||
{
|
||||
if(RecordSecurityLock.NullValueBehavior.DENY.equals(recordSecurityLock.getNullValueBehavior()))
|
||||
if(RecordSecurityLock.NullValueBehavior.DENY.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock)))
|
||||
{
|
||||
inputRecord.addError(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " this record - the referenced " + leftMostJoinTable.getLabel() + " was not found."));
|
||||
}
|
||||
@ -298,7 +299,7 @@ public class ValidateRecordSecurityLockHelper
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// handle null values - error if the NullValueBehavior is DENY //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
if(RecordSecurityLock.NullValueBehavior.DENY.equals(recordSecurityLock.getNullValueBehavior()))
|
||||
if(RecordSecurityLock.NullValueBehavior.DENY.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock)))
|
||||
{
|
||||
String lockLabel = CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain()) ? recordSecurityLock.getSecurityKeyType() : table.getField(recordSecurityLock.getFieldName()).getLabel();
|
||||
record.addError(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " a record without a value in the field: " + lockLabel));
|
||||
|
@ -34,7 +34,6 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QValueException;
|
||||
@ -104,7 +103,7 @@ public class QPossibleValueTranslator
|
||||
{
|
||||
if(!transactionsPerTable.containsKey(tableName))
|
||||
{
|
||||
transactionsPerTable.put(tableName, new InsertAction().openTransaction(new InsertInput(tableName)));
|
||||
transactionsPerTable.put(tableName, QBackendTransaction.openFor(new InsertInput(tableName)));
|
||||
}
|
||||
|
||||
return (transactionsPerTable.get(tableName));
|
||||
@ -270,6 +269,10 @@ public class QPossibleValueTranslator
|
||||
{
|
||||
value = ValueUtils.getValueAsInteger(value);
|
||||
}
|
||||
if(field.getType().equals(QFieldType.LONG) && !(value instanceof Long))
|
||||
{
|
||||
value = ValueUtils.getValueAsLong(value);
|
||||
}
|
||||
}
|
||||
catch(QValueException e)
|
||||
{
|
||||
@ -367,6 +370,14 @@ public class QPossibleValueTranslator
|
||||
*******************************************************************************/
|
||||
private String translatePossibleValueCustom(Serializable value, QPossibleValueSource possibleValueSource)
|
||||
{
|
||||
/////////////////////////////////
|
||||
// null input gets null output //
|
||||
/////////////////////////////////
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
QCustomPossibleValueProvider customPossibleValueProvider = QCodeLoader.getCustomPossibleValueProvider(possibleValueSource);
|
||||
|
@ -28,7 +28,6 @@ import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@ -334,27 +333,39 @@ public class QValueFormatter
|
||||
if(exposedJoin.getJoinTable().equals(nameParts[0]))
|
||||
{
|
||||
QTableMetaData joinTable = QContext.getQInstance().getTable(nameParts[0]);
|
||||
fieldMap.put(fieldName, joinTable.getField(nameParts[1]));
|
||||
if(joinTable.getFields().containsKey(nameParts[1]))
|
||||
{
|
||||
fieldMap.put(fieldName, joinTable.getField(nameParts[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
fieldMap.put(fieldName, table.getField(fieldName));
|
||||
if(table.getFields().containsKey(fieldName))
|
||||
{
|
||||
fieldMap.put(fieldName, table.getField(fieldName));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// put an empty field in - so no formatting will be done //
|
||||
///////////////////////////////////////////////////////////
|
||||
LOG.info("Error getting field for setting display value", e, logPair("fieldName", fieldName), logPair("tableName", table.getName()));
|
||||
fieldMap.put(fieldName, new QFieldMetaData());
|
||||
LOG.warn("Error getting field for setting display value", e, logPair("fieldName", fieldName), logPair("tableName", table.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we didn't find the field definition, put an empty field in the map, so no formatting will be done //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(!fieldMap.containsKey(fieldName))
|
||||
{
|
||||
fieldMap.put(fieldName, new QFieldMetaData());
|
||||
}
|
||||
}
|
||||
|
||||
setDisplayValuesInRecord(fieldMap, record);
|
||||
ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, QContext.getQInstance(), table, records, null);
|
||||
|
||||
setDisplayValuesInRecord(table, fieldMap, record, true);
|
||||
record.setRecordLabel(formatRecordLabel(table, record));
|
||||
}
|
||||
}
|
||||
@ -364,61 +375,49 @@ public class QValueFormatter
|
||||
/*******************************************************************************
|
||||
** For a list of records, set their recordLabels and display values
|
||||
*******************************************************************************/
|
||||
public static void setDisplayValuesInRecords(Collection<QFieldMetaData> fields, List<QRecord> records)
|
||||
public static void setDisplayValuesInRecords(QTableMetaData table, Map<String, QFieldMetaData> fields, List<QRecord> records)
|
||||
{
|
||||
if(records == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if(table != null)
|
||||
{
|
||||
ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, QContext.getQInstance(), table, records, null);
|
||||
}
|
||||
|
||||
for(QRecord record : records)
|
||||
{
|
||||
setDisplayValuesInRecord(fields, record);
|
||||
setDisplayValuesInRecord(table, fields, record, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For a list of records, set their recordLabels and display values
|
||||
** For a single record, set its display values - public version of this.
|
||||
*******************************************************************************/
|
||||
public static void setDisplayValuesInRecords(Map<String, QFieldMetaData> fields, List<QRecord> records)
|
||||
public static void setDisplayValuesInRecord(QTableMetaData table, Map<String, QFieldMetaData> fields, QRecord record)
|
||||
{
|
||||
if(records == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for(QRecord record : records)
|
||||
{
|
||||
setDisplayValuesInRecord(fields, record);
|
||||
}
|
||||
setDisplayValuesInRecord(table, fields, record, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For a list of records, set their display values
|
||||
** For a single record, set its display values - where caller (meant to stay private)
|
||||
** can specify if they've already done fieldBehaviors (to avoid re-doing).
|
||||
*******************************************************************************/
|
||||
public static void setDisplayValuesInRecord(Collection<QFieldMetaData> fields, QRecord record)
|
||||
private static void setDisplayValuesInRecord(QTableMetaData table, Map<String, QFieldMetaData> fields, QRecord record, boolean alreadyAppliedFieldDisplayBehaviors)
|
||||
{
|
||||
for(QFieldMetaData field : fields)
|
||||
if(!alreadyAppliedFieldDisplayBehaviors)
|
||||
{
|
||||
if(record.getDisplayValue(field.getName()) == null)
|
||||
if(table != null)
|
||||
{
|
||||
String formattedValue = formatValue(field, record.getValue(field.getName()));
|
||||
record.setDisplayValue(field.getName(), formattedValue);
|
||||
ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, QContext.getQInstance(), table, List.of(record), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For a list of records, set their display values
|
||||
*******************************************************************************/
|
||||
public static void setDisplayValuesInRecord(Map<String, QFieldMetaData> fields, QRecord record)
|
||||
{
|
||||
for(Map.Entry<String, QFieldMetaData> entry : fields.entrySet())
|
||||
{
|
||||
String fieldName = entry.getKey();
|
||||
@ -480,6 +479,13 @@ public class QValueFormatter
|
||||
{
|
||||
adornmentValues = fileDownloadAdornment.get().getValues();
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////
|
||||
// don't change blobs unless they are file-downloads //
|
||||
///////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
|
||||
String fileNameField = ValueUtils.getValueAsString(adornmentValues.get(AdornmentType.FileDownloadValues.FILE_NAME_FIELD));
|
||||
String fileNameFormat = ValueUtils.getValueAsString(adornmentValues.get(AdornmentType.FileDownloadValues.FILE_NAME_FORMAT));
|
||||
|
@ -23,18 +23,18 @@ package com.kingsrook.qqq.backend.core.actions.values;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldDisplayBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class to apply value behaviors to records.
|
||||
** Utility class to apply value behaviors to records.
|
||||
*******************************************************************************/
|
||||
public class ValueBehaviorApplier
|
||||
{
|
||||
@ -42,16 +42,11 @@ public class ValueBehaviorApplier
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static void applyFieldBehaviors(QInstance instance, QTableMetaData table, List<QRecord> recordList)
|
||||
public enum Action
|
||||
{
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String fieldName = field.getName();
|
||||
if(field.getType().equals(QFieldType.STRING) && field.getMaxLength() != null)
|
||||
{
|
||||
applyValueTooLongBehavior(instance, recordList, field, fieldName);
|
||||
}
|
||||
}
|
||||
INSERT,
|
||||
UPDATE,
|
||||
FORMATTING
|
||||
}
|
||||
|
||||
|
||||
@ -59,30 +54,44 @@ public class ValueBehaviorApplier
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void applyValueTooLongBehavior(QInstance instance, List<QRecord> recordList, QFieldMetaData field, String fieldName)
|
||||
public static void applyFieldBehaviors(Action action, QInstance instance, QTableMetaData table, List<QRecord> recordList, Set<FieldBehavior<?>> behaviorsToOmit)
|
||||
{
|
||||
ValueTooLongBehavior valueTooLongBehavior = field.getBehavior(instance, ValueTooLongBehavior.class);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// don't process PASS_THROUGH - so we don't have to iterate over the whole record list to do noop //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(valueTooLongBehavior != null && !valueTooLongBehavior.equals(ValueTooLongBehavior.PASS_THROUGH))
|
||||
if(CollectionUtils.nullSafeIsEmpty(recordList))
|
||||
{
|
||||
for(QRecord record : recordList)
|
||||
return;
|
||||
}
|
||||
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
for(FieldBehavior<?> fieldBehavior : CollectionUtils.nonNullCollection(field.getBehaviors()))
|
||||
{
|
||||
String value = record.getValueString(fieldName);
|
||||
if(value != null && value.length() > field.getMaxLength())
|
||||
boolean applyBehavior = true;
|
||||
if(behaviorsToOmit != null && behaviorsToOmit.contains(fieldBehavior))
|
||||
{
|
||||
switch(valueTooLongBehavior)
|
||||
{
|
||||
case TRUNCATE -> record.setValue(fieldName, StringUtils.safeTruncate(value, field.getMaxLength()));
|
||||
case TRUNCATE_ELLIPSIS -> record.setValue(fieldName, StringUtils.safeTruncate(value, field.getMaxLength(), "..."));
|
||||
case ERROR -> record.addError(new BadInputStatusMessage("The value for " + field.getLabel() + " is too long (max allowed length=" + field.getMaxLength() + ")"));
|
||||
case PASS_THROUGH ->
|
||||
{
|
||||
}
|
||||
default -> throw new IllegalStateException("Unexpected valueTooLongBehavior: " + valueTooLongBehavior);
|
||||
}
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we're given a set of behaviors to omit, and this behavior is in there, then skip //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
applyBehavior = false;
|
||||
}
|
||||
|
||||
if(Action.FORMATTING == action && !(fieldBehavior instanceof FieldDisplayBehavior<?>))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// for the formatting action, do not apply the behavior unless it is a field-display-behavior //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
applyBehavior = false;
|
||||
}
|
||||
else if(Action.FORMATTING != action && fieldBehavior instanceof FieldDisplayBehavior<?>)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// for non-formatting actions, do not apply the behavior IF it is a field-display-behavior //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
applyBehavior = false;
|
||||
}
|
||||
|
||||
if(applyBehavior)
|
||||
{
|
||||
fieldBehavior.apply(action, recordList, instance, table, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.shared.mapping.AbstractQFieldMapping;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
@ -133,11 +134,17 @@ public class CsvToQRecordAdapter
|
||||
CSVFormat.DEFAULT
|
||||
.withFirstRecordAsHeader()
|
||||
.withIgnoreHeaderCase()
|
||||
.withIgnoreEmptyLines()
|
||||
.withTrim());
|
||||
|
||||
List<String> headers = csvParser.getHeaderNames();
|
||||
headers = makeHeadersUnique(headers);
|
||||
|
||||
////////////////////////////////////////
|
||||
// used by csv-headers-as-field-names //
|
||||
////////////////////////////////////////
|
||||
Map<String, QFieldMetaData> csvHeaderFieldMapping = buildCsvHeaderFieldMappingIfNeeded(inputWrapper, headers);
|
||||
|
||||
Iterator<CSVRecord> csvIterator = csvParser.iterator();
|
||||
int recordCount = 0;
|
||||
while(csvIterator.hasNext())
|
||||
@ -160,11 +167,27 @@ public class CsvToQRecordAdapter
|
||||
QRecord qRecord = new QRecord();
|
||||
try
|
||||
{
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
if(inputWrapper.getCsvHeadersAsFieldNames())
|
||||
{
|
||||
String fieldSource = mapping == null ? field.getName() : String.valueOf(mapping.getFieldSource(field.getName()));
|
||||
fieldSource = adjustHeaderCase(fieldSource, inputWrapper);
|
||||
setValue(inputWrapper, qRecord, field, csvValues.get(fieldSource));
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// in csv-headers-as-field-names mode, don't mess with table, and don't do any mapping //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
for(Map.Entry<String, String> entry : csvValues.entrySet())
|
||||
{
|
||||
setValue(inputWrapper, qRecord, csvHeaderFieldMapping.get(entry.getKey()), entry.getValue());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////
|
||||
// otherwise, fields come from table //
|
||||
///////////////////////////////////////
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String fieldSource = mapping == null ? field.getName() : String.valueOf(mapping.getFieldSource(field.getName()));
|
||||
fieldSource = adjustHeaderCase(fieldSource, inputWrapper);
|
||||
setValue(inputWrapper, qRecord, field, csvValues.get(fieldSource));
|
||||
}
|
||||
}
|
||||
|
||||
runRecordCustomizer(recordCustomizer, qRecord);
|
||||
@ -247,6 +270,26 @@ public class CsvToQRecordAdapter
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, QFieldMetaData> buildCsvHeaderFieldMappingIfNeeded(InputWrapper inputWrapper, List<String> headers)
|
||||
{
|
||||
Map<String, QFieldMetaData> csvHeaderFieldMapping = null;
|
||||
if(inputWrapper.getCsvHeadersAsFieldNames())
|
||||
{
|
||||
csvHeaderFieldMapping = new HashMap<>();
|
||||
for(String header : headers)
|
||||
{
|
||||
header = adjustHeaderCase(header, inputWrapper);
|
||||
csvHeaderFieldMapping.put(header, new QFieldMetaData(header, QFieldType.STRING));
|
||||
}
|
||||
}
|
||||
return csvHeaderFieldMapping;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -376,7 +419,8 @@ public class CsvToQRecordAdapter
|
||||
private Integer limit;
|
||||
private boolean doCorrectValueTypes = false;
|
||||
|
||||
private boolean caseSensitiveHeaders = false;
|
||||
private boolean caseSensitiveHeaders = false;
|
||||
private boolean csvHeadersAsFieldNames = false;
|
||||
|
||||
|
||||
|
||||
@ -618,6 +662,40 @@ public class CsvToQRecordAdapter
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for csvHeadersAsFieldNames
|
||||
**
|
||||
*******************************************************************************/
|
||||
public boolean getCsvHeadersAsFieldNames()
|
||||
{
|
||||
return csvHeadersAsFieldNames;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for csvHeadersAsFieldNames
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void setCsvHeadersAsFieldNames(boolean csvHeadersAsFieldNames)
|
||||
{
|
||||
this.csvHeadersAsFieldNames = csvHeadersAsFieldNames;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for csvHeadersAsFieldNames
|
||||
**
|
||||
*******************************************************************************/
|
||||
public InputWrapper withCsvHeadersAsFieldNames(boolean csvHeadersAsFieldNames)
|
||||
{
|
||||
this.csvHeadersAsFieldNames = csvHeadersAsFieldNames;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for doCorrectValueTypes
|
||||
**
|
||||
|
@ -103,13 +103,23 @@ public class JsonToQRecordAdapter
|
||||
{
|
||||
QRecord record = new QRecord();
|
||||
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
if(table == null)
|
||||
{
|
||||
String fieldSource = mapping == null ? field.getName() : String.valueOf(mapping.getFieldSource(field.getName()));
|
||||
// todo - so if the mapping didn't say how to map this field, does that mean we should use the default name for the field?
|
||||
if(jsonObject.has(fieldSource))
|
||||
jsonObject.keys().forEachRemaining(key ->
|
||||
{
|
||||
record.setValue(field.getName(), (Serializable) jsonObject.get(fieldSource));
|
||||
record.setValue(key, jsonObject.optString(key));
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String fieldSource = mapping == null ? field.getName() : String.valueOf(mapping.getFieldSource(field.getName()));
|
||||
// todo - so if the mapping didn't say how to map this field, does that mean we should use the default name for the field?
|
||||
if(jsonObject.has(fieldSource))
|
||||
{
|
||||
record.setValue(field.getName(), (Serializable) jsonObject.get(fieldSource));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -34,5 +34,11 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
*******************************************************************************/
|
||||
public record CapturedContext(QInstance qInstance, QSession qSession, QBackendTransaction qBackendTransaction, Stack<AbstractActionInput> actionStack)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Simpler constructor
|
||||
*******************************************************************************/
|
||||
public CapturedContext(QInstance qInstance, QSession qSession)
|
||||
{
|
||||
this(qInstance, qSession, null, null);
|
||||
}
|
||||
}
|
||||
|
@ -22,6 +22,9 @@
|
||||
package com.kingsrook.qqq.backend.core.context;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Stack;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
@ -31,6 +34,8 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeVoidVoidMethod;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -47,6 +52,8 @@ public class QContext
|
||||
private static ThreadLocal<QBackendTransaction> qBackendTransactionThreadLocal = new ThreadLocal<>();
|
||||
private static ThreadLocal<Stack<AbstractActionInput>> actionStackThreadLocal = new ThreadLocal<>();
|
||||
|
||||
private static ThreadLocal<Map<String, Serializable>> objectsThreadLocal = new ThreadLocal<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -100,6 +107,25 @@ public class QContext
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static <T extends Throwable> void withTemporaryContext(CapturedContext context, UnsafeVoidVoidMethod<T> method) throws T
|
||||
{
|
||||
CapturedContext originalContext = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(context);
|
||||
method.run();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(originalContext);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Init a new thread with the context captured from a different thread. e.g.,
|
||||
** when starting some async task.
|
||||
@ -132,6 +158,7 @@ public class QContext
|
||||
qSessionThreadLocal.remove();
|
||||
qBackendTransactionThreadLocal.remove();
|
||||
actionStackThreadLocal.remove();
|
||||
objectsThreadLocal.remove();
|
||||
}
|
||||
|
||||
|
||||
@ -259,4 +286,94 @@ public class QContext
|
||||
|
||||
return (Optional.of(actionStackThreadLocal.get().get(0)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** get one named object from the Context for the current thread. may return null.
|
||||
*******************************************************************************/
|
||||
public static Serializable getObject(String key)
|
||||
{
|
||||
if(objectsThreadLocal.get() == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return objectsThreadLocal.get().get(key);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** get one named object from the Context for the current thread, cast to the
|
||||
** specified type if possible. if not found, or wrong type, empty is returned.
|
||||
*******************************************************************************/
|
||||
public static <T extends Serializable> Optional<T> getObject(String key, Class<T> type)
|
||||
{
|
||||
Serializable object = getObject(key);
|
||||
|
||||
if(type.isInstance(object))
|
||||
{
|
||||
return Optional.of(type.cast(object));
|
||||
}
|
||||
else if(object == null)
|
||||
{
|
||||
return Optional.empty();
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG.warn("Unexpected type of object found in session under key [" + key + "]",
|
||||
logPair("expectedType", type.getName()),
|
||||
logPair("actualType", object.getClass().getName())
|
||||
);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** put a named object into the Context for the current thread.
|
||||
*******************************************************************************/
|
||||
public static void setObject(String key, Serializable object)
|
||||
{
|
||||
if(objectsThreadLocal.get() == null)
|
||||
{
|
||||
objectsThreadLocal.set(new HashMap<>());
|
||||
}
|
||||
objectsThreadLocal.get().put(key, object);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** remove a named object from the Context of the current thread.
|
||||
*******************************************************************************/
|
||||
public static void removeObject(String key)
|
||||
{
|
||||
if(objectsThreadLocal.get() != null)
|
||||
{
|
||||
objectsThreadLocal.get().remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** get the full map of named objects for the current thread (possibly null).
|
||||
*******************************************************************************/
|
||||
public static Map<String, Serializable> getObjects()
|
||||
{
|
||||
return objectsThreadLocal.get();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** fully replace the map of named objets for the current thread.
|
||||
*******************************************************************************/
|
||||
public static void setObjects(Map<String, Serializable> objects)
|
||||
{
|
||||
objectsThreadLocal.set(objects);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.DynamicDefaultValueBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
@ -76,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.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
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.ListingHash;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
@ -94,10 +96,8 @@ public class QInstanceEnricher
|
||||
|
||||
private JoinGraph joinGraph;
|
||||
|
||||
//////////////////////////////////////////////////////////
|
||||
// todo - come up w/ a way for app devs to set configs! //
|
||||
//////////////////////////////////////////////////////////
|
||||
private boolean configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels = true;
|
||||
private boolean configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels = true;
|
||||
private boolean configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate = true;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// let an instance define mappings to be applied during name-to-label enrichments, //
|
||||
@ -160,6 +160,18 @@ public class QInstanceEnricher
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -464,6 +476,22 @@ public class QInstanceEnricher
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// add field behaviors for create date & modify date, if so configured //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate)
|
||||
{
|
||||
if("createDate".equals(field.getName()) && field.getBehaviorOnlyIfSet(DynamicDefaultValueBehavior.class) == null)
|
||||
{
|
||||
field.withBehavior(DynamicDefaultValueBehavior.CREATE_DATE);
|
||||
}
|
||||
|
||||
if("modifyDate".equals(field.getName()) && field.getBehaviorOnlyIfSet(DynamicDefaultValueBehavior.class) == null)
|
||||
{
|
||||
field.withBehavior(DynamicDefaultValueBehavior.MODIFY_DATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1015,6 +1043,50 @@ public class QInstanceEnricher
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Do a default mapping from an underscore_style field name to a camelCase name.
|
||||
**
|
||||
** Examples:
|
||||
** <ul>
|
||||
** <li>word_another_word_more_words -> wordAnotherWordMoreWords</li>
|
||||
** <li>l_ul_ul_ul -> lUlUlUl</li>
|
||||
** <li>tla_first -> tlaFirst</li>
|
||||
** <li>word_then_tla_in_middle -> wordThenTlaInMiddle</li>
|
||||
** <li>end_with_tla -> endWithTla</li>
|
||||
** <li>tla_and_another_tla -> tlaAndAnotherTla</li>
|
||||
** <li>ALL_CAPS -> allCaps</li>
|
||||
** </ul>
|
||||
*******************************************************************************/
|
||||
public static String inferNameFromBackendName(String backendName)
|
||||
{
|
||||
StringBuilder rs = new StringBuilder();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// build a list of words in the name, then join them with _ and lower-case the result //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
String[] words = backendName.toLowerCase(Locale.ROOT).split("_");
|
||||
for(int i = 0; i < words.length; i++)
|
||||
{
|
||||
String word = words[i];
|
||||
if(i == 0)
|
||||
{
|
||||
rs.append(word);
|
||||
}
|
||||
else
|
||||
{
|
||||
rs.append(word.substring(0, 1).toUpperCase());
|
||||
if(word.length() > 1)
|
||||
{
|
||||
rs.append(word.substring(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (rs.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** If a app didn't have any sections, generate "sensible defaults"
|
||||
*******************************************************************************/
|
||||
@ -1220,4 +1292,66 @@ public class QInstanceEnricher
|
||||
labelMappings.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels
|
||||
*******************************************************************************/
|
||||
public boolean getConfigRemoveIdFromNameWhenCreatingPossibleValueFieldLabels()
|
||||
{
|
||||
return (this.configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels
|
||||
*******************************************************************************/
|
||||
public void setConfigRemoveIdFromNameWhenCreatingPossibleValueFieldLabels(boolean configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels)
|
||||
{
|
||||
this.configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels = configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels
|
||||
*******************************************************************************/
|
||||
public QInstanceEnricher withConfigRemoveIdFromNameWhenCreatingPossibleValueFieldLabels(boolean configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels)
|
||||
{
|
||||
this.configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels = configRemoveIdFromNameWhenCreatingPossibleValueFieldLabels;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate
|
||||
*******************************************************************************/
|
||||
public boolean getConfigAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate()
|
||||
{
|
||||
return (this.configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate
|
||||
*******************************************************************************/
|
||||
public void setConfigAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate(boolean configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate)
|
||||
{
|
||||
this.configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate = configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate
|
||||
*******************************************************************************/
|
||||
public QInstanceEnricher withConfigAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate(boolean configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate)
|
||||
{
|
||||
this.configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate = configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -30,12 +30,14 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.HelpFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.HelpRole;
|
||||
@ -109,6 +111,8 @@ public class QInstanceHelpContentManager
|
||||
String processName = nameValuePairs.get("process");
|
||||
String fieldName = nameValuePairs.get("field");
|
||||
String sectionName = nameValuePairs.get("section");
|
||||
String widgetName = nameValuePairs.get("widget");
|
||||
String slotName = nameValuePairs.get("slot");
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// build a help content meta-data object from the record //
|
||||
@ -137,89 +141,16 @@ public class QInstanceHelpContentManager
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
if(StringUtils.hasContent(tableName))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
if(table == null)
|
||||
{
|
||||
LOG.info("Unrecognized table in help content", logPair("key", key));
|
||||
return;
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(fieldName))
|
||||
{
|
||||
//////////////////////////
|
||||
// handle a table field //
|
||||
//////////////////////////
|
||||
QFieldMetaData field = table.getFields().get(fieldName);
|
||||
if(field == null)
|
||||
{
|
||||
LOG.info("Unrecognized table field in help content", logPair("key", key));
|
||||
return;
|
||||
}
|
||||
|
||||
if(helpContent != null)
|
||||
{
|
||||
field.withHelpContent(helpContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
field.removeHelpContent(roles);
|
||||
}
|
||||
}
|
||||
else if(StringUtils.hasContent(sectionName))
|
||||
{
|
||||
////////////////////////////
|
||||
// handle a table section //
|
||||
////////////////////////////
|
||||
Optional<QFieldSection> optionalSection = table.getSections().stream().filter(s -> sectionName.equals(s.getName())).findFirst();
|
||||
if(optionalSection.isEmpty())
|
||||
{
|
||||
LOG.info("Unrecognized table section in help content", logPair("key", key));
|
||||
return;
|
||||
}
|
||||
|
||||
if(helpContent != null)
|
||||
{
|
||||
optionalSection.get().withHelpContent(helpContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
optionalSection.get().removeHelpContent(roles);
|
||||
}
|
||||
}
|
||||
processHelpContentForTable(key, tableName, sectionName, fieldName, roles, helpContent);
|
||||
}
|
||||
else if(StringUtils.hasContent(processName))
|
||||
{
|
||||
QProcessMetaData process = qInstance.getProcess(processName);
|
||||
if(process == null)
|
||||
{
|
||||
LOG.info("Unrecognized process in help content", logPair("key", key));
|
||||
return;
|
||||
}
|
||||
processHelpContentForProcess(key, processName, fieldName, roles, helpContent);
|
||||
}
|
||||
else if(StringUtils.hasContent(widgetName))
|
||||
{
|
||||
processHelpContentForWidget(key, widgetName, slotName, helpContent);
|
||||
|
||||
if(StringUtils.hasContent(fieldName))
|
||||
{
|
||||
////////////////////////////
|
||||
// handle a process field //
|
||||
////////////////////////////
|
||||
Optional<QFieldMetaData> optionalField = CollectionUtils.mergeLists(process.getInputFields(), process.getOutputFields())
|
||||
.stream().filter(f -> fieldName.equals(f.getName()))
|
||||
.findFirst();
|
||||
|
||||
if(optionalField.isEmpty())
|
||||
{
|
||||
LOG.info("Unrecognized process field in help content", logPair("key", key));
|
||||
return;
|
||||
}
|
||||
|
||||
if(helpContent != null)
|
||||
{
|
||||
optionalField.get().withHelpContent(helpContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
optionalField.get().removeHelpContent(roles);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
@ -230,6 +161,131 @@ public class QInstanceHelpContentManager
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void processHelpContentForTable(String key, String tableName, String sectionName, String fieldName, Set<HelpRole> roles, QHelpContent helpContent)
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
if(table == null)
|
||||
{
|
||||
LOG.info("Unrecognized table in help content", logPair("key", key));
|
||||
}
|
||||
else if(StringUtils.hasContent(fieldName))
|
||||
{
|
||||
//////////////////////////
|
||||
// handle a table field //
|
||||
//////////////////////////
|
||||
QFieldMetaData field = table.getFields().get(fieldName);
|
||||
if(field == null)
|
||||
{
|
||||
LOG.info("Unrecognized table field in help content", logPair("key", key));
|
||||
}
|
||||
else if(helpContent != null)
|
||||
{
|
||||
field.withHelpContent(helpContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
field.removeHelpContent(roles);
|
||||
}
|
||||
}
|
||||
else if(StringUtils.hasContent(sectionName))
|
||||
{
|
||||
////////////////////////////
|
||||
// handle a table section //
|
||||
////////////////////////////
|
||||
Optional<QFieldSection> optionalSection = table.getSections().stream().filter(s -> sectionName.equals(s.getName())).findFirst();
|
||||
if(optionalSection.isEmpty())
|
||||
{
|
||||
LOG.info("Unrecognized table section in help content", logPair("key", key));
|
||||
}
|
||||
else if(helpContent != null)
|
||||
{
|
||||
optionalSection.get().withHelpContent(helpContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
optionalSection.get().removeHelpContent(roles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void processHelpContentForProcess(String key, String processName, String fieldName, Set<HelpRole> roles, QHelpContent helpContent)
|
||||
{
|
||||
QProcessMetaData process = QContext.getQInstance().getProcess(processName);
|
||||
if(process == null)
|
||||
{
|
||||
LOG.info("Unrecognized process in help content", logPair("key", key));
|
||||
}
|
||||
else if(StringUtils.hasContent(fieldName))
|
||||
{
|
||||
////////////////////////////
|
||||
// handle a process field //
|
||||
////////////////////////////
|
||||
Optional<QFieldMetaData> optionalField = CollectionUtils.mergeLists(process.getInputFields(), process.getOutputFields())
|
||||
.stream().filter(f -> fieldName.equals(f.getName()))
|
||||
.findFirst();
|
||||
|
||||
if(optionalField.isEmpty())
|
||||
{
|
||||
LOG.info("Unrecognized process field in help content", logPair("key", key));
|
||||
}
|
||||
else if(helpContent != null)
|
||||
{
|
||||
optionalField.get().withHelpContent(helpContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
optionalField.get().removeHelpContent(roles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void processHelpContentForWidget(String key, String widgetName, String slotName, QHelpContent helpContent)
|
||||
{
|
||||
QWidgetMetaDataInterface widget = QContext.getQInstance().getWidget(widgetName);
|
||||
if(!StringUtils.hasContent(slotName))
|
||||
{
|
||||
LOG.info("Missing slot name in help content", logPair("key", key));
|
||||
}
|
||||
else if(widget == null)
|
||||
{
|
||||
LOG.info("Unrecognized widget in help content", logPair("key", key));
|
||||
}
|
||||
else
|
||||
{
|
||||
Map<String, QHelpContent> widgetHelpContent = widget.getHelpContent();
|
||||
if(widgetHelpContent == null)
|
||||
{
|
||||
widgetHelpContent = new HashMap<>();
|
||||
}
|
||||
|
||||
if(helpContent != null)
|
||||
{
|
||||
widgetHelpContent.put(slotName, helpContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
widgetHelpContent.remove(slotName);
|
||||
}
|
||||
|
||||
widget.setHelpContent(widgetHelpContent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** add a help content object to a list - replacing an entry in the list with the
|
||||
** same roles if one is found.
|
||||
|
@ -24,23 +24,30 @@ package com.kingsrook.qqq.backend.core.instances;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.text.ParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationHandler;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.metadata.JoinGraph;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.scripts.TestScriptActionInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException;
|
||||
import com.kingsrook.qqq.backend.core.instances.validation.plugins.QInstanceValidatorPluginInterface;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
@ -49,10 +56,15 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.automation.QAutomationProviderMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.ParentWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
@ -60,21 +72,27 @@ import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QSupplementalProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.queues.QQueueMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.queues.QQueueProviderMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.queues.SQSQueueProviderMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportDataSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportField;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportView;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.AssociatedScript;
|
||||
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.QSupplementalTableMetaData;
|
||||
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.UniqueKey;
|
||||
@ -83,9 +101,13 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.Automatio
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.QTableAutomationDetails;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.cache.CacheOf;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.cache.CacheUseCase;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ListingHash;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeLambda;
|
||||
import org.quartz.CronExpression;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -104,6 +126,8 @@ public class QInstanceValidator
|
||||
|
||||
private boolean printWarnings = false;
|
||||
|
||||
private static ListingHash<Class<?>, QInstanceValidatorPluginInterface<?>> validatorPlugins = new ListingHash<>();
|
||||
|
||||
private List<String> errors = new ArrayList<>();
|
||||
|
||||
|
||||
@ -150,11 +174,13 @@ public class QInstanceValidator
|
||||
try
|
||||
{
|
||||
validateBackends(qInstance);
|
||||
validateAuthentication(qInstance);
|
||||
validateAutomationProviders(qInstance);
|
||||
validateTables(qInstance, joinGraph);
|
||||
validateProcesses(qInstance);
|
||||
validateReports(qInstance);
|
||||
validateApps(qInstance);
|
||||
validateWidgets(qInstance);
|
||||
validatePossibleValueSources(qInstance);
|
||||
validateQueuesAndProviders(qInstance);
|
||||
validateJoins(qInstance);
|
||||
@ -162,6 +188,8 @@ public class QInstanceValidator
|
||||
validateSupplementalMetaData(qInstance);
|
||||
|
||||
validateUniqueTopLevelNames(qInstance);
|
||||
|
||||
runPlugins(QInstance.class, qInstance, qInstance);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@ -180,6 +208,57 @@ public class QInstanceValidator
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static void addValidatorPlugin(QInstanceValidatorPluginInterface<?> plugin)
|
||||
{
|
||||
Optional<Method> validateMethod = Arrays.stream(plugin.getClass().getDeclaredMethods())
|
||||
.filter(m -> m.getName().equals("validate")
|
||||
&& m.getParameterCount() == 3
|
||||
&& !m.getParameterTypes()[0].equals(Object.class)
|
||||
&& m.getParameterTypes()[1].equals(QInstance.class)
|
||||
&& m.getParameterTypes()[2].equals(QInstanceValidator.class)
|
||||
).findFirst();
|
||||
|
||||
if(validateMethod.isPresent())
|
||||
{
|
||||
Class<?> parameterType = validateMethod.get().getParameterTypes()[0];
|
||||
validatorPlugins.add(parameterType, plugin);
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG.warn("Could not find validate method on validator plugin [" + plugin.getClass().getName() + "] (to infer type being validated) - this plugin will not be used.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static void removeAllValidatorPlugins()
|
||||
{
|
||||
validatorPlugins.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private <T> void runPlugins(Class<T> c, T t, QInstance qInstance)
|
||||
{
|
||||
for(QInstanceValidatorPluginInterface<?> plugin : CollectionUtils.nonNullList(validatorPlugins.get(c)))
|
||||
{
|
||||
@SuppressWarnings("unchecked")
|
||||
QInstanceValidatorPluginInterface<T> processPlugin = (QInstanceValidatorPluginInterface<T>) plugin;
|
||||
processPlugin.validate(t, qInstance, this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -188,6 +267,8 @@ public class QInstanceValidator
|
||||
for(QSupplementalInstanceMetaData supplementalInstanceMetaData : CollectionUtils.nonNullMap(qInstance.getSupplementalMetaData()).values())
|
||||
{
|
||||
supplementalInstanceMetaData.validate(qInstance, this);
|
||||
|
||||
runPlugins(QSupplementalInstanceMetaData.class, supplementalInstanceMetaData, qInstance);
|
||||
}
|
||||
}
|
||||
|
||||
@ -204,18 +285,29 @@ public class QInstanceValidator
|
||||
if(assertCondition(StringUtils.hasContent(securityKeyType.getName()), "Missing name for a securityKeyType"))
|
||||
{
|
||||
assertCondition(Objects.equals(name, securityKeyType.getName()), "Inconsistent naming for securityKeyType: " + name + "/" + securityKeyType.getName() + ".");
|
||||
assertCondition(!usedNames.contains(name), "More than one SecurityKeyType with name (or allAccessKeyName) of: " + name);
|
||||
|
||||
String duplicateNameMessagePrefix = "More than one SecurityKeyType with name (or allAccessKeyName or nullValueBehaviorKeyName) of: ";
|
||||
assertCondition(!usedNames.contains(name), duplicateNameMessagePrefix + name);
|
||||
usedNames.add(name);
|
||||
|
||||
if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()))
|
||||
{
|
||||
assertCondition(!usedNames.contains(securityKeyType.getAllAccessKeyName()), "More than one SecurityKeyType with name (or allAccessKeyName) of: " + securityKeyType.getAllAccessKeyName());
|
||||
assertCondition(!usedNames.contains(securityKeyType.getAllAccessKeyName()), duplicateNameMessagePrefix + securityKeyType.getAllAccessKeyName());
|
||||
usedNames.add(securityKeyType.getAllAccessKeyName());
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(securityKeyType.getNullValueBehaviorKeyName()))
|
||||
{
|
||||
assertCondition(!usedNames.contains(securityKeyType.getNullValueBehaviorKeyName()), duplicateNameMessagePrefix + securityKeyType.getNullValueBehaviorKeyName());
|
||||
usedNames.add(securityKeyType.getNullValueBehaviorKeyName());
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(securityKeyType.getPossibleValueSourceName()))
|
||||
{
|
||||
assertCondition(qInstance.getPossibleValueSource(securityKeyType.getPossibleValueSourceName()) != null, "Unrecognized possibleValueSourceName in securityKeyType: " + name);
|
||||
}
|
||||
|
||||
runPlugins(QSecurityKeyType.class, securityKeyType, qInstance);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -262,6 +354,8 @@ public class QInstanceValidator
|
||||
assertNoException(() -> qInstance.getTable(join.getRightTable()).getField(orderBy.getFieldName()), "Field name " + orderBy.getFieldName() + " in orderBy for join " + joinName + " is not a defined field in the right-table " + join.getRightTable());
|
||||
}
|
||||
}
|
||||
|
||||
runPlugins(QJoinMetaData.class, join, qInstance);
|
||||
});
|
||||
}
|
||||
|
||||
@ -325,6 +419,8 @@ public class QInstanceValidator
|
||||
assertCondition(StringUtils.hasContent(sqsQueueProvider.getBaseURL()), "Missing baseURL for SQSQueueProvider: " + name);
|
||||
assertCondition(StringUtils.hasContent(sqsQueueProvider.getRegion()), "Missing region for SQSQueueProvider: " + name);
|
||||
}
|
||||
|
||||
runPlugins(QQueueProviderMetaData.class, queueProvider, qInstance);
|
||||
});
|
||||
}
|
||||
|
||||
@ -339,6 +435,13 @@ public class QInstanceValidator
|
||||
{
|
||||
assertCondition(qInstance.getProcesses() != null && qInstance.getProcess(queue.getProcessName()) != null, "Unrecognized processName for queue: " + name);
|
||||
}
|
||||
|
||||
if(queue.getSchedule() != null)
|
||||
{
|
||||
validateScheduleMetaData(queue.getSchedule(), qInstance, "SQSQueueProvider " + name + ", schedule: ");
|
||||
}
|
||||
|
||||
runPlugins(QQueueMetaData.class, queue, qInstance);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -357,6 +460,8 @@ public class QInstanceValidator
|
||||
assertCondition(Objects.equals(backendName, backend.getName()), "Inconsistent naming for backend: " + backendName + "/" + backend.getName() + ".");
|
||||
|
||||
backend.performValidation(this);
|
||||
|
||||
runPlugins(QBackendMetaData.class, backend, qInstance);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -374,12 +479,33 @@ public class QInstanceValidator
|
||||
{
|
||||
assertCondition(Objects.equals(name, automationProvider.getName()), "Inconsistent naming for automationProvider: " + name + "/" + automationProvider.getName() + ".");
|
||||
assertCondition(automationProvider.getType() != null, "Missing type for automationProvider: " + name);
|
||||
|
||||
runPlugins(QAutomationProviderMetaData.class, automationProvider, qInstance);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void validateAuthentication(QInstance qInstance)
|
||||
{
|
||||
QAuthenticationMetaData authentication = qInstance.getAuthentication();
|
||||
if(authentication != null)
|
||||
{
|
||||
if(authentication.getCustomizer() != null)
|
||||
{
|
||||
validateSimpleCodeReference("Instance Authentication meta data customizer ", authentication.getCustomizer(), QAuthenticationModuleCustomizerInterface.class);
|
||||
}
|
||||
|
||||
runPlugins(QAuthenticationMetaData.class, authentication, qInstance);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -480,6 +606,11 @@ public class QInstanceValidator
|
||||
validateTableCustomizer(tableName, entry.getKey(), entry.getValue());
|
||||
}
|
||||
|
||||
if(table.getBackendDetails() != null)
|
||||
{
|
||||
table.getBackendDetails().validate(qInstance, table, this);
|
||||
}
|
||||
|
||||
validateTableAutomationDetails(qInstance, table);
|
||||
validateTableUniqueKeys(table);
|
||||
validateAssociatedScripts(table);
|
||||
@ -487,6 +618,13 @@ public class QInstanceValidator
|
||||
validateTableRecordSecurityLocks(qInstance, table);
|
||||
validateTableAssociations(qInstance, table);
|
||||
validateExposedJoins(qInstance, joinGraph, table);
|
||||
|
||||
for(QSupplementalTableMetaData supplementalTableMetaData : CollectionUtils.nonNullMap(table.getSupplementalMetaData()).values())
|
||||
{
|
||||
supplementalTableMetaData.validate(qInstance, table, this);
|
||||
}
|
||||
|
||||
runPlugins(QTableMetaData.class, table, qInstance);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -673,7 +811,7 @@ public class QInstanceValidator
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void validateTableField(QInstance qInstance, String tableName, String fieldName, QTableMetaData table, QFieldMetaData field)
|
||||
private <T extends FieldBehavior<T>> void validateTableField(QInstance qInstance, String tableName, String fieldName, QTableMetaData table, QFieldMetaData field)
|
||||
{
|
||||
assertCondition(Objects.equals(fieldName, field.getName()),
|
||||
"Inconsistent naming in table " + tableName + " for field " + fieldName + "/" + field.getName() + ".");
|
||||
@ -686,12 +824,32 @@ public class QInstanceValidator
|
||||
|
||||
String prefix = "Field " + fieldName + " in table " + tableName + " ";
|
||||
|
||||
ValueTooLongBehavior behavior = field.getBehavior(qInstance, ValueTooLongBehavior.class);
|
||||
///////////////////////////////////////////////////
|
||||
// validate things we know about field behaviors //
|
||||
///////////////////////////////////////////////////
|
||||
ValueTooLongBehavior behavior = field.getBehaviorOrDefault(qInstance, ValueTooLongBehavior.class);
|
||||
if(behavior != null && !behavior.equals(ValueTooLongBehavior.PASS_THROUGH))
|
||||
{
|
||||
assertCondition(field.getMaxLength() != null, prefix + "specifies a ValueTooLongBehavior, but not a maxLength.");
|
||||
}
|
||||
|
||||
Set<Class<FieldBehavior<T>>> usedFieldBehaviorTypes = new HashSet<>();
|
||||
if(field.getBehaviors() != null)
|
||||
{
|
||||
for(FieldBehavior<?> fieldBehavior : field.getBehaviors())
|
||||
{
|
||||
Class<FieldBehavior<T>> behaviorClass = (Class<FieldBehavior<T>>) fieldBehavior.getClass();
|
||||
|
||||
errors.addAll(fieldBehavior.validateBehaviorConfiguration(table, field));
|
||||
|
||||
if(!fieldBehavior.allowMultipleBehaviorsOfThisType())
|
||||
{
|
||||
assertCondition(!usedFieldBehaviorTypes.contains(behaviorClass), prefix + "has more than 1 fieldBehavior of type " + behaviorClass.getSimpleName() + ", which is not allowed for this type");
|
||||
}
|
||||
usedFieldBehaviorTypes.add(behaviorClass);
|
||||
}
|
||||
}
|
||||
|
||||
if(field.getMaxLength() != null)
|
||||
{
|
||||
assertCondition(field.getMaxLength() > 0, prefix + "has an invalid maxLength (" + field.getMaxLength() + ") - must be greater than 0.");
|
||||
@ -884,6 +1042,11 @@ public class QInstanceValidator
|
||||
assertCondition(qInstance.getAutomationProvider(providerName) != null, " has an unrecognized providerName: " + providerName);
|
||||
}
|
||||
|
||||
if(automationDetails.getSchedule() != null)
|
||||
{
|
||||
validateScheduleMetaData(automationDetails.getSchedule(), qInstance, prefix + " automationDetails, schedule: ");
|
||||
}
|
||||
|
||||
//////////////////////////////////
|
||||
// validate the status tracking //
|
||||
//////////////////////////////////
|
||||
@ -1239,7 +1402,25 @@ public class QInstanceValidator
|
||||
{
|
||||
if(fieldMetaData.getDefaultValue() != null && fieldMetaData.getDefaultValue() instanceof QCodeReference codeReference)
|
||||
{
|
||||
validateSimpleCodeReference("Process " + processName + " backend step code reference: ", codeReference, BackendStep.class);
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// by default, assume that any process field which is a QCodeReference should be a reference to a BackendStep... //
|
||||
// but... allow a secondary field name to be set, to tell us what class to *actually* expect here... //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
Class<?> expectedClass = BackendStep.class;
|
||||
try
|
||||
{
|
||||
Optional<QFieldMetaData> expectedTypeField = backendStepMetaData.getInputMetaData().getField(fieldMetaData.getName() + "_expectedType");
|
||||
if(expectedTypeField.isPresent() && expectedTypeField.get().getDefaultValue() != null)
|
||||
{
|
||||
expectedClass = Class.forName(ValueUtils.getValueAsString(expectedTypeField.get().getDefaultValue()));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
warn("Error loading expectedType for field [" + fieldMetaData.getName() + "] in process [" + processName + "]: " + e.getMessage());
|
||||
}
|
||||
|
||||
validateSimpleCodeReference("Process " + processName + " code reference: ", codeReference, expectedClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1253,13 +1434,17 @@ public class QInstanceValidator
|
||||
if(process.getSchedule() != null)
|
||||
{
|
||||
QScheduleMetaData schedule = process.getSchedule();
|
||||
assertCondition(schedule.getRepeatMillis() != null || schedule.getRepeatSeconds() != null, "Either repeat millis or repeat seconds must be set on schedule in process " + processName);
|
||||
validateScheduleMetaData(schedule, qInstance, "Process " + processName + ", schedule: ");
|
||||
}
|
||||
|
||||
if(schedule.getVariantBackend() != null)
|
||||
{
|
||||
assertCondition(qInstance.getBackend(schedule.getVariantBackend()) != null, "A variant backend was not found for " + schedule.getVariantBackend());
|
||||
assertCondition(schedule.getVariantRunStrategy() != null, "A variant run strategy was not set for " + schedule.getVariantBackend() + " on schedule in process " + processName);
|
||||
}
|
||||
if(process.getVariantBackend() != null)
|
||||
{
|
||||
assertCondition(qInstance.getBackend(process.getVariantBackend()) != null, "Process " + processName + ", a variant backend was not found named " + process.getVariantBackend());
|
||||
assertCondition(process.getVariantRunStrategy() != null, "A variant run strategy was not set for process " + processName + " (which does specify a variant backend)");
|
||||
}
|
||||
else
|
||||
{
|
||||
assertCondition(process.getVariantRunStrategy() == null, "A variant run strategy was set for process " + processName + " (which isn't allowed, since it does not specify a variant backend)");
|
||||
}
|
||||
|
||||
for(QSupplementalProcessMetaData supplementalProcessMetaData : CollectionUtils.nonNullMap(process.getSupplementalMetaData()).values())
|
||||
@ -1267,12 +1452,57 @@ public class QInstanceValidator
|
||||
supplementalProcessMetaData.validate(qInstance, process, this);
|
||||
}
|
||||
|
||||
runPlugins(QProcessMetaData.class, process, qInstance);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void validateScheduleMetaData(QScheduleMetaData schedule, QInstance qInstance, String prefix)
|
||||
{
|
||||
boolean isRepeat = schedule.getRepeatMillis() != null || schedule.getRepeatSeconds() != null;
|
||||
boolean isCron = StringUtils.hasContent(schedule.getCronExpression());
|
||||
assertCondition(isRepeat || isCron, prefix + " either repeatMillis or repeatSeconds or cronExpression must be set");
|
||||
assertCondition(!(isRepeat && isCron), prefix + " both a repeat time and cronExpression may not be set");
|
||||
|
||||
if(isCron)
|
||||
{
|
||||
boolean hasDelay = schedule.getInitialDelayMillis() != null || schedule.getInitialDelaySeconds() != null;
|
||||
assertCondition(!hasDelay, prefix + " a cron schedule may not have an initial delay");
|
||||
|
||||
try
|
||||
{
|
||||
CronExpression.validateExpression(schedule.getCronExpression());
|
||||
}
|
||||
catch(ParseException pe)
|
||||
{
|
||||
errors.add(prefix + " invalid cron expression: " + pe.getMessage());
|
||||
}
|
||||
|
||||
if(assertCondition(StringUtils.hasContent(schedule.getCronTimeZoneId()), prefix + " a cron schedule must specify a cronTimeZoneId"))
|
||||
{
|
||||
String[] availableIDs = TimeZone.getAvailableIDs();
|
||||
Optional<String> first = Arrays.stream(availableIDs).filter(id -> id.equals(schedule.getCronTimeZoneId())).findFirst();
|
||||
assertCondition(first.isPresent(), prefix + " unrecognized cronTimeZoneId: " + schedule.getCronTimeZoneId());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
assertCondition(!StringUtils.hasContent(schedule.getCronTimeZoneId()), prefix + " a non-cron schedule must not specify a cronTimeZoneId");
|
||||
}
|
||||
|
||||
if(assertCondition(StringUtils.hasContent(schedule.getSchedulerName()), prefix + " is missing a scheduler name"))
|
||||
{
|
||||
assertCondition(qInstance.getScheduler(schedule.getSchedulerName()) != null, prefix + " is referencing an unknown scheduler name: " + schedule.getSchedulerName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -1373,6 +1603,8 @@ public class QInstanceValidator
|
||||
// view.getTitleFormat(); view.getTitleFields(); // validate these match?
|
||||
}
|
||||
}
|
||||
|
||||
runPlugins(QReportMetaData.class, report, qInstance);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1493,9 +1725,9 @@ public class QInstanceValidator
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////
|
||||
// validate field sections in the table //
|
||||
//////////////////////////////////////////
|
||||
////////////////////////////////////////
|
||||
// validate field sections in the app //
|
||||
////////////////////////////////////////
|
||||
Set<String> childNamesInSections = new HashSet<>();
|
||||
if(app.getSections() != null)
|
||||
{
|
||||
@ -1515,12 +1747,57 @@ public class QInstanceValidator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////
|
||||
// validate widgets //
|
||||
//////////////////////
|
||||
for(String widgetName : CollectionUtils.nonNullList(app.getWidgets()))
|
||||
{
|
||||
assertCondition(qInstance.getWidget(widgetName) != null, "App " + appName + " widget " + widgetName + " is not a recognized widget.");
|
||||
}
|
||||
|
||||
runPlugins(QAppMetaData.class, app, qInstance);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void validateWidgets(QInstance qInstance)
|
||||
{
|
||||
if(CollectionUtils.nullSafeHasContents(qInstance.getWidgets()))
|
||||
{
|
||||
qInstance.getWidgets().forEach((widgetName, widget) ->
|
||||
{
|
||||
assertCondition(Objects.equals(widgetName, widget.getName()), "Inconsistent naming for widget: " + widgetName + "/" + widget.getName() + ".");
|
||||
|
||||
if(assertCondition(widget.getCodeReference() != null, "Missing codeReference for widget: " + widgetName))
|
||||
{
|
||||
validateSimpleCodeReference("Widget " + widgetName + " code reference: ", widget.getCodeReference(), AbstractWidgetRenderer.class);
|
||||
}
|
||||
|
||||
if(widget instanceof ParentWidgetMetaData parentWidgetMetaData)
|
||||
{
|
||||
if(assertCondition(CollectionUtils.nullSafeHasContents(parentWidgetMetaData.getChildWidgetNameList()), "Missing child widgets for parent widget: " + widget.getName()))
|
||||
{
|
||||
for(String childWidgetName : parentWidgetMetaData.getChildWidgetNameList())
|
||||
{
|
||||
assertCondition(qInstance.getWidget(childWidgetName) != null, "Unrecognized child widget name [" + childWidgetName + "] in parent widget: " + widget.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runPlugins(QWidgetMetaDataInterface.class, widget, qInstance);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -1599,6 +1876,8 @@ public class QInstanceValidator
|
||||
}
|
||||
default -> errors.add("Unexpected possibleValueSource type: " + possibleValueSource.getType());
|
||||
}
|
||||
|
||||
runPlugins(QPossibleValueSource.class, possibleValueSource, qInstance);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1760,20 +2039,6 @@ public class QInstanceValidator
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface UnsafeLambda
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
void run() throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -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.instances.validation.plugins;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.basepull.BasepullExtractStepInterface;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** instance validator plugin, to ensure that a process which is a basepull uses
|
||||
** an extract step marked for basepulls.
|
||||
*******************************************************************************/
|
||||
public class BasepullExtractStepValidator implements QInstanceValidatorPluginInterface<QProcessMetaData>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QProcessMetaData process, QInstance qInstance, QInstanceValidator qInstanceValidator)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// if there's no basepull config on the process, don't do any validation //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
if(process.getBasepullConfiguration() == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// try to find an input field in the process, w/ a defaultValue that's a QCodeReference //
|
||||
// and is an instance of BasepullExtractStepInterface //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
boolean foundBasepullExtractStep = false;
|
||||
for(QFieldMetaData field : process.getInputFields())
|
||||
{
|
||||
if(field.getDefaultValue() != null && field.getDefaultValue() instanceof QCodeReference codeReference)
|
||||
{
|
||||
try
|
||||
{
|
||||
BasepullExtractStepInterface extractStep = QCodeLoader.getAdHoc(BasepullExtractStepInterface.class, codeReference);
|
||||
if(extractStep != null)
|
||||
{
|
||||
foundBasepullExtractStep = true;
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
//////////////////////////////////////////////////////
|
||||
// ok, just means we haven't found our extract step //
|
||||
//////////////////////////////////////////////////////
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// validate we could find a BasepullExtractStepInterface //
|
||||
///////////////////////////////////////////////////////////
|
||||
qInstanceValidator.assertCondition(foundBasepullExtractStep, "Process [" + process.getName() + "] has a basepullConfiguration, but does not have a field with a default value that is a BasepullExtractStepInterface CodeReference");
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.instances.validation.plugins;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Interface for additional / optional q instance validators. Some will be
|
||||
** provided by QQQ - others can be defined by applications.
|
||||
*******************************************************************************/
|
||||
public interface QInstanceValidatorPluginInterface<T>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
void validate(T object, QInstance qInstance, QInstanceValidator qInstanceValidator);
|
||||
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
/*
|
||||
* 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.logging;
|
||||
|
||||
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A log message, which can be "collected" by the QCollectingLogger.
|
||||
*******************************************************************************/
|
||||
public class CollectedLogMessage
|
||||
{
|
||||
private Level level;
|
||||
private String message;
|
||||
private Throwable exception;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
**
|
||||
*******************************************************************************/
|
||||
public CollectedLogMessage()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "CollectedLogMessage{level=" + level + ", message='" + message + '\'' + ", exception=" + exception + '}';
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for message
|
||||
*******************************************************************************/
|
||||
public String getMessage()
|
||||
{
|
||||
return (this.message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for message
|
||||
*******************************************************************************/
|
||||
public void setMessage(String message)
|
||||
{
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for message
|
||||
*******************************************************************************/
|
||||
public CollectedLogMessage withMessage(String message)
|
||||
{
|
||||
this.message = message;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for exception
|
||||
*******************************************************************************/
|
||||
public Throwable getException()
|
||||
{
|
||||
return (this.exception);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for exception
|
||||
*******************************************************************************/
|
||||
public void setException(Throwable exception)
|
||||
{
|
||||
this.exception = exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for exception
|
||||
*******************************************************************************/
|
||||
public CollectedLogMessage withException(Throwable exception)
|
||||
{
|
||||
this.exception = exception;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for level
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Level getLevel()
|
||||
{
|
||||
return level;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for level
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void setLevel(Level level)
|
||||
{
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for level
|
||||
**
|
||||
*******************************************************************************/
|
||||
public CollectedLogMessage withLevel(Level level)
|
||||
{
|
||||
this.level = level;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public JSONObject getMessageAsJSONObject() throws JSONException
|
||||
{
|
||||
return (new JSONObject(getMessage()));
|
||||
}
|
||||
}
|
@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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.logging;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.apache.logging.log4j.Marker;
|
||||
import org.apache.logging.log4j.message.Message;
|
||||
import org.apache.logging.log4j.message.ObjectMessage;
|
||||
import org.apache.logging.log4j.message.SimpleMessageFactory;
|
||||
import org.apache.logging.log4j.simple.SimpleLogger;
|
||||
import org.apache.logging.log4j.util.PropertiesUtil;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ log4j implementation, used within a QLogger, to "collect" log messages
|
||||
** in an internal list - the idea being - for tests, to assert that logs happened.
|
||||
*******************************************************************************/
|
||||
public class QCollectingLogger extends SimpleLogger
|
||||
{
|
||||
private List<CollectedLogMessage> collectedMessages = new ArrayList<>();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// just in case one of these gets activated, and left on, put a limit on how many messages we'll collect //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private int capacity = 100;
|
||||
|
||||
private Logger logger;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
**
|
||||
*******************************************************************************/
|
||||
public QCollectingLogger(Logger logger)
|
||||
{
|
||||
super(logger.getName(), logger.getLevel(), false, false, true, false, "", new SimpleMessageFactory(), new PropertiesUtil(new Properties()), System.out);
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void logMessage(String fqcn, Level level, Marker marker, Message message, Throwable throwable)
|
||||
{
|
||||
////////////////////////////////////////////
|
||||
// add this log message to our collection //
|
||||
////////////////////////////////////////////
|
||||
collectedMessages.add(new CollectedLogMessage()
|
||||
.withLevel(level)
|
||||
.withMessage(message.getFormattedMessage())
|
||||
.withException(throwable));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we've gone over our capacity, remove the 1st entry until we're back at capacity //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
while(collectedMessages.size() > capacity)
|
||||
{
|
||||
collectedMessages.remove(0);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// update the message that we log to indicate that we collected it. //
|
||||
// if it looks like JSON, insert as a name:value pair; else text. //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
String formattedMessage = message.getFormattedMessage();
|
||||
String updatedMessage;
|
||||
if(formattedMessage.startsWith("{"))
|
||||
{
|
||||
updatedMessage = """
|
||||
{"collected":true,""" + formattedMessage.substring(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
updatedMessage = "[Collected] " + formattedMessage;
|
||||
}
|
||||
ObjectMessage myMessage = new ObjectMessage(updatedMessage);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// log the message with the original log4j logger, with our slightly updated message //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
logger.logMessage(level, marker, fqcn, null, myMessage, throwable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for logger
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void setLogger(Logger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for collectedMessages
|
||||
**
|
||||
*******************************************************************************/
|
||||
public List<CollectedLogMessage> getCollectedMessages()
|
||||
{
|
||||
return collectedMessages;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void clear()
|
||||
{
|
||||
this.collectedMessages.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for capacity
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void setCapacity(int capacity)
|
||||
{
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
}
|
@ -119,6 +119,34 @@ public class QLogger
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static QCollectingLogger activateCollectingLoggerForClass(Class<?> c)
|
||||
{
|
||||
Logger loggerFromLogManager = LogManager.getLogger(c);
|
||||
QCollectingLogger collectingLogger = new QCollectingLogger(loggerFromLogManager);
|
||||
|
||||
QLogger qLogger = getLogger(c);
|
||||
qLogger.setLogger(collectingLogger);
|
||||
|
||||
return collectingLogger;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static void deactivateCollectingLoggerForClass(Class<?> c)
|
||||
{
|
||||
Logger loggerFromLogManager = LogManager.getLogger(c);
|
||||
QLogger qLogger = getLogger(c);
|
||||
qLogger.setLogger(loggerFromLogManager);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -518,7 +546,7 @@ public class QLogger
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private String makeJsonString(String message, Throwable t, List<LogPair> logPairList)
|
||||
protected String makeJsonString(String message, Throwable t, List<LogPair> logPairList)
|
||||
{
|
||||
if(logPairList == null)
|
||||
{
|
||||
@ -620,4 +648,15 @@ public class QLogger
|
||||
exceptionList.get(0).setHasLoggedLevel(level);
|
||||
return (level);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for logger
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void setLogger(Logger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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.actions.audits;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Object to accumulate multiple audit-details to be recorded under a single
|
||||
** audit per-record, within a process step. Especially useful if/when the
|
||||
** process step spreads its work out through multiple classes.
|
||||
**
|
||||
** Pattern of usage looks like:
|
||||
**
|
||||
** <pre>
|
||||
** // declare as a field (or local) w/ message for the audit headers
|
||||
** private AuditDetailAccumulator auditDetailAccumulator = new AuditDetailAccumulator("Audit header message");
|
||||
**
|
||||
** // put into thread context
|
||||
** AuditDetailAccumulator.setInContext(auditDetailAccumulator);
|
||||
**
|
||||
** // add a detail message for a record
|
||||
** auditDetailAccumulator.addAuditDetail(tableName, record, "Detail message");
|
||||
**
|
||||
** // in another class, get the accumulator from context and safely add a detail message
|
||||
** AuditDetailAccumulator.getFromContext().ifPresent(ada -> ada.addAuditDetail(tableName, record, "More Details"));
|
||||
**
|
||||
** // at the end of a step run/runOnePage method, add the accumulated audit details to step output
|
||||
** auditDetailAccumulator.getAccumulatedAuditSingleInputs().forEach(runBackendStepOutput::addAuditSingleInput);
|
||||
** auditDetailAccumulator.clear();
|
||||
** </pre>
|
||||
*******************************************************************************/
|
||||
public class AuditDetailAccumulator implements Serializable
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuditDetailAccumulator.class);
|
||||
|
||||
private static final String objectKey = AuditDetailAccumulator.class.getSimpleName();
|
||||
|
||||
private String header;
|
||||
|
||||
private Map<TableNameAndPrimaryKey, AuditSingleInput> recordAuditInputMap = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
**
|
||||
*******************************************************************************/
|
||||
public AuditDetailAccumulator(String header)
|
||||
{
|
||||
this.header = header;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void setInContext()
|
||||
{
|
||||
QContext.setObject(objectKey, this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static Optional<AuditDetailAccumulator> getFromContext()
|
||||
{
|
||||
return QContext.getObject(objectKey, AuditDetailAccumulator.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void addAuditDetail(String tableName, QRecordEntity entity, String message)
|
||||
{
|
||||
if(entity != null)
|
||||
{
|
||||
addAuditDetail(tableName, entity.toQRecord(), message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void addAuditDetail(String tableName, QRecord record, String message)
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
Serializable primaryKey = record.getValue(table.getPrimaryKeyField());
|
||||
if(primaryKey == null)
|
||||
{
|
||||
LOG.info("Missing primary key in input record - audit detail message will not be recorded.", logPair("message", message));
|
||||
return;
|
||||
}
|
||||
|
||||
AuditSingleInput auditSingleInput = recordAuditInputMap.computeIfAbsent(new TableNameAndPrimaryKey(tableName, primaryKey), (key) -> new AuditSingleInput(table, record, header));
|
||||
auditSingleInput.addDetail(message);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Collection<AuditSingleInput> getAccumulatedAuditSingleInputs()
|
||||
{
|
||||
return (recordAuditInputMap.values());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void clear()
|
||||
{
|
||||
recordAuditInputMap.clear();
|
||||
}
|
||||
|
||||
|
||||
private record TableNameAndPrimaryKey(String tableName, Serializable primaryKey) {}
|
||||
}
|
@ -41,7 +41,7 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
/*******************************************************************************
|
||||
** Input data to insert a single audit record (with optional child record)..
|
||||
*******************************************************************************/
|
||||
public class AuditSingleInput
|
||||
public class AuditSingleInput implements Serializable
|
||||
{
|
||||
private String auditTableName;
|
||||
private String auditUserName;
|
||||
|
@ -60,6 +60,11 @@ public interface QueryOrGetInputInterface
|
||||
QBackendTransaction getTransaction();
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
String getTableName();
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for transaction
|
||||
*******************************************************************************/
|
||||
|
@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.aggregate;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
|
||||
|
||||
@ -38,6 +39,17 @@ public class GroupBy implements Serializable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public GroupBy(QFieldMetaData field)
|
||||
{
|
||||
this.type = field.getType();
|
||||
this.fieldName = field.getName();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -25,7 +25,9 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query;
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.serialization.QFilterCriteriaDeserializer;
|
||||
@ -42,7 +44,7 @@ public class QFilterCriteria implements Serializable, Cloneable
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(QFilterCriteria.class);
|
||||
|
||||
private String fieldName;
|
||||
private String fieldName;
|
||||
private QCriteriaOperator operator;
|
||||
private List<Serializable> values;
|
||||
|
||||
@ -93,11 +95,37 @@ public class QFilterCriteria implements Serializable, Cloneable
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public QFilterCriteria(String fieldName, QCriteriaOperator operator, List<Serializable> values)
|
||||
@SuppressWarnings("unchecked")
|
||||
public QFilterCriteria(String fieldName, QCriteriaOperator operator, List<? extends Serializable> values)
|
||||
{
|
||||
this.fieldName = fieldName;
|
||||
this.operator = operator;
|
||||
this.values = values == null ? new ArrayList<>() : values;
|
||||
this.values = values == null ? new ArrayList<>() : (List<Serializable>) values;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||
public QFilterCriteria(String fieldName, QCriteriaOperator operator, Collection<? extends Serializable> values)
|
||||
{
|
||||
this.fieldName = fieldName;
|
||||
this.operator = operator;
|
||||
|
||||
if(values == null)
|
||||
{
|
||||
this.values = new ArrayList<>();
|
||||
}
|
||||
else if(values instanceof List list)
|
||||
{
|
||||
this.values = list;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.values = new ArrayList<>(values);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -319,4 +347,37 @@ public class QFilterCriteria implements Serializable, Cloneable
|
||||
return (rs.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean equals(Object o)
|
||||
{
|
||||
if(this == o)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if(o == null || getClass() != o.getClass())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
QFilterCriteria that = (QFilterCriteria) o;
|
||||
return Objects.equals(fieldName, that.fieldName) && operator == that.operator && Objects.equals(values, that.values) && Objects.equals(otherFieldName, that.otherFieldName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return Objects.hash(fieldName, operator, values, otherFieldName);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
@ -323,6 +324,18 @@ public class QQueryFilter implements Serializable, Cloneable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for adding a single subFilter
|
||||
**
|
||||
*******************************************************************************/
|
||||
public QQueryFilter withSubFilter(QQueryFilter subFilter)
|
||||
{
|
||||
addSubFilter(subFilter);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -359,7 +372,7 @@ public class QQueryFilter implements Serializable, Cloneable
|
||||
rs.append(")");
|
||||
|
||||
rs.append("OrderBy[");
|
||||
for(QFilterOrderBy orderBy : orderBys)
|
||||
for(QFilterOrderBy orderBy : CollectionUtils.nonNullList(orderBys))
|
||||
{
|
||||
rs.append(orderBy).append(",");
|
||||
}
|
||||
@ -467,4 +480,36 @@ public class QQueryFilter implements Serializable, Cloneable
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean equals(Object o)
|
||||
{
|
||||
if(this == o)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if(o == null || getClass() != o.getClass())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
QQueryFilter that = (QQueryFilter) o;
|
||||
return Objects.equals(criteria, that.criteria) && Objects.equals(orderBys, that.orderBys) && booleanOperator == that.booleanOperator && Objects.equals(subFilters, that.subFilters) && Objects.equals(skip, that.skip) && Objects.equals(limit, that.limit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return Objects.hash(criteria, orderBys, booleanOperator, subFilters, skip, limit);
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
@ -68,6 +69,24 @@ public class QueryInput extends AbstractTableActionInput implements QueryOrGetIn
|
||||
private boolean includeAssociations = false;
|
||||
private Collection<String> associationNamesToInclude = null;
|
||||
|
||||
private EnumSet<QueryHint> queryHints = EnumSet.noneOf(QueryHint.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Information about the query that an application (or qqq service) may know and
|
||||
** want to tell the backend, that can help influence how the backend processes
|
||||
** query.
|
||||
**
|
||||
** For example, a query with potentially a large result set, for MySQL backend,
|
||||
** we may want to configure the result set to stream results rather than do its
|
||||
** default in-memory thing. See RDBMSQueryAction for usage.
|
||||
*******************************************************************************/
|
||||
public enum QueryHint
|
||||
{
|
||||
POTENTIALLY_LARGE_NUMBER_OF_RESULTS
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -569,4 +588,64 @@ public class QueryInput extends AbstractTableActionInput implements QueryOrGetIn
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for queryHints
|
||||
*******************************************************************************/
|
||||
public EnumSet<QueryHint> getQueryHints()
|
||||
{
|
||||
return (this.queryHints);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for queryHints
|
||||
*******************************************************************************/
|
||||
public void setQueryHints(EnumSet<QueryHint> queryHints)
|
||||
{
|
||||
this.queryHints = queryHints;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for queryHints
|
||||
*******************************************************************************/
|
||||
public QueryInput withQueryHints(EnumSet<QueryHint> queryHints)
|
||||
{
|
||||
this.queryHints = queryHints;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for queryHints
|
||||
*******************************************************************************/
|
||||
public QueryInput withQueryHint(QueryHint queryHint)
|
||||
{
|
||||
if(this.queryHints == null)
|
||||
{
|
||||
this.queryHints = EnumSet.noneOf(QueryHint.class);
|
||||
}
|
||||
this.queryHints.add(queryHint);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for queryHints
|
||||
*******************************************************************************/
|
||||
public QueryInput withoutQueryHint(QueryHint queryHint)
|
||||
{
|
||||
if(this.queryHints != null)
|
||||
{
|
||||
this.queryHints.remove(queryHint);
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user