feat: per-session custom apps, a local widget backend and restricted permissions on QQQ 4.1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 15s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 15s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 53s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 10s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Failing after 3m1s
Shared Java Maven CI / verify (push) Failing after 1m12s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
Shared Java Maven CI / gate (push) Failing after 10s
qrun-release / release (push) Failing after 3m25s
qrun-release / postpublish (push) Skipped

Port the host-proven Custom Apps work onto upstream develop (9ba3336f):
- LOCAL_WIDGET backend for custom screens that show a widget over local data, and the
  allowed-component-hosts and backend-name settings in CustomAppsQBitConfig (https hosts
  only, lower-cased, no embedded credentials);
- CustomAppsMetaDataActionCustomizer, which gives each session exactly the containers,
  apps and widgets whose "<name>.hasAccess" permission it holds;
- permission widgets for custom apps and custom app containers (the roles that hold the
  permission, as a child record list) and the metadata producer changes behind them;
- CustomAppsPostgresTest and TestDdl: the schema owner creates the tables, the application
  runs as a DML-only role, on a real PostgreSQL 17 (Testcontainers, UTC surefire argLine).

Build against the published QQQ 4.1 core (qqq-bom-pom 4.1.0-RC.21) and
qbit-user-role-permissions 0.33.0-RC.1 (canonical registry and Maven Central; its core
dependency is excluded so the QQQ BOM governs). The only SNAPSHOT left is the project's own
development revision. Every qqq class the sources import exists in the published RC.21 and
URP jars and every QQQ-style call name resolves in them (read-only javap); not compiled here.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Sonnet 5.5 committed 2026-11-05 09:07:08 -06:00
1 parent 9ba3336fe9
commit 2b95715c35
10 files changed
+1017 -17

No files matched your search

+42 -9
View File
@@ -28,6 +28,10 @@
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.showDeprecation>true</maven.compiler.showDeprecation>
<maven.compiler.showWarnings>true</maven.compiler.showWarnings>
<!-- jacoco sets argLine; the empty default keeps the UTC surefire argLine below valid when jacoco is not active -->
<argLine></argLine>
<!-- qbit-user-role-permissions built for the QQQ 4.1 family: 0.33.0-RC.1, read back from the canonical registry and on Maven Central (flattened POM, QQQ 4.1.0-RC.21). -->
<urp.version>0.33.0-RC.1</urp.version>
</properties>
<parent>
@@ -56,6 +60,16 @@
<modelVersion>4.0.0</modelVersion>
<build>
<plugins>
<plugin>
<!-- PostgreSQL instants (CustomAppsPostgresTest) are only correct under a UTC JVM -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -Duser.timezone=UTC</argLine>
</configuration>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
@@ -87,7 +101,9 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>0.40.0</version>
<!-- The LOCAL_WIDGET backend, per-session metadata customizer and restricted-permission code need the QQQ 4.1 family: the published core
(Central JAR/POM), the one BOM version shared by the four app-QBit patches. -->
<version>4.1.0-RC.21</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -98,12 +114,17 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>0.40.0</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-user-role-permissions</artifactId>
<version>0.31.0</version>
<version>${urp.version}</version>
<exclusions>
<exclusion>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -115,11 +136,6 @@
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-material-dashboard</artifactId>
<version>0.40.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
@@ -129,7 +145,24 @@
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-postgres</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.220</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -44,6 +44,10 @@ public class CustomAppsQBitConfig implements QBitConfig
private boolean includeApiVersions = false;
private String componentSourceUrl;
private String backendName;
private java.util.Set<String> allowedComponentHosts = new java.util.LinkedHashSet<>();
/*******************************************************************************
@@ -63,6 +67,7 @@ public class CustomAppsQBitConfig implements QBitConfig
@Override
public void validate(QInstance qInstance, List<String> 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<String> getAllowedComponentHosts()
{
return (this.allowedComponentHosts);
}
/*******************************************************************************
** Fluent setter for allowedComponentHosts
*******************************************************************************/
public CustomAppsQBitConfig withAllowedComponentHosts(java.util.Collection<String> hosts)
{
this.allowedComponentHosts = new java.util.LinkedHashSet<>();
hosts.forEach(host -> this.allowedComponentHosts.add(host.toLowerCase()));
return (this);
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<QWidgetMetaData>
{
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);
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<QWidgetMetaData>
{
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);
}
}
@@ -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<String> 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<String> 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<String, Serializable> 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<QAppMetaData> containerList, Map<String, String> positionMap, Map<String, QAppMetaData> appMap, Map<String, QWidgetMetaDataInterface> widgetMap) {}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
** "&lt;name&gt;.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<AppTreeNode> 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);
}
}
@@ -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);
}
}
@@ -33,7 +33,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMeta
public enum CustomAppBackendEnum implements PossibleValueEnum<Integer>
{
LOOKER(1, "Looker"),
URL(2, "URL");
URL(2, "URL"),
LOCAL_WIDGET(3, "Local widget");
private final Integer id;
private final String label;
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> 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<QRecord> 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<String> 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)));
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> lines = new ArrayList<>();
for(QFieldMetaData field : table.getFields().values())
{
lines.add(" \"" + field.getName() + "\" " + type(table, field));
}
for(UniqueKey key : table.getUniqueKeys() == null ? List.<UniqueKey>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" : "");
}
}