diff --git a/pom.xml b/pom.xml
index 3907069..ff03c7c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -28,6 +28,10 @@
21
true
true
+
+
+
+ 0.33.0-RC.1
@@ -56,6 +60,16 @@
4.0.0
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ @{argLine} -Duser.timezone=UTC
+
+
+
src/main/resources
@@ -82,12 +96,30 @@
+
+
+
+ canonical-maven-central
+ https://repo.maven.apache.org/maven2/
+
+ true
+
+
+ false
+
+
+
+
com.kingsrook.qqq
qqq-bom-pom
- 0.40.0
+
+ 4.1.0-RC.21
pom
import
@@ -98,12 +130,17 @@
com.kingsrook.qqq
qqq-backend-core
- 0.40.0
com.kingsrook.qbits
qbit-user-role-permissions
- 0.31.0
+ ${urp.version}
+
+
+ com.kingsrook.qqq
+ qqq-backend-core
+
+
com.kingsrook.qqq
@@ -115,11 +152,6 @@
qqq-backend-module-rdbms
test
-
- com.kingsrook.qqq
- qqq-frontend-material-dashboard
- 0.40.0
-
org.junit.jupiter
@@ -129,7 +161,24 @@
org.assertj
assertj-core
- 3.27.3
+ 3.27.7
+ test
+
+
+ com.kingsrook.qqq
+ qqq-backend-module-postgres
+ test
+
+
+ com.h2database
+ h2
+ 2.2.220
+ test
+
+
+ org.testcontainers
+ testcontainers
+ 2.0.3
test
diff --git a/src/main/java/com/kingsrook/qbits/customapps/CustomAppsQBitConfig.java b/src/main/java/com/kingsrook/qbits/customapps/CustomAppsQBitConfig.java
index 67ee496..9203aca 100644
--- a/src/main/java/com/kingsrook/qbits/customapps/CustomAppsQBitConfig.java
+++ b/src/main/java/com/kingsrook/qbits/customapps/CustomAppsQBitConfig.java
@@ -44,6 +44,10 @@ public class CustomAppsQBitConfig implements QBitConfig
private boolean includeApiVersions = false;
private String componentSourceUrl;
+ private String backendName;
+
+ private java.util.Set allowedComponentHosts = new java.util.LinkedHashSet<>();
+
/*******************************************************************************
@@ -63,6 +67,7 @@ public class CustomAppsQBitConfig implements QBitConfig
@Override
public void validate(QInstance qInstance, List errors)
{
+ assertCondition(backendName != null && !backendName.isBlank(), "backendName is required for the Custom Apps QBit", errors);
if(includeApiVersions && !apiMiddlewareModuleAvailable)
{
errors.add("Custom Apps QBit is configured to includeApiVersions, but the qqq-middleware-api module is available.");
@@ -173,4 +178,59 @@ public class CustomAppsQBitConfig implements QBitConfig
return (this);
}
+
+
+ /*******************************************************************************
+ ** The backend the qbit's tables live on.
+ *******************************************************************************/
+ @Override
+ public String getDefaultBackendNameForTables()
+ {
+ return (backendName);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for backendName
+ *******************************************************************************/
+ public String getBackendName()
+ {
+ return (this.backendName);
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for backendName
+ *******************************************************************************/
+ public CustomAppsQBitConfig withBackendName(String backendName)
+ {
+ this.backendName = backendName;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for allowedComponentHosts (lower-case host names an https component
+ ** bundle may be loaded from; empty means paths on this application only)
+ *******************************************************************************/
+ public java.util.Set getAllowedComponentHosts()
+ {
+ return (this.allowedComponentHosts);
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for allowedComponentHosts
+ *******************************************************************************/
+ public CustomAppsQBitConfig withAllowedComponentHosts(java.util.Collection hosts)
+ {
+ this.allowedComponentHosts = new java.util.LinkedHashSet<>();
+ hosts.forEach(host -> this.allowedComponentHosts.add(host.toLowerCase()));
+ return (this);
+ }
+
}
diff --git a/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppContainerPermissionsWidgetMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppContainerPermissionsWidgetMetaDataProducer.java
new file mode 100644
index 0000000..52eff51
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppContainerPermissionsWidgetMetaDataProducer.java
@@ -0,0 +1,68 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qbits.customapps.metadata;
+
+
+import com.kingsrook.qbits.customapps.model.CustomAppContainer;
+import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
+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;
+
+
+/*******************************************************************************
+ ** The "users" section of a custom app container: the roles that hold the container's access
+ ** permission (rows of the permission-to-role table), as a child record list.
+ *******************************************************************************/
+public class CustomAppContainerPermissionsWidgetMetaDataProducer extends MetaDataProducer
+{
+ public static final String NAME = "customAppContainerPermissions";
+ public static final String JOIN_NAME = "customAppContainerRolePermission";
+
+
+
+ /***************************************************************************
+ ** The widget, and (as a side effect, once) its join.
+ ***************************************************************************/
+ @Override
+ public QWidgetMetaData produce(QInstance qInstance) throws QException
+ {
+ if(qInstance.getJoin(JOIN_NAME) == null)
+ {
+ qInstance.addJoin(new QJoinMetaData()
+ .withName(JOIN_NAME)
+ .withLeftTable(CustomAppContainer.TABLE_NAME)
+ .withRightTable("rolePermissionInt")
+ .withType(JoinType.ONE_TO_MANY)
+ .withJoinOn(new JoinOn("permissionId", "permissionId")));
+ }
+ QWidgetMetaData widget = ChildRecordListRenderer.widgetMetaDataBuilder(qInstance.getJoin(JOIN_NAME))
+ .withLabel("Roles with access")
+ .getWidgetMetaData();
+ widget.setName(NAME);
+ return (widget);
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppPermissionsWidgetMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppPermissionsWidgetMetaDataProducer.java
new file mode 100644
index 0000000..c3ef105
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppPermissionsWidgetMetaDataProducer.java
@@ -0,0 +1,68 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qbits.customapps.metadata;
+
+
+import com.kingsrook.qbits.customapps.model.CustomApp;
+import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
+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;
+
+
+/*******************************************************************************
+ ** The "users" section of a custom app: the roles that hold the app's access
+ ** permission (rows of the permission-to-role table), as a child record list.
+ *******************************************************************************/
+public class CustomAppPermissionsWidgetMetaDataProducer extends MetaDataProducer
+{
+ public static final String NAME = "customAppPermissions";
+ public static final String JOIN_NAME = "customAppRolePermission";
+
+
+
+ /***************************************************************************
+ ** The widget, and (as a side effect, once) its join.
+ ***************************************************************************/
+ @Override
+ public QWidgetMetaData produce(QInstance qInstance) throws QException
+ {
+ if(qInstance.getJoin(JOIN_NAME) == null)
+ {
+ qInstance.addJoin(new QJoinMetaData()
+ .withName(JOIN_NAME)
+ .withLeftTable(CustomApp.TABLE_NAME)
+ .withRightTable("rolePermissionInt")
+ .withType(JoinType.ONE_TO_MANY)
+ .withJoinOn(new JoinOn("permissionId", "permissionId")));
+ }
+ QWidgetMetaData widget = ChildRecordListRenderer.widgetMetaDataBuilder(qInstance.getJoin(JOIN_NAME))
+ .withLabel("Roles with access")
+ .getWidgetMetaData();
+ widget.setName(NAME);
+ return (widget);
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsDynamicMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsDynamicMetaDataProducer.java
index 882c51c..67b477f 100644
--- a/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsDynamicMetaDataProducer.java
+++ b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsDynamicMetaDataProducer.java
@@ -28,6 +28,7 @@ import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
+import com.kingsrook.qbits.customapps.CustomAppsQBitConfig;
import com.kingsrook.qbits.customapps.model.CustomApp;
import com.kingsrook.qbits.customapps.model.CustomAppBackendConfig;
import com.kingsrook.qbits.customapps.model.CustomAppBackendEnum;
@@ -38,6 +39,8 @@ import com.kingsrook.qbits.customapps.utils.CustomAppsUtils;
import com.kingsrook.qbits.customapps.widgets.LookerWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -76,6 +79,42 @@ public class CustomAppsDynamicMetaDataProducer
*
***************************************************************************/
public CustomAppData getCustomAppList(QInstance qInstance) throws QException
+ {
+ ////////////////////////////////////////////////////////////////////////////////
+ // capture the viewer now: the build itself runs with the system session set //
+ ////////////////////////////////////////////////////////////////////////////////
+ QSession viewer = QContext.getQSession();
+ return (getCustomAppList(qInstance, name -> viewer != null && viewer.hasPermission(name + ".hasAccess")));
+ }
+
+
+
+ /***************************************************************************
+ ** Build the apps for whoever the predicate admits (by app/container name).
+ ** The definition rows are read as the system user (they are configuration,
+ ** not data of the viewer); what the viewer may see is decided only by the
+ ** predicate, which the metadata customizer ties to the session's permissions.
+ ***************************************************************************/
+ public CustomAppData getCustomAppList(QInstance qInstance, java.util.function.Predicate permitted) throws QException
+ {
+ QSession original = QContext.getQSession();
+ try
+ {
+ QContext.setQSession(new QSystemUserSession());
+ return (buildCustomAppList(qInstance, permitted, original));
+ }
+ finally
+ {
+ QContext.setQSession(original);
+ }
+ }
+
+
+
+ /***************************************************************************
+ ** The build proper, with the system session in place for reads.
+ ***************************************************************************/
+ private CustomAppData buildCustomAppList(QInstance qInstance, java.util.function.Predicate permitted, QSession viewer) throws QException
{
///////////////////////////////////////////////////
// look up data needed to set up the custom apps //
@@ -152,7 +191,7 @@ public class CustomAppsDynamicMetaDataProducer
/////////////////////////////////////////////////////
// make sure user has permission to this app //
////////////////////////////////////////////////////
- if(!QContext.getQSession().hasPermission(appName + ".hasAccess"))
+ if(!permitted.test(appName))
{
continue;
}
@@ -161,8 +200,21 @@ public class CustomAppsDynamicMetaDataProducer
// create a custom widget for this app //
/////////////////////////////////////////
String widgetName = appName + "Widget";
- QWidgetMetaDataInterface widget = buildWidget(qInstance, app, customAppBackendConfigMap, widgetName);
- CustomAppIcon appIcon = customAppIconMap.get(app.getCustomAppIconId());
+ QWidgetMetaDataInterface widget;
+ try
+ {
+ widget = buildWidget(qInstance, app, customAppBackendConfigMap, widgetName);
+ }
+ catch(IllegalArgumentException e)
+ {
+ ///////////////////////////////////////////////////////////////////////
+ // one invalid row must not take the other apps (or startup) with it; //
+ // it is simply not offered //
+ ///////////////////////////////////////////////////////////////////////
+ LOG.warn("Custom app skipped: its definition is not valid", logPair("app", app.getName()), logPair("reason", e.getMessage()));
+ continue;
+ }
+ CustomAppIcon appIcon = customAppIconMap.get(app.getCustomAppIconId());
QAppMetaData appMetaData = new QAppMetaData()
.withPermissionRules(qInstance.getDefaultPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(appName).withDenyBehavior(DenyBehavior.HIDDEN))
@@ -217,7 +269,7 @@ public class CustomAppsDynamicMetaDataProducer
/////////////////////////////////////////////////////
// make sure user has permission to this container //
/////////////////////////////////////////////////////
- if(!QContext.getQSession().hasPermission(containerName + ".hasAccess"))
+ if(!permitted.test(containerName))
{
continue;
}
@@ -248,6 +300,8 @@ public class CustomAppsDynamicMetaDataProducer
{
case LOOKER:
return (buildLookerWidget(qInstance, customApp, widgetName));
+ case LOCAL_WIDGET:
+ return (localWidget(qInstance, customApp));
default:
throw new IllegalArgumentException("Unknown custom app backend: " + customAppBackendConfig.getCustomAppBackendId());
}
@@ -256,6 +310,34 @@ public class CustomAppsDynamicMetaDataProducer
+ /*******************************************************************************
+ ** a custom app that shows a widget the application itself defines (a record
+ ** list, a chart, ...): the screen is composed from existing, permissioned
+ ** metadata, so no code or markup comes from the data, and the widget keeps its
+ ** own permission rules. Only widgets that exist on the instance can be named,
+ ** and never this qbit's own generated ones.
+ *******************************************************************************/
+ private QWidgetMetaDataInterface localWidget(QInstance qInstance, CustomApp customApp)
+ {
+ String name = customApp.getWidgetName();
+ if(!StringUtils.hasContent(name))
+ {
+ throw new IllegalArgumentException("a local widget app needs a widget name");
+ }
+ QWidgetMetaDataInterface widget = qInstance.getWidget(name);
+ if(widget == null)
+ {
+ throw new IllegalArgumentException("widget " + name + " is not defined on this application");
+ }
+ if(name.startsWith("qca"))
+ {
+ throw new IllegalArgumentException("widget " + name + " is generated by custom apps and cannot be chosen");
+ }
+ return (widget);
+ }
+
+
+
/*******************************************************************************
** builds a widget to be used for a customapps app
*******************************************************************************/
@@ -271,10 +353,19 @@ public class CustomAppsDynamicMetaDataProducer
componentSourceUrl = "/dynamic-qfmd-components/q-embed-looker-dashboards.bundle.js";
}
+ if(!isAllowedComponentSource(componentSourceUrl))
+ {
+ throw new IllegalArgumentException("the component source must be a path on this application (or an https address the configuration allows)");
+ }
+
//////////////////////////////
// add the dashboard to url //
/////////////////////////
Integer dashboardId = customApp.getLookerDashboardId();
+ if(dashboardId == null)
+ {
+ throw new IllegalArgumentException("a Looker app needs a dashboard id");
+ }
componentSourceUrl += "?dashboardId=" + dashboardId;
Map defaultValues = MapBuilder.of(
@@ -287,7 +378,7 @@ public class CustomAppsDynamicMetaDataProducer
QWidgetMetaData lookerWidget = new QWidgetMetaData()
.withName(widgetName)
- .withPermissionRules(qInstance.getDefaultPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(widgetName + ".hasAccess").withDenyBehavior(DenyBehavior.HIDDEN))
+ .withPermissionRules(qInstance.getDefaultPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(customApp.getName() == null ? widgetName : CustomAppsUtils.getCamelCaseName(customApp.getName(), CustomApp.TABLE_NAME)).withDenyBehavior(DenyBehavior.HIDDEN))
.withType(WidgetType.CUSTOM_COMPONENT.getType())
.withCodeReference(new QCodeReference(LookerWidgetRenderer.class))
.withGridColumns(12)
@@ -304,5 +395,51 @@ public class CustomAppsDynamicMetaDataProducer
+ /*******************************************************************************
+ ** The component bundle is code the browser runs, so its source is never taken
+ ** from data: a path served by this application, or an https address whose host
+ ** the configuration lists. Anything else (script or data URLs, protocol
+ ** relative addresses, other hosts) is refused.
+ *******************************************************************************/
+ public boolean isAllowedComponentSource(String url)
+ {
+ if(url == null || url.chars().anyMatch(c -> c < 0x21 || c > 0x7e))
+ {
+ return (false);
+ }
+ if(url.startsWith("/") && !url.startsWith("//") && !url.contains("\\") && !url.contains(".."))
+ {
+ return (true);
+ }
+ try
+ {
+ java.net.URI uri = new java.net.URI(url);
+ CustomAppsQBitConfig config = configFor(QContext.getQInstance());
+ return ("https".equals(uri.getScheme()) && uri.getUserInfo() == null && uri.getHost() != null
+ && config != null && config.getAllowedComponentHosts().contains(uri.getHost().toLowerCase()));
+ }
+ catch(java.net.URISyntaxException e)
+ {
+ return (false);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** The qbit's configuration on the instance, if installed.
+ *******************************************************************************/
+ static CustomAppsQBitConfig configFor(QInstance qInstance)
+ {
+ if(qInstance == null || qInstance.getQBits() == null)
+ {
+ return (null);
+ }
+ return (qInstance.getQBits().values().stream().map(qBit -> qBit.getConfig()).filter(CustomAppsQBitConfig.class::isInstance)
+ .map(CustomAppsQBitConfig.class::cast).findFirst().orElse(null));
+ }
+
+
+
public record CustomAppData(List containerList, Map positionMap, Map appMap, Map widgetMap) {}
}
diff --git a/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsMetaDataActionCustomizer.java b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsMetaDataActionCustomizer.java
new file mode 100644
index 0000000..15e1cec
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsMetaDataActionCustomizer.java
@@ -0,0 +1,117 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qbits.customapps.metadata;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.actions.metadata.DefaultNoopMetaDataActionCustomizer;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
+import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataOutput;
+import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
+import com.kingsrook.qqq.backend.core.model.metadata.frontend.AppTreeNode;
+import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendAppMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendWidgetMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
+
+
+/*******************************************************************************
+ ** Offers the declared custom apps to the frontend, per session: each request's
+ ** metadata gets exactly the containers, apps and widgets whose
+ ** "<name>.hasAccess" permission the session holds, ahead of a container named
+ ** by its "place before" setting. Everything else is the application's own
+ ** metadata customizer behaviour, left unchanged (this extends the standard no-op
+ ** customizer, so an application that needs its own rules extends this class).
+ *******************************************************************************/
+public class CustomAppsMetaDataActionCustomizer extends DefaultNoopMetaDataActionCustomizer
+{
+ private static final QLogger LOG = QLogger.getLogger(CustomAppsMetaDataActionCustomizer.class);
+
+
+
+ /***************************************************************************
+ ** Add the permitted custom apps to the finished metadata.
+ ***************************************************************************/
+ @Override
+ public void postProcess(MetaDataOutput metaDataOutput) throws QException
+ {
+ super.postProcess(metaDataOutput);
+
+ CustomAppsDynamicMetaDataProducer.CustomAppData data;
+ try
+ {
+ data = new CustomAppsDynamicMetaDataProducer().getCustomAppList(QContext.getQInstance());
+ }
+ catch(QException | RuntimeException e)
+ {
+ LOG.warn("Custom apps could not be read; none are offered", logPair("error", e.getClass().getSimpleName()));
+ return;
+ }
+
+ MetaDataInput input = new MetaDataInput();
+ for(QWidgetMetaDataInterface widget : data.widgetMap().values())
+ {
+ if(QContext.getQInstance().getWidget(widget.getName()) != null)
+ {
+ metaDataOutput.getWidgets().put(widget.getName(), new QFrontendWidgetMetaData(input, widget));
+ }
+ }
+ for(QAppMetaData app : data.appMap().values())
+ {
+ metaDataOutput.getApps().put(app.getName(), new QFrontendAppMetaData(app, metaDataOutput));
+ }
+
+ List tree = new ArrayList<>(metaDataOutput.getAppTree());
+ for(QAppMetaData container : data.containerList())
+ {
+ AppTreeNode node = new AppTreeNode(container);
+ for(var section : container.getSections())
+ {
+ for(String appName : section.getApps())
+ {
+ QAppMetaData child = data.appMap().get(appName);
+ if(child != null)
+ {
+ node.addChild(new AppTreeNode(child));
+ }
+ }
+ }
+ metaDataOutput.getApps().put(container.getName(), new QFrontendAppMetaData(container, metaDataOutput));
+ int position = tree.size();
+ String before = data.positionMap().get(container.getName());
+ for(int i = 0; before != null && i < tree.size(); i++)
+ {
+ if(before.equals(tree.get(i).getName()))
+ {
+ position = i;
+ break;
+ }
+ }
+ tree.add(position, node);
+ }
+ metaDataOutput.setAppTree(tree);
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/customapps/model/CustomApp.java b/src/main/java/com/kingsrook/qbits/customapps/model/CustomApp.java
index 3a9cfeb..f280134 100644
--- a/src/main/java/com/kingsrook/qbits/customapps/model/CustomApp.java
+++ b/src/main/java/com/kingsrook/qbits/customapps/model/CustomApp.java
@@ -77,7 +77,7 @@ public class CustomApp extends QRecordEntity implements Serializable
.withRecordLabelFormat("%s")
.withRecordLabelFields("name")
.withSection(SectionFactory.defaultT1("id"))
- .withSection(SectionFactory.defaultT2("name", "customAppIconId", "customAppBackendConfigId", "customAppSectionId", "sequenceNo", "lookerDashboardId", "permissionId"))
+ .withSection(SectionFactory.defaultT2("name", "customAppIconId", "customAppBackendConfigId", "customAppSectionId", "sequenceNo", "lookerDashboardId", "widgetName", "permissionId"))
.withSection(new QFieldSection("users", new QIcon().withName("person"), Tier.T2).withWidgetName("customAppPermissions"))
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"))
@@ -115,9 +115,12 @@ public class CustomApp extends QRecordEntity implements Serializable
@QField(isEditable = false, possibleValueSourceName = Permission.TABLE_NAME)
private Integer permissionId;
- @QField(isRequired = true)
+ @QField(label = "Looker Dashboard Id")
private Integer lookerDashboardId;
+ @QField(label = "Local Widget", maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
+ private String widgetName;
+
@QField(isEditable = false)
private Instant createDate;
@@ -453,4 +456,35 @@ public class CustomApp extends QRecordEntity implements Serializable
return (this);
}
+
+
+ /*******************************************************************************
+ ** Getter for widgetName
+ *******************************************************************************/
+ public String getWidgetName()
+ {
+ return (this.widgetName);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for widgetName
+ *******************************************************************************/
+ public void setWidgetName(String widgetName)
+ {
+ this.widgetName = widgetName;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for widgetName
+ *******************************************************************************/
+ public CustomApp withWidgetName(String widgetName)
+ {
+ this.widgetName = widgetName;
+ return (this);
+ }
+
}
diff --git a/src/main/java/com/kingsrook/qbits/customapps/model/CustomAppBackendEnum.java b/src/main/java/com/kingsrook/qbits/customapps/model/CustomAppBackendEnum.java
index a14f91a..4ce91b7 100644
--- a/src/main/java/com/kingsrook/qbits/customapps/model/CustomAppBackendEnum.java
+++ b/src/main/java/com/kingsrook/qbits/customapps/model/CustomAppBackendEnum.java
@@ -33,7 +33,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMeta
public enum CustomAppBackendEnum implements PossibleValueEnum
{
LOOKER(1, "Looker"),
- URL(2, "URL");
+ URL(2, "URL"),
+ LOCAL_WIDGET(3, "Local widget");
private final Integer id;
private final String label;
diff --git a/src/test/java/com/kingsrook/qbits/customapps/CustomAppsPostgresTest.java b/src/test/java/com/kingsrook/qbits/customapps/CustomAppsPostgresTest.java
new file mode 100644
index 0000000..477813c
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/customapps/CustomAppsPostgresTest.java
@@ -0,0 +1,395 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qbits.customapps;
+
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+import java.util.Set;
+import com.kingsrook.qbits.customapps.metadata.CustomAppsDynamicMetaDataProducer;
+import com.kingsrook.qbits.customapps.metadata.CustomAppsMetaDataActionCustomizer;
+import com.kingsrook.qbits.customapps.model.CustomApp;
+import com.kingsrook.qbits.customapps.model.CustomAppBackendEnum;
+import com.kingsrook.qbits.userrolepermissions.UserRolePermissionsQBitConfig;
+import com.kingsrook.qbits.userrolepermissions.UserRolePermissionsQBitProducer;
+import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
+import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
+import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
+import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
+import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
+import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
+import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
+import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
+import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
+import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+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.dashboard.QWidgetMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
+import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
+import com.kingsrook.qqq.backend.core.model.metadata.qbits.ProvidedOrSuppliedTableConfig;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
+import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+
+/*******************************************************************************
+ ** Custom apps on a real PostgreSQL 17: the schema owner creates the tables, the
+ ** application runs as a DML-only role, apps declared as rows reach the frontend
+ ** metadata per session, a custom screen shows a widget over local data, and the
+ ** apps' permissions (rows of the permission table the roles qbit manages) decide
+ ** who sees and renders it.
+ *******************************************************************************/
+class CustomAppsPostgresTest
+{
+ private static final String OWNER = "ca_owner";
+ private static final String RUNTIME = "ca_runtime";
+ private static final String BACKEND = "customAppsPg";
+ private static final String ITEMS = "demoItem";
+ private static final String WIDGET = "demoItemCountWidget";
+
+ private static GenericContainer> postgres;
+ private static String host;
+ private static int port;
+
+
+
+ /*******************************************************************************
+ ** PG17 with an owner-created schema (derived from the installed metadata) and a
+ ** DML-only runtime role.
+ *******************************************************************************/
+ @BeforeAll
+ static void start() throws Exception
+ {
+ postgres = new GenericContainer<>("postgres:17-alpine")
+ .withEnv("POSTGRES_PASSWORD", "super-synthetic")
+ .withExposedPorts(5432)
+ .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2).withStartupTimeout(java.time.Duration.ofMinutes(2)));
+ postgres.start();
+ host = postgres.getHost();
+ port = postgres.getMappedPort(5432);
+
+ try(Connection admin = connect("postgres", "super-synthetic", "postgres"); Statement statement = admin.createStatement())
+ {
+ statement.execute("CREATE ROLE " + OWNER + " LOGIN PASSWORD 'owner-synthetic'");
+ statement.execute("CREATE ROLE " + RUNTIME + " LOGIN PASSWORD 'runtime-synthetic'");
+ statement.execute("CREATE DATABASE customapps OWNER " + OWNER);
+ statement.execute("REVOKE ALL ON DATABASE customapps FROM PUBLIC");
+ statement.execute("GRANT CONNECT ON DATABASE customapps TO " + RUNTIME);
+ }
+
+ QInstance scratch = instance(OWNER, "owner-synthetic");
+ try(Connection owner = connect(OWNER, "owner-synthetic", "customapps"); Statement statement = owner.createStatement())
+ {
+ statement.execute("REVOKE ALL ON SCHEMA public FROM PUBLIC");
+ statement.execute("GRANT USAGE ON SCHEMA public TO " + RUNTIME);
+ for(QTableMetaData table : scratch.getTables().values())
+ {
+ if(BACKEND.equals(table.getBackendName()))
+ {
+ statement.execute(TestDdl.create(table));
+ statement.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON \"" + table.getName() + "\" TO " + RUNTIME);
+ }
+ }
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Stop the database.
+ *******************************************************************************/
+ @AfterAll
+ static void stop()
+ {
+ if(postgres != null)
+ {
+ postgres.stop();
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Clear the context.
+ *******************************************************************************/
+ @AfterEach
+ void clear()
+ {
+ QContext.clear();
+ }
+
+
+
+ /*******************************************************************************
+ ** Declared rows reach the frontend metadata per session; the screen shows a
+ ** widget over local data; the app is closed to anyone without its permission;
+ ** invalid rows are skipped rather than taking the others down.
+ *******************************************************************************/
+ @Test
+ void testDeclaredAppRendersAndIsPermissioned() throws Exception
+ {
+ QInstance instance = instance(RUNTIME, "runtime-synthetic");
+ QContext.init(instance, new QSystemUserSession());
+ seed();
+
+ /////////////////////////////////////////////////////////////////////////////////
+ // the customizers created one permission per app and container (URP rows) //
+ /////////////////////////////////////////////////////////////////////////////////
+ assertThat(new CountAction().execute(new CountInput("permission")).getCount()).isGreaterThanOrEqualTo(2);
+
+ var all = new CustomAppsDynamicMetaDataProducer().getCustomAppList(instance, name -> true);
+ assertThat(all.appMap()).containsKey("qcaOperationsBoard");
+ assertThat(all.appMap()).doesNotContainKeys("qcaMissingWidget", "qcaGeneratedName", "qcaNoWidget");
+ assertThat(all.appMap().get("qcaOperationsBoard").getWidgets()).containsExactly(WIDGET);
+ assertThat(all.appMap().get("qcaOperationsBoard").getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
+
+ /////////////////////////////////////////////////
+ // a user without the app permission sees none //
+ /////////////////////////////////////////////////
+ QContext.init(instance, new QSession().withPermissions(Set.of("unrelated.read")));
+ var nothing = new MetaDataAction().execute(new MetaDataInput());
+ assertThat(nothing.getApps()).doesNotContainKeys("qcaOperationsBoard", "qcacOperations");
+ assertThat(nothing.getAppTree().stream().map(n -> n.getName())).doesNotContain("qcacOperations");
+ assertThatThrownBy(() -> new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(instance.getWidget(WIDGET))))
+ .isInstanceOf(QPermissionDeniedException.class);
+
+ /////////////////////////////////////////////////////////////////////////////////
+ // a user granted the app (and its container) sees it and renders real DB data //
+ /////////////////////////////////////////////////////////////////////////////////
+ QContext.init(instance, new QSession().withPermissions(Set.of("qcaOperationsBoard.hasAccess", "qcacOperations.hasAccess", ITEMS + ".read")));
+ var visible = new MetaDataAction().execute(new MetaDataInput());
+ assertThat(visible.getApps()).containsKeys("qcaOperationsBoard", "qcacOperations");
+ assertThat(visible.getWidgets()).containsKey(WIDGET);
+ assertThat(visible.getAppTree().stream().map(n -> n.getName())).contains("qcacOperations");
+ RenderWidgetOutput rendered = new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(instance.getWidget(WIDGET)));
+ assertThat(((StatisticsData) rendered.getWidgetData()).getCount()).isEqualTo(3);
+
+ ////////////////////////////////////////////////////////////////////////////
+ // a user granted the app but not the container does not see it in the tree //
+ ////////////////////////////////////////////////////////////////////////////
+ QContext.init(instance, new QSession().withPermissions(Set.of("qcaOperationsBoard.hasAccess")));
+ assertThat(new MetaDataAction().execute(new MetaDataInput()).getAppTree().stream().map(n -> n.getName())).doesNotContain("qcacOperations");
+ }
+
+
+
+ /*******************************************************************************
+ ** The runtime role does DML only.
+ *******************************************************************************/
+ @Test
+ void testRuntimeRoleHasNoDdl() throws Exception
+ {
+ try(Connection runtime = connect(RUNTIME, "runtime-synthetic", "customapps"); Statement statement = runtime.createStatement())
+ {
+ assertThatThrownBy(() -> statement.execute("CREATE TABLE intruder (id int)")).isInstanceOf(SQLException.class).hasMessageContaining("permission denied");
+ assertThatThrownBy(() -> statement.execute("DROP TABLE \"" + CustomApp.TABLE_NAME + "\"")).isInstanceOf(SQLException.class);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Component bundles are code the browser runs: only paths on this application,
+ ** or https hosts the configuration lists, are accepted.
+ *******************************************************************************/
+ @Test
+ void testComponentSourceIsRestricted() throws Exception
+ {
+ QInstance instance = instance(RUNTIME, "runtime-synthetic");
+ QContext.init(instance, new QSystemUserSession());
+ CustomAppsDynamicMetaDataProducer producer = new CustomAppsDynamicMetaDataProducer();
+ assertThat(producer.isAllowedComponentSource("/dynamic-qfmd-components/q-embed-looker-dashboards.bundle.js")).isTrue();
+ assertThat(producer.isAllowedComponentSource("//evil.example/x.js")).isFalse();
+ assertThat(producer.isAllowedComponentSource("https://evil.example/x.js")).isFalse();
+ assertThat(producer.isAllowedComponentSource("javascript:alert(1)")).isFalse();
+ assertThat(producer.isAllowedComponentSource("data:text/javascript,alert(1)")).isFalse();
+ assertThat(producer.isAllowedComponentSource("/ok/../../etc/passwd")).isFalse();
+ assertThat(producer.isAllowedComponentSource("/a b.js")).isFalse();
+ assertThat(producer.isAllowedComponentSource(null)).isFalse();
+
+ QInstance allowing = instance(RUNTIME, "runtime-synthetic", List.of("Cdn.Example.com"));
+ QContext.init(allowing, new QSystemUserSession());
+ assertThat(producer.isAllowedComponentSource("https://cdn.example.com/x.js")).isTrue();
+ assertThat(producer.isAllowedComponentSource("https://cdn.example.com@evil.example/x.js")).isFalse();
+ assertThat(producer.isAllowedComponentSource("http://cdn.example.com/x.js")).isFalse();
+ }
+
+
+
+ /*******************************************************************************
+ ** The configuration requires its backend.
+ *******************************************************************************/
+ @Test
+ void testConfigValidation() throws Exception
+ {
+ List errors = new java.util.ArrayList<>();
+ new CustomAppsQBitConfig().validate(new QInstance(), errors);
+ assertThat(errors).anyMatch(e -> e.contains("backendName"));
+ }
+
+
+
+ /*******************************************************************************
+ ** Synthetic definitions: one valid local-widget app and three invalid ones.
+ *******************************************************************************/
+ private static void seed() throws QException
+ {
+ insert("customAppIcon", new QRecord().withValue("id", 1).withValue("name", "Dashboard").withValue("iconId", "dashboard"));
+ insert("customAppBackendConfig", new QRecord().withValue("id", 1).withValue("name", "Local widget").withValue("customAppBackendId", CustomAppBackendEnum.LOCAL_WIDGET.getId()));
+ insert("customAppContainer", new QRecord().withValue("id", 1).withValue("name", "Operations").withValue("customAppIconId", 1).withValue("sequenceNo", 1));
+ insert("customAppSection", new QRecord().withValue("id", 1).withValue("name", "Boards").withValue("customAppContainerId", 1).withValue("sequenceNo", 1));
+ insertApp(1, "Operations Board", WIDGET);
+ insertApp(2, "Missing Widget", "noSuchWidget");
+ insertApp(3, "Generated Name", "qcaOperationsBoardWidget");
+ insertApp(4, "No Widget", null);
+ for(int i = 1; i <= 3; i++)
+ {
+ insert(ITEMS, new QRecord().withValue("name", "Item " + i));
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** One app definition row.
+ *******************************************************************************/
+ private static void insertApp(int id, String name, String widgetName) throws QException
+ {
+ insert("customApp", new QRecord().withValue("id", id).withValue("name", name).withValue("customAppBackendConfigId", 1).withValue("customAppSectionId", 1)
+ .withValue("customAppIconId", 1).withValue("sequenceNo", id).withValue("widgetName", widgetName));
+ }
+
+
+
+ /*******************************************************************************
+ ** Insert a record.
+ *******************************************************************************/
+ private static void insert(String table, QRecord record) throws QException
+ {
+ List records = new InsertAction().execute(new InsertInput(table).withRecord(record)).getRecords();
+ assertThat(records.get(0).getErrors()).isEmpty();
+ }
+
+
+
+ /*******************************************************************************
+ ** An application on the given role.
+ *******************************************************************************/
+ private static QInstance instance(String user, String password) throws QException
+ {
+ return instance(user, password, List.of());
+ }
+
+
+
+ /*******************************************************************************
+ ** An application: native PG backend as a role, the roles qbit (whose permission
+ ** table the apps use), this qbit with its metadata customizer, a local table and
+ ** a permissioned widget over it.
+ *******************************************************************************/
+ private static QInstance instance(String user, String password, List allowedHosts) throws QException
+ {
+ QInstance instance = new QInstance();
+ instance.setEnforcePermissionsInCoreActions(true);
+ instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
+ PostgreSQLBackendMetaData backend = new PostgreSQLBackendMetaData();
+ backend.setName(BACKEND);
+ backend.setHostName(host);
+ backend.setPort(port);
+ backend.setDatabaseName("customapps");
+ backend.setUsername(user);
+ backend.setPassword(password);
+ instance.addBackend(backend);
+
+ new UserRolePermissionsQBitProducer().withUserRolePermissionsQBitConfig(new UserRolePermissionsQBitConfig()
+ .withUserTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed(BACKEND))
+ .withTableMetaDataCustomizer((i, table) ->
+ {
+ table.setBackendName(BACKEND);
+ return (table);
+ })).produce(instance);
+ new CustomAppsQBitProducer().withQBitConfig(new CustomAppsQBitConfig().withBackendName(BACKEND).withAllowedComponentHosts(allowedHosts)).produce(instance).addSelfToInstance(instance);
+ instance.setMetaDataActionCustomizer(new QCodeReference(CustomAppsMetaDataActionCustomizer.class));
+
+ instance.addTable(new QTableMetaData().withName(ITEMS).withBackendName(BACKEND).withPrimaryKeyField("id")
+ .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS).withPermissionBaseName(ITEMS))
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
+ .withField(new QFieldMetaData("name", QFieldType.STRING).withMaxLength(100)));
+ instance.addWidget(new QWidgetMetaData().withName(WIDGET).withType(WidgetType.STATISTICS.getType())
+ .withCodeReference(new QCodeReference(ItemCountWidgetRenderer.class))
+ .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("qcaOperationsBoard")));
+ new QInstanceValidator().validate(instance);
+ return instance;
+ }
+
+
+
+ /*******************************************************************************
+ ** A JDBC connection as a named role.
+ *******************************************************************************/
+ private static Connection connect(String user, String password, String database) throws SQLException
+ {
+ return DriverManager.getConnection("jdbc:postgresql://" + host + ":" + port + "/" + database, user, password);
+ }
+
+
+
+ /*******************************************************************************
+ ** A widget that counts the rows of the local table, as the viewing user.
+ *******************************************************************************/
+ public static class ItemCountWidgetRenderer extends AbstractWidgetRenderer
+ {
+ /***************************************************************************
+ ** Render the count.
+ ***************************************************************************/
+ @Override
+ public RenderWidgetOutput render(RenderWidgetInput input) throws QException
+ {
+ Integer count = new CountAction().execute(new CountInput(ITEMS)).getCount();
+ return (new RenderWidgetOutput(new StatisticsData().withCount(count)));
+ }
+ }
+}
diff --git a/src/test/java/com/kingsrook/qbits/customapps/TestDdl.java b/src/test/java/com/kingsrook/qbits/customapps/TestDdl.java
new file mode 100644
index 0000000..6ce415c
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/customapps/TestDdl.java
@@ -0,0 +1,87 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qbits.customapps;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
+
+
+/*******************************************************************************
+ ** PostgreSQL DDL derived from QQQ table metadata (exact, quoted names, as QQQ
+ ** quotes them), so a test schema is exactly what the producer defined.
+ *******************************************************************************/
+final class TestDdl
+{
+ /*******************************************************************************
+ ** Utility class.
+ *******************************************************************************/
+ private TestDdl()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** One CREATE TABLE.
+ *******************************************************************************/
+ static String create(QTableMetaData table)
+ {
+ List lines = new ArrayList<>();
+ for(QFieldMetaData field : table.getFields().values())
+ {
+ lines.add(" \"" + field.getName() + "\" " + type(table, field));
+ }
+ for(UniqueKey key : table.getUniqueKeys() == null ? List.of() : table.getUniqueKeys())
+ {
+ lines.add(" UNIQUE (" + String.join(", ", key.getFieldNames().stream().map(n -> "\"" + n + "\"").toList()) + ")");
+ }
+ return "CREATE TABLE \"" + table.getName() + "\" (\n" + String.join(",\n", lines) + "\n)";
+ }
+
+
+
+ /*******************************************************************************
+ ** Column type and constraints.
+ *******************************************************************************/
+ private static String type(QTableMetaData table, QFieldMetaData field)
+ {
+ if(field.getName().equals(table.getPrimaryKeyField()))
+ {
+ return "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY";
+ }
+ String base = switch(field.getType())
+ {
+ case STRING, PASSWORD -> field.getMaxLength() == null ? "TEXT" : "VARCHAR(" + field.getMaxLength() + ")";
+ case TEXT, HTML -> "TEXT";
+ case INTEGER -> "INTEGER";
+ case LONG -> "BIGINT";
+ case BOOLEAN -> "BOOLEAN";
+ case DATE_TIME -> "TIMESTAMP WITH TIME ZONE";
+ default -> throw new IllegalStateException("Unsupported type " + field.getType());
+ };
+ return base + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : "");
+ }
+}