Compare commits

..

17 Commits

Author SHA1 Message Date
815f160a94 Add defaultValue to QField annotation 2024-02-26 15:26:18 -06:00
0aba833af3 Add some logPairs to "Error executing query" 2024-02-26 15:19:51 -06:00
2522bdcf1b CE-937 set qInstance in context in manageSession (something in this story needed that) 2024-02-26 15:17:17 -06:00
e27ef7b835 Asciidoc cleanup 2024-02-26 15:16:25 -06:00
45899400ad CE-937 Add concept of nullValueBehaviorKeyName to QSecurityKeyType - so that a session can override the null-value-behavior for a table's lock (e.g., allow super-users access to null records, where normal users cannot). 2024-02-26 15:14:55 -06:00
7d25fc7390 CE-937 Remove withSecurityKeyValues that took a list and added all of them - felt a little unclear (did it replace or append to the list under the key?). now just replace the entire Map<String, List<Serializable>> yourself, or add them one-by-one. Updated tests accordingly. 2024-02-26 14:34:12 -06:00
878f374cb5 CE-937 Add concept of customizers to authentication modules; basic implementation within auth0 for customizing session/security keys 2024-02-26 14:29:45 -06:00
29a54f5293 Initial checkin - helper for memoizing Supplier-style functions 2024-02-26 14:20:28 -06:00
a035bbe18f Add getResultThrowing; remove Deprecated getResult 2024-02-26 14:20:02 -06:00
edf942a01b Update to give this test a variable amount of time for its process to finish (updated circleci executor didn't complete in 100ms...) 2024-02-22 20:23:42 -06:00
ed6319ff53 Update to localstack/platform@2.1 2024-02-22 20:12:27 -06:00
55b4e2154c Docs update - add RecordLookupHelper, RenderingWidgets, some content in Widgets, and "Supplemental Meta Data" in Tables [skip ci] 2024-02-22 11:32:20 -06:00
aabe9e315e Update log4j (to a version which seems to work better with slf4j for mongodb, javalin); set more sane log-levels for org.mongodb, org.eclipse.jetty, and io.javalin 2024-02-22 11:31:26 -06:00
557824c572 CE-781 Fix s3 delete to handle slashes it doesn't want 2024-02-21 10:48:41 -06:00
b506150842 CE-781 Updated logging in filesystem importer 2024-02-21 09:03:04 -06:00
3b8eef0f9c Add method reinitializeRecordLookupHelper, for pages after the first 2024-02-20 21:49:02 -06:00
c07d9a779a Add record no to the import-record table's label 2024-02-20 21:48:00 -06:00
45 changed files with 1574 additions and 760 deletions

View File

@ -1,7 +1,7 @@
version: 2.1
orbs:
localstack: localstack/platform@1.0
localstack: localstack/platform@2.1
commands:
store_jacoco_site:

View File

@ -26,12 +26,12 @@ include::metaData/Reports.adoc[leveloffset=+1]
include::metaData/Icons.adoc[leveloffset=+1]
include::metaData/PermissionRules.adoc[leveloffset=+1]
== 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#
@ -61,3 +61,6 @@ include::actions/InsertAction.adoc[leveloffset=+1]
== 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]

View File

@ -2,16 +2,79 @@
== Security Key Types
include::../variables.adoc[]
#TODO#
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.
#TODO#
*QSecurityKeyType Properties:*
* `name` - *String, Required* - Unique name for the security key type within the QQQ Instance.
* `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.
#TODO#

View File

@ -244,3 +244,41 @@ QQQ provides the mechanism for UI's to present and manage such scripts (e.g., th
* `scriptTypeId` - *Serializable (typically Integer), Required* - primary key value from the `"scriptType"` table in the instance, to designate the type of the Script.
* `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"))));
----

View File

@ -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&#8217;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&#8217;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">&quot;</span><span style="color:#D20">name</span><span style="color:#710">&quot;</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">&quot;</span><span style="color:#D20">birthDate</span><span style="color:#710">&quot;</span></span>, QFieldType.DATE)
<span style="color:#777">// We can produce a record label such as &quot;Darin Kelkhoff (1980-05-31)&quot; via:</span>
.withRecordLabelFormat(<span style="background-color:hsla(0,100%,50%,0.05)"><span style="color:#710">&quot;</span><span style="color:#D20">%s (%s)</span><span style="color:#710">&quot;</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">&quot;</span><span style="color:#D20">name</span><span style="color:#710">&quot;</span></span>, <span style="background-color:hsla(0,100%,50%,0.05)"><span style="color:#710">&quot;</span><span style="color:#D20">birthDate</span><span style="color:#710">&quot;</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>

View File

@ -2,16 +2,50 @@
== Widgets
include::../variables.adoc[]
#TODO#
Widgets are the most customizable UI components in QQQ.
They can be used either on App Home Screens (e.g., as Dashboard screens),
or they can be included into Record View screens.
QQQ defines several types of widgets, such as charts (pie, bar, line),
numeric displays, application-populated tables, or even fully custom HTML.
=== QWidgetMetaData
A Widget is defined in a QQQ Instance in a `*QWidgetMetaData*` object.
#TODO#
*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#
#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#

View 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)`.

View 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.

View File

@ -80,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>

View File

@ -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));

View File

@ -50,6 +50,7 @@ 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.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
@ -85,6 +86,7 @@ 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.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
@ -153,6 +155,7 @@ public class QInstanceValidator
try
{
validateBackends(qInstance);
validateAuthentication(qInstance);
validateAutomationProviders(qInstance);
validateTables(qInstance, joinGraph);
validateProcesses(qInstance);
@ -207,14 +210,23 @@ 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);
@ -383,6 +395,23 @@ public class QInstanceValidator
/*******************************************************************************
**
*******************************************************************************/
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);
}
}
}
/*******************************************************************************
**
*******************************************************************************/

View File

@ -63,6 +63,11 @@ public @interface QField
*******************************************************************************/
boolean isHidden() default false;
/*******************************************************************************
**
*******************************************************************************/
String defaultValue() default "";
/*******************************************************************************
**
*******************************************************************************/

View File

@ -1052,10 +1052,16 @@ public class QInstance
for(QSecurityKeyType securityKeyType : CollectionUtils.nonNullMap(getSecurityKeyTypes()).values())
{
rs.add(securityKeyType.getName());
if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()))
{
rs.add(securityKeyType.getAllAccessKeyName());
}
if(StringUtils.hasContent(securityKeyType.getNullValueBehaviorKeyName()))
{
rs.add(securityKeyType.getNullValueBehaviorKeyName());
}
}
return (rs);
}

View File

@ -28,6 +28,7 @@ import com.fasterxml.jackson.annotation.JsonFilter;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.TopLevelMetaDataInterface;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
/*******************************************************************************
@ -43,6 +44,7 @@ public class QAuthenticationMetaData implements TopLevelMetaDataInterface
@JsonFilter("secretsFilter")
private Map<String, String> values;
private QCodeReference customizer;
/*******************************************************************************
@ -192,4 +194,35 @@ public class QAuthenticationMetaData implements TopLevelMetaDataInterface
qInstance.setAuthentication(this);
}
/*******************************************************************************
** Getter for customizer
*******************************************************************************/
public QCodeReference getCustomizer()
{
return (this.customizer);
}
/*******************************************************************************
** Setter for customizer
*******************************************************************************/
public void setCustomizer(QCodeReference customizer)
{
this.customizer = customizer;
}
/*******************************************************************************
** Fluent setter for customizer
*******************************************************************************/
public QAuthenticationMetaData withCustomizer(QCodeReference customizer)
{
this.customizer = customizer;
return (this);
}
}

View File

@ -45,6 +45,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@ -224,6 +225,11 @@ public class QFieldMetaData implements Cloneable
{
withBehavior(fieldAnnotation.valueTooLongBehavior());
}
if(StringUtils.hasContent(fieldAnnotation.defaultValue()))
{
ValueUtils.getValueAsFieldType(this.type, fieldAnnotation.defaultValue());
}
}
}
catch(QException qe)

View File

@ -0,0 +1,75 @@
/*
* 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.metadata.security;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock.NullValueBehavior;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Utility for working with security key, nullValueBehaviors.
*******************************************************************************/
public class NullValueBehaviorUtil
{
private static final QLogger LOG = QLogger.getLogger(NullValueBehaviorUtil.class);
/*******************************************************************************
** Look at a RecordSecurityLock, but also the active session - and if the session
** has a null-value-behavior key for the lock's key-type, then allow that behavior
** to override the lock's default.
*******************************************************************************/
public static NullValueBehavior getEffectiveNullValueBehavior(RecordSecurityLock recordSecurityLock)
{
QSecurityKeyType securityKeyType = QContext.getQInstance().getSecurityKeyType(recordSecurityLock.getSecurityKeyType());
if(StringUtils.hasContent(securityKeyType.getNullValueBehaviorKeyName()))
{
List<Serializable> nullValueSessionValueList = QContext.getQSession().getSecurityKeyValues(securityKeyType.getNullValueBehaviorKeyName());
if(CollectionUtils.nullSafeHasContents(nullValueSessionValueList))
{
NullValueBehavior nullValueBehavior = NullValueBehavior.tryToGetFromString(ValueUtils.getValueAsString(nullValueSessionValueList.get(0)));
if(nullValueBehavior != null)
{
return nullValueBehavior;
}
else
{
LOG.info("Unexpected value in nullValueBehavior security key. Will use recordSecurityLock's nullValueBehavior",
logPair("nullValueBehaviorKeyName", securityKeyType.getNullValueBehaviorKeyName()),
logPair("value", nullValueSessionValueList.get(0)));
}
}
}
return (recordSecurityLock.getNullValueBehavior());
}
}

View File

@ -34,6 +34,7 @@ public class QSecurityKeyType implements TopLevelMetaDataInterface
{
private String name;
private String allAccessKeyName;
private String nullValueBehaviorKeyName;
private String possibleValueSourceName;
@ -149,4 +150,35 @@ public class QSecurityKeyType implements TopLevelMetaDataInterface
qInstance.addSecurityKeyType(this);
}
/*******************************************************************************
** Getter for nullValueBehaviorKeyName
*******************************************************************************/
public String getNullValueBehaviorKeyName()
{
return (this.nullValueBehaviorKeyName);
}
/*******************************************************************************
** Setter for nullValueBehaviorKeyName
*******************************************************************************/
public void setNullValueBehaviorKeyName(String nullValueBehaviorKeyName)
{
this.nullValueBehaviorKeyName = nullValueBehaviorKeyName;
}
/*******************************************************************************
** Fluent setter for nullValueBehaviorKeyName
*******************************************************************************/
public QSecurityKeyType withNullValueBehaviorKeyName(String nullValueBehaviorKeyName)
{
this.nullValueBehaviorKeyName = nullValueBehaviorKeyName;
return (this);
}
}

View File

@ -22,7 +22,9 @@
package com.kingsrook.qqq.backend.core.model.metadata.security;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/*******************************************************************************
@ -67,7 +69,34 @@ public class RecordSecurityLock
{
ALLOW,
ALLOW_WRITE_ONLY, // not common - but see Audit, where you can do a thing that inserts them into a generic table, even though you can't later read them yourself...
DENY
DENY;
////////////////////////////////////////////////////////////////////
// for use in tryToGetFromString, where we'll lowercase the input //
////////////////////////////////////////////////////////////////////
private static final Map<String, NullValueBehavior> stringMapping = new HashMap<>();
static
{
stringMapping.put("allow", ALLOW);
stringMapping.put("allow_write_only", ALLOW_WRITE_ONLY);
stringMapping.put("allowwriteonly", ALLOW_WRITE_ONLY);
stringMapping.put("deny", DENY);
}
/*******************************************************************************
**
*******************************************************************************/
public static NullValueBehavior tryToGetFromString(String string)
{
if(string == null)
{
return (null);
}
return stringMapping.get(string.toLowerCase());
}
}

View File

@ -48,6 +48,7 @@ public class QSession implements Serializable
private String uuid;
private Set<String> permissions;
private Map<String, List<Serializable>> securityKeyValues;
private Map<String, Serializable> backendVariants;
@ -337,15 +338,10 @@ public class QSession implements Serializable
/*******************************************************************************
** Fluent setter for securityKeyValues - add a list of values for 1 key
** Fluent setter for securityKeyValues - add 1 value for 1 key.
*******************************************************************************/
public QSession withSecurityKeyValues(String keyName, List<Serializable> values)
public QSession withSecurityKeyValue(String keyName, Serializable value)
{
if(values == null)
{
return (this);
}
if(securityKeyValues == null)
{
securityKeyValues = new HashMap<>();
@ -355,12 +351,15 @@ public class QSession implements Serializable
try
{
securityKeyValues.get(keyName).addAll(values);
securityKeyValues.get(keyName).add(value);
}
catch(UnsupportedOperationException uoe)
{
/////////////////////
// grr, List.of... //
/////////////////////
securityKeyValues.put(keyName, new ArrayList<>(securityKeyValues.get(keyName)));
securityKeyValues.get(keyName).addAll(values);
securityKeyValues.get(keyName).add(value);
}
return (this);
@ -368,16 +367,6 @@ public class QSession implements Serializable
/*******************************************************************************
** Fluent setter for securityKeyValues - add 1 value for 1 key.
*******************************************************************************/
public QSession withSecurityKeyValue(String keyName, Serializable value)
{
return (withSecurityKeyValues(keyName, List.of(value)));
}
/*******************************************************************************
** Clear the map of security key values in the session.
*******************************************************************************/

View File

@ -0,0 +1,55 @@
/*
* 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.modules.authentication;
import java.io.Serializable;
import java.util.Map;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Interface for customizing behavior of an Authentication module.
*******************************************************************************/
public interface QAuthenticationModuleCustomizerInterface
{
/*******************************************************************************
**
*******************************************************************************/
default void addSecurityKeyValueToSession(QSession session, String keyName, Serializable value)
{
session.withSecurityKeyValue(keyName, value);
}
/*******************************************************************************
**
*******************************************************************************/
default void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
{
//////////
// noop //
//////////
}
}

View File

@ -47,6 +47,7 @@ import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.net.Response;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
@ -71,6 +72,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.authentication.Auth0Authent
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSession;
import com.kingsrook.qqq.backend.core.state.InMemoryStateProvider;
@ -80,6 +82,7 @@ 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.ValueUtils;
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
@ -129,6 +132,19 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
static final String EXPIRED_TOKEN_ERROR = "Token has expired";
static final String INVALID_TOKEN_ERROR = "An invalid token was provided";
private Auth0AuthenticationMetaData metaData;
//////////////////////////////////////////////////////////////////////////////////
// do not use this var directly - rather - always call the getCustomizer method //
//////////////////////////////////////////////////////////////////////////////////
private QAuthenticationModuleCustomizerInterface _customizer = null;
private boolean customizerHasBeenRequested = false;
private static boolean mayMemoize = true;
private static final Memoization<String, String> getAccessTokenFromSessionUUIDMemoization = new Memoization<String, String>()
.withTimeout(Duration.of(1, ChronoUnit.MINUTES))
.withMaxSize(1000);
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this is how we allow the actions within this class to work without themselves having a logged-in user. //
@ -165,9 +181,12 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
@Override
public QSession createSession(QInstance qInstance, Map<String, String> context) throws QAuthenticationException
{
QInstance contextInstanceBefore = QContext.getQInstance();
try
{
Auth0AuthenticationMetaData metaData = (Auth0AuthenticationMetaData) qInstance.getAuthentication();
QContext.setQInstance(qInstance);
this.metaData = (Auth0AuthenticationMetaData) qInstance.getAuthentication();
String accessToken = null;
if(CollectionUtils.containsKeyWithNonNullValue(context, SESSION_UUID_KEY))
@ -252,16 +271,6 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
}
}
/* todo confirm this is deprecated
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// check to see if the session id is a UUID, if so, that means we need to look up the 'actual' token in the access_token table //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(accessToken != null && StringUtils.isUUID(accessToken))
{
accessToken = lookupActualAccessToken(metaData, accessToken);
}
*/
///////////////////////////////////////////
// if token wasn't found by now, give up //
///////////////////////////////////////////
@ -311,6 +320,10 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
LOG.error(message, e);
throw (new QAuthenticationException(message));
}
finally
{
QContext.setQInstance(contextInstanceBefore);
}
}
@ -346,26 +359,36 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
*******************************************************************************/
private QSession buildAndValidateSession(QInstance qInstance, String accessToken) throws JwkException
{
QSession qSession = buildQSessionFromToken(accessToken, qInstance);
if(isSessionValid(qInstance, qSession))
QSession beforeSession = QContext.getQSession();
try
{
QContext.setQSession(getChickenAndEggSession());
QSession qSession = buildQSessionFromToken(accessToken, qInstance);
if(isSessionValid(qInstance, qSession))
{
return (qSession);
}
//////////////////////////////////////////////////////////////////////////////////////////
// if we make it here it means we have never validated this token or it has been a long //
// enough duration so we need to re-verify the token //
//////////////////////////////////////////////////////////////////////////////////////////
qSession = revalidateTokenAndBuildSession(qInstance, accessToken);
/////////////////////////////////////////////////////////////////////
// put now into state so we don't check until next interval passes //
/////////////////////////////////////////////////////////////////////
StateProviderInterface spi = getStateProvider();
SimpleStateKey<String> key = new SimpleStateKey<>(qSession.getIdReference());
spi.put(key, Instant.now());
return (qSession);
}
//////////////////////////////////////////////////////////////////////////////////////////
// if we make it here it means we have never validated this token or it has been a long //
// enough duration so we need to re-verify the token //
//////////////////////////////////////////////////////////////////////////////////////////
qSession = revalidateTokenAndBuildSession(qInstance, accessToken);
/////////////////////////////////////////////////////////////////////
// put now into state so we don't check until next interval passes //
/////////////////////////////////////////////////////////////////////
StateProviderInterface spi = getStateProvider();
SimpleStateKey<String> key = new SimpleStateKey<>(qSession.getIdReference());
spi.put(key, Instant.now());
return (qSession);
finally
{
QContext.setQSession(beforeSession);
}
}
@ -509,7 +532,7 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
*******************************************************************************/
private void validateToken(QInstance qInstance, String tokenString) throws JwkException
{
Auth0AuthenticationMetaData metaData = (Auth0AuthenticationMetaData) qInstance.getAuthentication();
this.metaData = (Auth0AuthenticationMetaData) qInstance.getAuthentication();
DecodedJWT idToken = JWT.decode(tokenString);
JwkProvider provider = new UrlJwkProvider(metaData.getBaseUrl());
@ -567,10 +590,10 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
qSession.setIdReference(accessToken);
qSession.setUser(qUser);
/////////////////////////////////////////////////////////////////////
// put the user id reference in security key value for usierId key //
/////////////////////////////////////////////////////////////////////
qSession.withSecurityKeyValue("userId", qUser.getIdReference());
////////////////////////////////////////////////////////////////////
// put the user id reference in security key value for userId key //
////////////////////////////////////////////////////////////////////
addSecurityKeyValueToSession(qSession, "userId", qUser.getIdReference());
/////////////////////////////////////////////////
// set permissions in the session from the JWT //
@ -581,12 +604,43 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
// set security keys in the session from the JWT //
///////////////////////////////////////////////////
setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
//////////////////////////////////////////////////////////////
// allow customizer to do custom things here, if so desired //
//////////////////////////////////////////////////////////////
if(getCustomizer() != null)
{
getCustomizer().customizeSession(qInstance, qSession, Map.of("jwtPayloadJsonObject", payload));
}
return (qSession);
}
/*******************************************************************************
**
*******************************************************************************/
private void addSecurityKeyValueToSession(QSession qSession, String key, String value)
{
if(getCustomizer() == null)
{
///////////////////////////////////////////////////
// if there's no customizer, do the direct thing //
///////////////////////////////////////////////////
qSession.withSecurityKeyValue(key, value);
}
else
{
///////////////////////////////////////////////////////////
// else have the customizer add the value to the session //
///////////////////////////////////////////////////////////
getCustomizer().addSecurityKeyValueToSession(qSession, key, value);
}
}
/*******************************************************************************
**
*******************************************************************************/
@ -616,7 +670,7 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
/*******************************************************************************
**
*******************************************************************************/
static void setSecurityKeysInSessionFromJwtPayload(QInstance qInstance, JSONObject payload, QSession qSession)
void setSecurityKeysInSessionFromJwtPayload(QInstance qInstance, JSONObject payload, QSession qSession)
{
for(String payloadKey : List.of("com.kingsrook.qqq.app_metadata", "com.kingsrook.qqq.client_metadata"))
{
@ -668,7 +722,7 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
/*******************************************************************************
**
*******************************************************************************/
private static void setSecurityKeyValuesFromToken(Set<String> allowedSecurityKeyNames, QSession qSession, String securityKeyName, JSONObject securityKeyValues, String jsonKey)
private void setSecurityKeyValuesFromToken(Set<String> allowedSecurityKeyNames, QSession qSession, String securityKeyName, JSONObject securityKeyValues, String jsonKey)
{
if(!allowedSecurityKeyNames.contains(securityKeyName))
{
@ -686,7 +740,7 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
Object optValue = valueArray.opt(i);
if(optValue != null)
{
qSession.withSecurityKeyValue(securityKeyName, ValueUtils.getValueAsString(optValue));
addSecurityKeyValueToSession(qSession, securityKeyName, ValueUtils.getValueAsString(optValue));
}
}
}
@ -697,7 +751,7 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
{
for(String v : values.split(","))
{
qSession.withSecurityKeyValue(securityKeyName, v);
addSecurityKeyValueToSession(qSession, securityKeyName, v);
}
}
}
@ -815,6 +869,25 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
**
*******************************************************************************/
private String getAccessTokenFromSessionUUID(Auth0AuthenticationMetaData metaData, String sessionUUID) throws QAuthenticationException
{
if(mayMemoize)
{
return getAccessTokenFromSessionUUIDMemoization.getResultThrowing(sessionUUID, (String x) ->
doGetAccessTokenFromSessionUUID(sessionUUID)
).orElse(null);
}
else
{
return (doGetAccessTokenFromSessionUUID(sessionUUID));
}
}
/*******************************************************************************
**
*******************************************************************************/
private String doGetAccessTokenFromSessionUUID(String sessionUUID) throws QAuthenticationException
{
String accessToken = null;
QSession beforeSession = QContext.getQSession();
@ -982,4 +1055,39 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface
}
}
/*******************************************************************************
**
*******************************************************************************/
private QAuthenticationModuleCustomizerInterface getCustomizer()
{
try
{
if(!customizerHasBeenRequested)
{
customizerHasBeenRequested = true;
if(this.metaData == null)
{
this.metaData = (Auth0AuthenticationMetaData) QContext.getQInstance().getAuthentication();
}
if(this.metaData.getCustomizer() != null)
{
this._customizer = QCodeLoader.getAdHoc(QAuthenticationModuleCustomizerInterface.class, this.metaData.getCustomizer());
}
}
return (this._customizer);
}
catch(Exception e)
{
////////////////////////
// should this throw? //
////////////////////////
LOG.warn("Error getting customizer.", e);
return (null);
}
}
}

View File

@ -252,6 +252,10 @@ public abstract class AbstractTableSyncTransformStep extends AbstractTransformSt
{
initializeRecordLookupHelper(runBackendStepInput, runBackendStepInput.getRecords());
}
else
{
reinitializeRecordLookupHelper(runBackendStepInput, runBackendStepInput.getRecords());
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// query to see if we already have those records in the destination (to determine insert/update) //
@ -468,6 +472,18 @@ public abstract class AbstractTableSyncTransformStep extends AbstractTransformSt
/*******************************************************************************
** for pages after the first, possibly load more records in the lookup helper.
*******************************************************************************/
protected void reinitializeRecordLookupHelper(RunBackendStepInput runBackendStepInput, List<QRecord> sourceRecordList) throws QException
{
////////////////////////
// noop in base class //
////////////////////////
}
/*******************************************************************************
** Let the subclass "easily" add an audit to be inserted on the Execute step.
*******************************************************************************/

View File

@ -0,0 +1,78 @@
/*
* 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.utils.memoization;
/*******************************************************************************
** Meant to serve as the key in a Memoization where we actually don't need a parameter.
** e.g., to memoize a function like: public Object f();
*******************************************************************************/
public class AnyKey
{
private static AnyKey anyKey = null;
/*******************************************************************************
** Singleton constructor
*******************************************************************************/
private AnyKey()
{
}
/*******************************************************************************
** Singleton accessor
*******************************************************************************/
public static AnyKey getInstance()
{
if(anyKey == null)
{
anyKey = new AnyKey();
}
return (anyKey);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean equals(Object obj)
{
return (obj == this);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public int hashCode()
{
return 1;
}
}

View File

@ -58,42 +58,15 @@ public class Memoization<K, V>
/*******************************************************************************
** Get the memoized Value for a given input Key.
**
** But note, this looks the same to the caller, whether the key just wasn't in
** the internal map (e.g., had never been looked up), or if it was previously looked
** up, and that returned null. In either case, the optional will be empty.
**
** See getMemoizedResult for where we can tell the difference (and we would
** generally want to call that.
*******************************************************************************/
@Deprecated
public Optional<V> getResult(K key)
{
MemoizedResult<V> result = map.get(key);
if(result != null)
{
if(result.getTime().isAfter(Instant.now().minus(timeout)))
{
return (Optional.ofNullable(result.getResult()));
}
}
return (Optional.empty());
}
/*******************************************************************************
** Get the memoized Value for a given input Key - computing it if it wasn't previously
** memoized (or expired).
**
** In here, if the optional is empty, it means the value is null (whether that
** If the returned Optional is empty, it means the value is null (whether that
** came form memoization, or from the lookupFunction, you don't care - the answer
** is null).
*******************************************************************************/
public Optional<V> getResult(K key, UnsafeFunction<K, V, ?> lookupFunction)
public <E extends Exception> Optional<V> getResultThrowing(K key, UnsafeFunction<K, V, E> lookupFunction) throws E
{
MemoizedResult<V> result = map.get(key);
if(result != null)
@ -111,12 +84,33 @@ public class Memoization<K, V>
/////////////////////////////////////////////////////////////////////////////////////////////
// ok - either we never memoized this key, or it's expired, so, apply the lookup function, //
// store the result, and then return the value (in an Optional.ofNullable) //
// and if the lookup function throws - then we let it throw. //
/////////////////////////////////////////////////////////////////////////////////////////////
V value = lookupFunction.apply(key);
storeResult(key, value);
return (Optional.ofNullable(value));
}
/*******************************************************************************
** Get the memoized Value for a given input Key - computing it if it wasn't previously
** memoized (or expired).
**
** If a null value was memoized, the resulting optional here will be empty.
**
** If the lookup function throws, then a null value will be memoized and an empty
** Optional will be returned.
**
** In here, if the optional is empty, it means the value is null (whether that
** came form memoization, or from the lookupFunction, you don't care - the answer
** is null).
*******************************************************************************/
public Optional<V> getResult(K key, UnsafeFunction<K, V, ?> lookupFunction)
{
try
{
V value = lookupFunction.apply(key);
storeResult(key, value);
return (Optional.ofNullable(value));
return getResultThrowing(key, lookupFunction);
}
catch(Exception e)
{
@ -131,8 +125,8 @@ public class Memoization<K, V>
/*******************************************************************************
** Get a memoized result, optionally containing a Value, for a given input Key.
**
** In this method (contrasted with getResult), if the returned Optional is empty,
** it means that we haven't ever looked up or memoized the key (or it's expired).
** If the returned Optional is empty, it means that we haven't ever looked up
** or memoized the key (or it's expired).
**
** If the returned Optional is not empty, then it means we've memoized something
** (and it's not expired) - so if the Value from the MemoizedResult is null,

View File

@ -20,6 +20,12 @@
<Loggers>
<Logger name="org.apache.log4j.xml" additivity="false">
</Logger>
<Logger name="org.mongodb.driver" level="WARN">
</Logger>
<Logger name="org.eclipse.jetty" level="INFO">
</Logger>
<Logger name="io.javalin" level="INFO">
</Logger>
<Root level="all">
<AppenderRef ref="SystemOutAppender"/>
<AppenderRef ref="SyslogAppender"/>

View File

@ -328,7 +328,7 @@ class InsertActionTest extends BaseTest
// insert an order and lineItem with storeId=2 - then, reset our session to only have storeId=1 in it - and try to insert an order-line referencing that order. //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
QContext.getQSession().withSecurityKeyValues(new HashMap<>());
QContext.getQSession().withSecurityKeyValues(TestUtils.SECURITY_KEY_TYPE_STORE, List.of(2));
QContext.getQSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE, 2);
InsertInput insertOrderInput = new InsertInput();
insertOrderInput.setTableName(TestUtils.TABLE_NAME_ORDER);
insertOrderInput.setRecords(List.of(new QRecord().withValue("id", 42).withValue("storeId", 2)));
@ -342,7 +342,7 @@ class InsertActionTest extends BaseTest
assertEquals(4200, insertLineItemOutput.getRecords().get(0).getValueInteger("id"));
QContext.getQSession().withSecurityKeyValues(new HashMap<>());
QContext.getQSession().withSecurityKeyValues(TestUtils.SECURITY_KEY_TYPE_STORE, List.of(1));
QContext.getQSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE, 1);
InsertInput insertLineItemExtrinsicInput = new InsertInput();
insertLineItemExtrinsicInput.setTableName(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC);
insertLineItemExtrinsicInput.setRecords(List.of(new QRecord().withValue("lineItemId", 4200).withValue("key", "kidsCanCallYou").withValue("value", "HoJu")));
@ -352,7 +352,7 @@ class InsertActionTest extends BaseTest
{
QContext.getQSession().withSecurityKeyValues(new HashMap<>());
QContext.getQSession().withSecurityKeyValues(TestUtils.SECURITY_KEY_TYPE_STORE, List.of(1));
QContext.getQSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE, 1);
InsertInput insertOrderInput = new InsertInput();
insertOrderInput.setTableName(TestUtils.TABLE_NAME_ORDER);
insertOrderInput.setRecords(List.of(new QRecord().withValue("id", 47).withValue("storeId", 1)));
@ -450,7 +450,7 @@ class InsertActionTest extends BaseTest
// insert an order with storeId=2 - then, reset our session to only have storeId=1 in it - and try to insert an order-line referencing that order. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
QContext.getQSession().withSecurityKeyValues(new HashMap<>());
QContext.getQSession().withSecurityKeyValues(TestUtils.SECURITY_KEY_TYPE_STORE, List.of(2));
QContext.getQSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE, 2);
InsertInput insertOrderInput = new InsertInput();
insertOrderInput.setTableName(TestUtils.TABLE_NAME_ORDER);
insertOrderInput.setRecords(List.of(new QRecord().withValue("id", 42).withValue("storeId", 2)));
@ -458,7 +458,7 @@ class InsertActionTest extends BaseTest
assertEquals(42, insertOrderOutput.getRecords().get(0).getValueInteger("id"));
QContext.getQSession().withSecurityKeyValues(new HashMap<>());
QContext.getQSession().withSecurityKeyValues(TestUtils.SECURITY_KEY_TYPE_STORE, List.of(1));
QContext.getQSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE, 1);
InsertInput insertLineItemInput = new InsertInput();
insertLineItemInput.setTableName(TestUtils.TABLE_NAME_LINE_ITEM);
insertLineItemInput.setRecords(List.of(new QRecord().withValue("orderId", 42).withValue("sku", "BASIC1").withValue("quantity", 1)));
@ -468,7 +468,7 @@ class InsertActionTest extends BaseTest
{
QContext.getQSession().withSecurityKeyValues(new HashMap<>());
QContext.getQSession().withSecurityKeyValues(TestUtils.SECURITY_KEY_TYPE_STORE, List.of(1));
QContext.getQSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE, 1);
InsertInput insertOrderInput = new InsertInput();
insertOrderInput.setTableName(TestUtils.TABLE_NAME_ORDER);
insertOrderInput.setRecords(List.of(new QRecord().withValue("id", 47).withValue("storeId", 1)));

View File

@ -75,6 +75,7 @@ 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;
import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.TableAutomationAction;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.LoadViaDeleteStep;
@ -1622,19 +1623,30 @@ class QInstanceValidatorTest extends BaseTest
assertValidationFailureReasons((qInstance ->
{
qInstance.addSecurityKeyType(new QSecurityKeyType().withName("clientId").withAllAccessKeyName("clientId"));
}), "More than one SecurityKeyType with name (or allAccessKeyName) of: clientId");
}), "More than one SecurityKeyType with name (or allAccessKeyName or nullValueBehaviorKeyName) of: clientId");
assertValidationFailureReasonsAllowingExtraReasons((qInstance ->
{
qInstance.addSecurityKeyType(new QSecurityKeyType().withName("clientId").withAllAccessKeyName("clientId").withNullValueBehaviorKeyName("clientId"));
}), "More than one SecurityKeyType with name (or allAccessKeyName or nullValueBehaviorKeyName) of: clientId");
assertValidationFailureReasons((qInstance ->
{
qInstance.addSecurityKeyType(new QSecurityKeyType().withName("clientId").withAllAccessKeyName("allAccess"));
qInstance.addSecurityKeyType(new QSecurityKeyType().withName("warehouseId").withAllAccessKeyName("allAccess"));
}), "More than one SecurityKeyType with name (or allAccessKeyName) of: allAccess");
}), "More than one SecurityKeyType with name (or allAccessKeyName or nullValueBehaviorKeyName) of: allAccess");
assertValidationFailureReasons((qInstance ->
{
qInstance.addSecurityKeyType(new QSecurityKeyType().withName("clientId").withNullValueBehaviorKeyName("nullBehavior"));
qInstance.addSecurityKeyType(new QSecurityKeyType().withName("warehouseId").withNullValueBehaviorKeyName("nullBehavior"));
}), "More than one SecurityKeyType with name (or allAccessKeyName or nullValueBehaviorKeyName) of: nullBehavior");
assertValidationFailureReasons((qInstance ->
{
qInstance.addSecurityKeyType(new QSecurityKeyType().withName("clientId").withAllAccessKeyName("allAccess"));
qInstance.addSecurityKeyType(new QSecurityKeyType().withName("allAccess"));
}), "More than one SecurityKeyType with name (or allAccessKeyName) of: allAccess");
}), "More than one SecurityKeyType with name (or allAccessKeyName or nullValueBehaviorKeyName) of: allAccess");
assertValidationFailureReasons((qInstance -> qInstance.addSecurityKeyType(new QSecurityKeyType().withName("clientId").withPossibleValueSourceName("nonPVS"))),
"Unrecognized possibleValueSourceName in securityKeyType");
@ -1813,6 +1825,19 @@ class QInstanceValidatorTest extends BaseTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testAuthenticationCustomizer()
{
assertValidationSuccess((qInstance -> qInstance.getAuthentication().withCustomizer(null)));
assertValidationSuccess((qInstance -> qInstance.getAuthentication().withCustomizer(new QCodeReference(ValidAuthCustomizer.class))));
assertValidationFailureReasons((qInstance -> qInstance.getAuthentication().withCustomizer(new QCodeReference(ArrayList.class))), "not of the expected type");
}
/*******************************************************************************
**
*******************************************************************************/
@ -1987,5 +2012,13 @@ class QInstanceValidatorTest extends BaseTest
return null;
}
}
/*******************************************************************************
**
*******************************************************************************/
public static class ValidAuthCustomizer implements QAuthenticationModuleCustomizerInterface {}
}

View File

@ -0,0 +1,84 @@
/*
* 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.metadata.security;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.BaseTest;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.utils.TestUtils;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/*******************************************************************************
** Unit test for NullValueBehaviorUtil
*******************************************************************************/
class NullValueBehaviorUtilTest extends BaseTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void test()
{
////////////////////////////////////////////////////////////////////////////////////////
// if session doesn't have a null-value key, then always get back the lock's behavior //
////////////////////////////////////////////////////////////////////////////////////////
for(RecordSecurityLock.NullValueBehavior lockNullValueBehavior : RecordSecurityLock.NullValueBehavior.values())
{
assertEquals(lockNullValueBehavior, NullValueBehaviorUtil.getEffectiveNullValueBehavior(new RecordSecurityLock()
.withSecurityKeyType(TestUtils.SECURITY_KEY_TYPE_STORE)
.withNullValueBehavior(lockNullValueBehavior)));
}
/////////////////////////////////////////////////////////////////////
// if session DOES have a null-value key, then always gete it back //
/////////////////////////////////////////////////////////////////////
for(RecordSecurityLock.NullValueBehavior sessionNullValueBehavior : RecordSecurityLock.NullValueBehavior.values())
{
QContext.getQSession().withSecurityKeyValues(Map.of(TestUtils.SECURITY_KEY_TYPE_STORE_NULL_BEHAVIOR, List.of(sessionNullValueBehavior.toString())));
for(RecordSecurityLock.NullValueBehavior lockNullValueBehavior : RecordSecurityLock.NullValueBehavior.values())
{
assertEquals(sessionNullValueBehavior, NullValueBehaviorUtil.getEffectiveNullValueBehavior(new RecordSecurityLock()
.withSecurityKeyType(TestUtils.SECURITY_KEY_TYPE_STORE)
.withNullValueBehavior(lockNullValueBehavior)));
}
}
////////////////////////////////////////////////////////////////////
// if session has an invalid key, always get back lock's behavior //
////////////////////////////////////////////////////////////////////
for(RecordSecurityLock.NullValueBehavior lockNullValueBehavior : RecordSecurityLock.NullValueBehavior.values())
{
QContext.getQSession().withSecurityKeyValues(Map.of(TestUtils.SECURITY_KEY_TYPE_STORE_NULL_BEHAVIOR, List.of("xyz")));
assertEquals(lockNullValueBehavior, NullValueBehaviorUtil.getEffectiveNullValueBehavior(new RecordSecurityLock()
.withSecurityKeyType(TestUtils.SECURITY_KEY_TYPE_STORE)
.withNullValueBehavior(lockNullValueBehavior)));
}
}
}

View File

@ -52,7 +52,8 @@ class QSessionTest extends BaseTest
assertEquals(List.of(1701), session.getSecurityKeyValues("warehouseId"));
assertEquals(List.of(), session.getSecurityKeyValues("tenantId"));
session.withSecurityKeyValues("clientId", List.of(256, 512));
session.withSecurityKeyValue("clientId", 256);
session.withSecurityKeyValue("clientId", 512);
for(int i : List.of(42, 47, 256, 512))
{
assertTrue(session.hasSecurityKeyValue("clientId", i), "Should contain: " + i);

View File

@ -22,6 +22,7 @@
package com.kingsrook.qqq.backend.core.modules.authentication.implementations;
import java.io.Serializable;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
@ -36,7 +37,9 @@ import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.Auth0AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.backend.core.state.InMemoryStateProvider;
import com.kingsrook.qqq.backend.core.state.SimpleStateKey;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
@ -354,7 +357,7 @@ public class Auth0AuthenticationModuleTest extends BaseTest
"iat": 1673379451
}
""");
Auth0AuthenticationModule.setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
new Auth0AuthenticationModule().setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
assertEquals(List.of("true"), qSession.getSecurityKeyValues("storeAllAccess"));
/////////////////////////////////////////////
@ -373,7 +376,7 @@ public class Auth0AuthenticationModuleTest extends BaseTest
"iat": 1673379451
}
""");
Auth0AuthenticationModule.setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
new Auth0AuthenticationModule().setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
assertEquals(List.of("2"), qSession.getSecurityKeyValues("store"));
//////////////////////////
@ -394,7 +397,7 @@ public class Auth0AuthenticationModuleTest extends BaseTest
"iat": 1673379451
}
""");
Auth0AuthenticationModule.setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
new Auth0AuthenticationModule().setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
assertEquals(List.of("3", "4", "5"), qSession.getSecurityKeyValues("store"));
assertEquals(List.of("internal"), qSession.getSecurityKeyValues("internalOrExternal"));
@ -409,7 +412,7 @@ public class Auth0AuthenticationModuleTest extends BaseTest
"iat": 1673379451
}
""");
Auth0AuthenticationModule.setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
new Auth0AuthenticationModule().setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
assertTrue(CollectionUtils.nullSafeIsEmpty(qSession.getSecurityKeyValues()));
/////////////////////////////////////////////////////
@ -428,7 +431,7 @@ public class Auth0AuthenticationModuleTest extends BaseTest
"iat": 1673379451
}
""");
Auth0AuthenticationModule.setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
new Auth0AuthenticationModule().setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
assertTrue(CollectionUtils.nullSafeIsEmpty(qSession.getSecurityKeyValues()));
}
@ -491,4 +494,73 @@ public class Auth0AuthenticationModuleTest extends BaseTest
assertEquals("123456******", maskForLog("1234567890"));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCustomizer()
{
QInstance qInstance = QContext.getQInstance();
qInstance.setAuthentication(new Auth0AuthenticationMetaData()
.withCustomizer(new QCodeReference(Customizer.class)));
{
/////////////////////////////////////////////////////////////////
// baseline case - value in json becomes value in security key //
/////////////////////////////////////////////////////////////////
QSession qSession = new QSession();
JSONObject payload = new JSONObject("""
{
"com.kingsrook.qqq.app_metadata": {
"securityKeyValues": {
"store": "1"
}
}
}
""");
new Auth0AuthenticationModule().setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
assertEquals(List.of("1"), qSession.getSecurityKeyValues("store"));
}
{
QSession qSession = new QSession();
JSONObject payload = new JSONObject("""
{
"com.kingsrook.qqq.app_metadata": {
"securityKeyValues": {
"store": "oddDigits"
}
}
}
""");
new Auth0AuthenticationModule().setSecurityKeysInSessionFromJwtPayload(qInstance, payload, qSession);
assertEquals(List.of("1", "3", "5", "7", "9"), qSession.getSecurityKeyValues("store"));
}
}
/*******************************************************************************
**
*******************************************************************************/
public static class Customizer implements QAuthenticationModuleCustomizerInterface
{
@Override
public void addSecurityKeyValueToSession(QSession session, String keyName, Serializable value)
{
if("oddDigits".equals(value))
{
for(String oddValue : List.of("1", "3", "5", "7", "9"))
{
QAuthenticationModuleCustomizerInterface.super.addSecurityKeyValueToSession(session, keyName, oddValue);
}
}
else
{
QAuthenticationModuleCustomizerInterface.super.addSecurityKeyValueToSession(session, keyName, value);
}
}
}
}

View File

@ -173,6 +173,7 @@ public class TestUtils
public static final String SECURITY_KEY_TYPE_STORE = "store";
public static final String SECURITY_KEY_TYPE_STORE_ALL_ACCESS = "storeAllAccess";
public static final String SECURITY_KEY_TYPE_STORE_NULL_BEHAVIOR = "storeNullBehavior";
public static final String SECURITY_KEY_TYPE_INTERNAL_OR_EXTERNAL = "internalOrExternal";
@ -471,6 +472,7 @@ public class TestUtils
return new QSecurityKeyType()
.withName(SECURITY_KEY_TYPE_STORE)
.withAllAccessKeyName(SECURITY_KEY_TYPE_STORE_ALL_ACCESS)
.withNullValueBehaviorKeyName(SECURITY_KEY_TYPE_STORE_NULL_BEHAVIOR)
.withPossibleValueSourceName(POSSIBLE_VALUE_SOURCE_STORE);
}
@ -1255,7 +1257,6 @@ public class TestUtils
/*******************************************************************************
**
*******************************************************************************/

View File

@ -39,6 +39,7 @@ import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeFunction;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@ -61,9 +62,9 @@ class MemoizationTest extends BaseTest
memoization.setMaxSize(3);
memoization.setTimeout(Duration.ofMillis(100));
assertThat(memoization.getResult("one")).isEmpty();
assertThat(memoization.getMemoizedResult("one")).isEmpty();
memoization.storeResult("one", 1);
assertThat(memoization.getResult("one")).isPresent().get().isEqualTo(1);
assertThat(memoization.getMemoizedResult("one")).isPresent().get().extracting("result").isEqualTo(1);
////////////////////////////////////////////////////
// store 3 more results - this should force 1 out //
@ -71,22 +72,22 @@ class MemoizationTest extends BaseTest
memoization.storeResult("two", 2);
memoization.storeResult("three", 3);
memoization.storeResult("four", 4);
assertThat(memoization.getResult("one")).isEmpty();
assertThat(memoization.getMemoizedResult("one")).isEmpty();
//////////////////////////////////
// make sure others are present //
//////////////////////////////////
assertThat(memoization.getResult("two")).isPresent().get().isEqualTo(2);
assertThat(memoization.getResult("three")).isPresent().get().isEqualTo(3);
assertThat(memoization.getResult("four")).isPresent().get().isEqualTo(4);
assertThat(memoization.getMemoizedResult("two")).isPresent().get().extracting("result").isEqualTo(2);
assertThat(memoization.getMemoizedResult("three")).isPresent().get().extracting("result").isEqualTo(3);
assertThat(memoization.getMemoizedResult("four")).isPresent().get().extracting("result").isEqualTo(4);
/////////////////////////////////////////////////////////////
// wait more than the timeout, then make sure all are gone //
/////////////////////////////////////////////////////////////
SleepUtils.sleep(150, TimeUnit.MILLISECONDS);
assertThat(memoization.getResult("two")).isEmpty();
assertThat(memoization.getResult("three")).isEmpty();
assertThat(memoization.getResult("four")).isEmpty();
assertThat(memoization.getMemoizedResult("two")).isEmpty();
assertThat(memoization.getMemoizedResult("three")).isEmpty();
assertThat(memoization.getMemoizedResult("four")).isEmpty();
}
@ -100,24 +101,17 @@ class MemoizationTest extends BaseTest
Memoization<String, Integer> memoization = new Memoization<>();
memoization.storeResult("null", null);
///////////////////////////////////////////////////////////////////////////////////////////
// note - we can't tell a stored null apart from a non-stored value by calling getResult //
///////////////////////////////////////////////////////////////////////////////////////////
Optional<Integer> optionalNull = memoization.getResult("null");
assertNotNull(optionalNull);
assertTrue(optionalNull.isEmpty());
////////////////////////////////////////////
// instead, we must use getMemoizedResult //
////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////////
// the memoizedResult should never be null, and should be present if we memoized/stored a null value //
///////////////////////////////////////////////////////////////////////////////////////////////////////
Optional<MemoizedResult<Integer>> optionalMemoizedResult = memoization.getMemoizedResult("null");
assertNotNull(optionalMemoizedResult);
assertTrue(optionalMemoizedResult.isPresent());
assertNull(optionalMemoizedResult.get().getResult());
/////////////////////////////////////////////////////////////////
// make sure getMemoizedResult returns empty for an un-set key //
/////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
// make sure getMemoizedResult returns non-null and empty for an un-set key //
//////////////////////////////////////////////////////////////////////////////
optionalMemoizedResult = memoization.getMemoizedResult("never-stored");
assertNotNull(optionalMemoizedResult);
assertTrue(optionalMemoizedResult.isEmpty());
@ -177,6 +171,23 @@ class MemoizationTest extends BaseTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testGetResultThrowing() throws Exception
{
Memoization<String, Integer> memoization = new Memoization<>();
UnsafeFunction<String, Integer, Exception> lookupFunction = Integer::parseInt;
assertEquals(Optional.of(1), memoization.getResultThrowing("1", lookupFunction));
assertThatThrownBy(() -> memoization.getResultThrowing(null, lookupFunction)).hasMessageContaining("null");
assertThatThrownBy(() -> memoization.getResultThrowing("two", lookupFunction)).hasMessageContaining("For input string:");
}
/*******************************************************************************
**
*******************************************************************************/
@ -198,7 +209,7 @@ class MemoizationTest extends BaseTest
for(int n = 0; n < 1_000_000; n++)
{
memoization.storeResult(String.valueOf(n), n);
memoization.getResult(String.valueOf(n));
memoization.getMemoizedResult(String.valueOf(n));
if(n % 100_000 == 0)
{

View File

@ -280,7 +280,7 @@ public class FilesystemImporterMetaDataTemplate
QTableMetaData qTableMetaData = new QTableMetaData()
.withName(importBaseName + IMPORT_RECORD_TABLE_SUFFIX)
.withIcon(new QIcon().withName("power_input"))
.withRecordLabelFormat("%s")
.withRecordLabelFormat("%s - Record %s")
.withRecordLabelFields("importFileId", "recordNo")
.withPrimaryKeyField("id")
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))

View File

@ -164,6 +164,7 @@ public class FilesystemImporterStep implements BackendStep
try
{
String sourceFileName = sourceEntry.getKey();
LOG.info("Found file", logPair("fileName", sourceFileName));
/////////////////////////////////////////////////////////
// if filename was already imported, decide what to do //
@ -183,7 +184,7 @@ public class FilesystemImporterStep implements BackendStep
}
else
{
LOG.debug("Skipping already-imported file", logPair("fileName", sourceFileName));
LOG.info("Skipping already-imported file", logPair("fileName", sourceFileName)); // todo - downgrade to debug?
removeSourceFileIfSoConfigured(removeFileAfterImport, sourceActionBase, sourceTable, sourceBackend, sourceFileName);
continue;
}
@ -317,8 +318,14 @@ public class FilesystemImporterStep implements BackendStep
if(removeFileAfterImport)
{
String fullBasePath = sourceActionBase.getFullBasePath(sourceTable, sourceBackend);
LOG.info("Removing source file", logPair("path", fullBasePath + "/" + sourceFileName), logPair("sourceTable", sourceTable.getName()));
sourceActionBase.deleteFile(QContext.getQInstance(), sourceTable, fullBasePath + "/" + sourceFileName);
}
else
{
// todo - downgrade to debug
LOG.info("Not configured to remove source file", logPair("sourceFileName", sourceFileName), logPair("sourceTable", sourceTable.getName()));
}
}
@ -355,7 +362,7 @@ public class FilesystemImporterStep implements BackendStep
+ "-" + sourceFileName.replaceAll(".*" + File.separator, "");
path = AbstractBaseFilesystemAction.stripDuplicatedSlashes(path);
LOG.info("Archiving file", logPair("path", path), logPair("archiveBackendName", archiveBackend.getName()), logPair("archiveTableName", archiveTable.getName()));
LOG.info("Archiving file", logPair("path", path));
archiveActionBase.writeFile(archiveBackend, path, bytes);
return (path);

View File

@ -42,7 +42,6 @@ import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.AbstractF
import com.kingsrook.qqq.backend.module.filesystem.exceptions.FilesystemException;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3Utils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
@ -163,18 +162,9 @@ public class AbstractS3Action extends AbstractBaseFilesystemAction<S3ObjectSumma
@Override
public void writeFile(QBackendMetaData backendMetaData, String path, byte[] contents) throws IOException
{
path = stripLeadingSlash(stripDuplicatedSlashes(path));
String bucketName = ((S3BackendMetaData) backendMetaData).getBucketName();
try
{
path = stripLeadingSlash(stripDuplicatedSlashes(path));
getS3Utils().writeFile(bucketName, path, contents);
}
catch(Exception e)
{
LOG.warn("Error writing file", e, logPair("path", path), logPair("bucketName", bucketName));
throw (new IOException("Error writing file", e));
}
getS3Utils().writeFile(bucketName, path, contents);
}
@ -230,10 +220,11 @@ public class AbstractS3Action extends AbstractBaseFilesystemAction<S3ObjectSumma
@Override
public void deleteFile(QInstance instance, QTableMetaData table, String fileReference) throws FilesystemException
{
QBackendMetaData backend = instance.getBackend(table.getBackendName());
String bucketName = ((S3BackendMetaData) backend).getBucketName();
QBackendMetaData backend = instance.getBackend(table.getBackendName());
String bucketName = ((S3BackendMetaData) backend).getBucketName();
String cleanedPath = stripLeadingSlash(stripDuplicatedSlashes(fileReference));
getS3Utils().deleteObject(bucketName, fileReference);
getS3Utils().deleteObject(bucketName, cleanedPath);
}

View File

@ -154,6 +154,35 @@ public class S3BackendModuleTest extends BaseS3Test
/*******************************************************************************
** Regression check on the s3 delete method - if the file name has duplicated
** slashes and/or leading slash(es), those need stripped. So, create that
** situation and assert delete works.
*******************************************************************************/
@Test
public void testDeleteFileExtraSlashes() throws Exception
{
QInstance qInstance = TestUtils.defineInstance();
QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_S3);
/////////////////////////////////////////////////////////////////////////////////////////////
// first list the files - then delete one, then re-list, and assert that we have one fewer //
/////////////////////////////////////////////////////////////////////////////////////////////
List<S3ObjectSummary> s3ObjectSummariesBeforeDelete = getS3Utils().listObjectsInBucketMatchingGlob(BUCKET_NAME, TEST_FOLDER, "");
S3BackendModule s3BackendModule = new S3BackendModule();
AbstractS3Action actionBase = (AbstractS3Action) s3BackendModule.getActionBase();
actionBase.setS3Utils(getS3Utils());
String path = "//" + s3ObjectSummariesBeforeDelete.get(0).getKey().replaceAll("/", "//");
actionBase.deleteFile(qInstance, table, "//" + path);
List<S3ObjectSummary> s3ObjectSummariesAfterDelete = getS3Utils().listObjectsInBucketMatchingGlob(BUCKET_NAME, TEST_FOLDER, "");
Assertions.assertEquals(s3ObjectSummariesBeforeDelete.size() - 1, s3ObjectSummariesAfterDelete.size(),
"Should be one fewer file listed after deleting one.");
}
/*******************************************************************************
**
*******************************************************************************/

View File

@ -53,7 +53,7 @@
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<version>2.17.1</version>
<version>2.23.0</version>
</dependency>
<dependency>

View File

@ -47,6 +47,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
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.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;
@ -479,7 +480,7 @@ public class AbstractMongoDBAction
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// handle user with no values -- they can only see null values, and only iff the lock's null-value behavior is ALLOW //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(recordSecurityLock.getNullValueBehavior()))
if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock)))
{
lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_BLANK));
}
@ -498,7 +499,7 @@ public class AbstractMongoDBAction
// else, if user/session has some values, build an IN rule - //
// noting that if the lock's null-value behavior is ALLOW, then we actually want IS_NULL_OR_IN, not just IN //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(recordSecurityLock.getNullValueBehavior()))
if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock)))
{
lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_NULL_OR_IN, securityKeyValues));
}

View File

@ -875,13 +875,13 @@ class MongoDBQueryActionTest extends BaseTest
QContext.setQSession(new QSession());
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, null));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, null));
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, Collections.emptyList()));
QContext.setQSession(new QSession().withSecurityKeyValues(Map.of(TestUtils.TABLE_NAME_STORE, Collections.emptyList())));
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3)));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 3));
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(2)
.anyMatch(r -> r.getValueInteger("key").equals(1))
@ -919,13 +919,13 @@ class MongoDBQueryActionTest extends BaseTest
QContext.setQSession(new QSession());
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, null));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, null));
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, Collections.emptyList()));
QContext.setQSession(new QSession().withSecurityKeyValues(Map.of(TestUtils.TABLE_NAME_STORE, Collections.emptyList())));
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3)));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 3));
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(6)
.allMatch(r -> r.getValueInteger("storeKey").equals(1) || r.getValueInteger("storeKey").equals(3));
@ -961,7 +961,7 @@ class MongoDBQueryActionTest extends BaseTest
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
queryInput.setFilter(new QQueryFilter(new QFilterCriteria("storeKey", QCriteriaOperator.IN, List.of(1, 2))));
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3)));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 3));
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(3)
.allMatch(r -> r.getValueInteger("storeKey").equals(1));

View File

@ -66,6 +66,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.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;
@ -467,7 +468,7 @@ public abstract class AbstractRDBMSAction
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// handle user with no values -- they can only see null values, and only iff the lock's null-value behavior is ALLOW //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(recordSecurityLock.getNullValueBehavior()))
if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock)))
{
lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_BLANK));
}
@ -486,7 +487,7 @@ public abstract class AbstractRDBMSAction
// else, if user/session has some values, build an IN rule - //
// noting that if the lock's null-value behavior is ALLOW, then we actually want IS_NULL_OR_IN, not just IN //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(recordSecurityLock.getNullValueBehavior()))
if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock)))
{
lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_NULL_OR_IN, securityKeyValues));
}

View File

@ -54,6 +54,7 @@ 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.Pair;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
@ -260,7 +261,7 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf
throw (new QUserFacingException("Query was cancelled."));
}
LOG.warn("Error executing query", e);
LOG.warn("Error executing query", e, logPair("tableName", queryInput.getTableName()), logPair("filter", queryInput.getFilter()));
throw new QException("Error executing query", e);
}
}

View File

@ -196,7 +196,7 @@ public class RDBMSCountActionTest extends RDBMSActionTest
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true));
assertThat(new CountAction().execute(countInput).getCount()).isEqualTo(8);
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(2, 3)));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 2).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 3));
assertThat(new CountAction().execute(countInput).getCount()).isEqualTo(5);
}

View File

@ -635,7 +635,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest
insertInput.setTableName(TestUtils.TABLE_NAME_PERSON);
InsertAction insertAction = new InsertAction();
QBackendTransaction transaction = QBackendTransaction.openFor(insertInput);
QBackendTransaction transaction = QBackendTransaction.openFor(insertInput);
insertInput.setTransaction(transaction);
insertInput.setRecords(List.of(
@ -1325,13 +1325,13 @@ public class RDBMSQueryActionTest extends RDBMSActionTest
QContext.setQSession(new QSession());
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, null));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, null));
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, Collections.emptyList()));
QContext.setQSession(new QSession().withSecurityKeyValues(Map.of(TestUtils.TABLE_NAME_STORE, Collections.emptyList())));
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3)));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 3));
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(2)
.anyMatch(r -> r.getValueInteger("id").equals(1))
@ -1369,13 +1369,13 @@ public class RDBMSQueryActionTest extends RDBMSActionTest
QContext.setQSession(new QSession());
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, null));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, null));
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, Collections.emptyList()));
QContext.setQSession(new QSession().withSecurityKeyValues(Map.of(TestUtils.TABLE_NAME_STORE, Collections.emptyList())));
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3)));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 3));
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(6)
.allMatch(r -> r.getValueInteger("storeId").equals(1) || r.getValueInteger("storeId").equals(3));
@ -1411,7 +1411,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
queryInput.setFilter(new QQueryFilter(new QFilterCriteria("storeId", QCriteriaOperator.IN, List.of(1, 2))));
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3)));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 3));
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(3)
.allMatch(r -> r.getValueInteger("storeId").equals(1));
@ -1556,10 +1556,17 @@ public class RDBMSQueryActionTest extends RDBMSActionTest
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// user with list of all ids shouldn't see the nulls (given that default null-behavior on this key type is DENY) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 2, 3, 4, 5)));
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(8)
.noneMatch(hasNullStoreId);
{
QSession qSession = new QSession();
for(Integer i : List.of(1, 2, 3, 4, 5))
{
qSession.withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, i);
}
QContext.setQSession(qSession);
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(8)
.noneMatch(hasNullStoreId);
}
//////////////////////////////////////////////////////////////////////////
// specifically set the null behavior to deny - repeat the last 2 tests //
@ -1569,10 +1576,17 @@ public class RDBMSQueryActionTest extends RDBMSActionTest
QContext.setQSession(new QSession());
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 2, 3, 4, 5)));
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(8)
.noneMatch(hasNullStoreId);
{
QSession qSession = new QSession();
for(Integer i : List.of(1, 2, 3, 4, 5))
{
qSession.withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, i);
}
QContext.setQSession(qSession);
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(8)
.noneMatch(hasNullStoreId);
}
///////////////////////////////////
// change null behavior to ALLOW //
@ -1598,10 +1612,17 @@ public class RDBMSQueryActionTest extends RDBMSActionTest
////////////////////////////////////////////////////
// user with list of all ids should see the nulls //
////////////////////////////////////////////////////
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 2, 3, 4, 5)));
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(10)
.anyMatch(hasNullStoreId);
{
QSession qSession = new QSession();
for(Integer i : List.of(1, 2, 3, 4, 5))
{
qSession.withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, i);
}
QContext.setQSession(qSession);
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(10)
.anyMatch(hasNullStoreId);
}
}
@ -1644,7 +1665,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest
assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty();
queryInput.setFilter(new QQueryFilter(new QFilterCriteria("storeId", QCriteriaOperator.IN, List.of(1, 2))));
QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3)));
QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 3));
assertThat(new QueryAction().execute(queryInput).getRecords())
.hasSize(3)
.allMatch(r -> r.getValueInteger("storeId").equals(1));

View File

@ -44,6 +44,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.fail;
/*******************************************************************************
@ -334,15 +335,31 @@ class ApiScriptUtilsTest extends BaseTest
String jobId = ValueUtils.getValueAsString(((Map<String, ?>) asyncResult).get("jobId"));
assertNotNull(jobId);
SleepUtils.sleep(100, TimeUnit.MILLISECONDS);
//////////////////////////////////////////////////////////////////////////////////
// check every 100 ms or so to see if the process is done - but after 10 loops, //
//////////////////////////////////////////////////////////////////////////////////
for(int i = 0; i < 10; i++)
{
Serializable result = apiScriptUtils.getProcessStatus(TestUtils.PROCESS_NAME_TRANSFORM_PEOPLE, jobId);
Serializable result = apiScriptUtils.getProcessStatus(TestUtils.PROCESS_NAME_TRANSFORM_PEOPLE, jobId);
assertThat(result).isInstanceOf(List.class);
List<Map<String, Object>> resultList = (List<Map<String, Object>>) result;
assertEquals(3, resultList.size());
if(result instanceof Map map && map.containsKey("jobId"))
{
System.out.println("Process is still running - sleep and look again...");
SleepUtils.sleep(100, TimeUnit.MILLISECONDS);
continue;
}
assertThat(resultList.stream().filter(m -> m.get("id").equals(2)).findFirst()).isPresent().get().hasFieldOrPropertyWithValue("statusCode", 200);
assertThat(resultList.stream().filter(m -> m.get("id").equals(3)).findFirst()).isPresent().get().hasFieldOrPropertyWithValue("statusCode", 500);
assertThat(result).isInstanceOf(List.class);
List<Map<String, Object>> resultList = (List<Map<String, Object>>) result;
assertEquals(3, resultList.size());
assertThat(resultList.stream().filter(m -> m.get("id").equals(2)).findFirst()).isPresent().get().hasFieldOrPropertyWithValue("statusCode", 200);
assertThat(resultList.stream().filter(m -> m.get("id").equals(3)).findFirst()).isPresent().get().hasFieldOrPropertyWithValue("statusCode", 500);
return;
}
fail("Process didn't complete after 10 loops, ~1 second.");
}

View File

@ -420,6 +420,7 @@ public class QJavalinImplementation
authContext.put(Auth0AuthenticationModule.ACCESS_TOKEN_KEY, ValueUtils.getValueAsString(map.get("accessToken")));
authContext.put(Auth0AuthenticationModule.DO_STORE_USER_SESSION_KEY, "true");
QContext.init(qInstance, null); // hmm...
QSession session = authenticationModule.createSession(qInstance, authContext);
context.cookie(SESSION_UUID_COOKIE_NAME, session.getUuid(), SESSION_COOKIE_AGE);