Compare commits

..
Author SHA1 Message Date
James MaesandCodex c818e3f0fa ci: enable branch-aware shared CodeQL scanning
Add the reviewed immutable shared CodeQL caller. Preserve existing source languages and add Actions scanning. Local actionlint and actual source-inventory selection passed. Merge requires accepted exact-head scanner uploads and existing CI gates. Use a ci: squash commit so the published orb selects test-only CI maintenance without artifact publication.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-10-29 16:09:04 -05:00
12 changed files with 24 additions and 1031 deletions

No files matched your search

-7
View File
@@ -1,7 +0,0 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+5 -5
View File
@@ -2,7 +2,7 @@
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
> **Status:** 0.1 release line, built on QQQ 4.1 (`4.1.0-RC.22`) and `qbit-user-role-permissions` `0.33.0-RC.1`. Published to the QRun-IO Gitea Maven registry only (`https://git.kof22.com/api/packages/QRun-IO/maven`); release candidates are `0.1.0-RC.N`.
> **Status:** ⚠️ **Experimental / scaffold.** This qbit declares dependencies against `0.9999.0` sentinel versions of `qqq-backend-core` and `qqq-frontend-material-dashboard`, which means it does **not** consume a released framework version. Treat as exploratory until a maintainer confirms its intended state.
QBit that aims to let qqq apps register **custom application screens** declaratively — pages built from widgets, forms, and table views — without writing dedicated front-end components.
@@ -27,15 +27,15 @@ src/main/java/com/kingsrook/qbits/customapps/
## How it relates to other qqq repos
- **Declared dependencies:** `qqq-backend-core`, `qqq-middleware-api` (optional), `qqq-middleware-javalin`, `qbit-user-role-permissions`; test scope: `qqq-backend-module-rdbms`, `qqq-backend-module-postgres`. All versions come from the `qqq-bom-pom` import (`4.1.0-RC.22`).
- **Declared dependencies:** `qqq-backend-core`, `qqq-backend-module-rdbms`, `qqq-frontend-material-dashboard`, `qqq-middleware-api`, `qqq-middleware-javalin` — all currently pinned to the `0.9999.0` sentinel version (development build).
- **Inherits from:** `qbit-build-parent` (in the `qbit-bom` repo).
- **Frontend pair:** Custom apps render via the standard qqq dashboard; no separate frontend component needed.
## Known gaps
- Integration tests (`CustomAppsPostgresTest`) use a PostgreSQL Testcontainer, so building needs Docker.
- Pre-1.0: the metadata and permission model may still change between release candidates.
- Depends on QQQ 4.1 release candidates, so it is not yet pinned to a final QQQ 4.1.0.
- No automated tests at the time this README was added.
- Sentinel `0.9999.0` versions indicate this qbit is not currently consuming any released qqq build — meaning it can't be cleanly installed by a downstream app.
- This README was added by an audit pass; original maintainer should verify intent and either pin to a real qqq version or mark the repo archived.
## License
+11 -44
View File
@@ -23,21 +23,17 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.3.0-SNAPSHOT</revision>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<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, latest in the QRun-IO Gitea Maven registry. -->
<urp.version>0.33.0-RC.2</urp.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.2.1</version>
<version>1.5.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -60,16 +56,6 @@
<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>
@@ -101,9 +87,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<!-- The LOCAL_WIDGET backend, per-session metadata customizer and restricted-permission code need the QQQ 4.1 family: the published core
(QRun-IO Gitea Maven registry), the one BOM version shared by the four app-QBit patches. -->
<version>4.1.0-RC.25</version>
<version>0.40.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -114,17 +98,12 @@
<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>${urp.version}</version>
<exclusions>
<exclusion>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
</exclusion>
</exclusions>
<version>0.31.0</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -136,6 +115,11 @@
<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>
@@ -145,24 +129,7 @@
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<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>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -44,10 +44,6 @@ 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<>();
/*******************************************************************************
@@ -67,7 +63,6 @@ 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.");
@@ -178,59 +173,4 @@ 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);
}
}
@@ -1,68 +0,0 @@
/*
* 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);
}
}
@@ -1,68 +0,0 @@
/*
* 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,7 +28,6 @@ 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;
@@ -39,8 +38,6 @@ 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;
@@ -79,42 +76,6 @@ 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 //
@@ -191,7 +152,7 @@ public class CustomAppsDynamicMetaDataProducer
/////////////////////////////////////////////////////
// make sure user has permission to this app //
////////////////////////////////////////////////////
if(!permitted.test(appName))
if(!QContext.getQSession().hasPermission(appName + ".hasAccess"))
{
continue;
}
@@ -200,21 +161,8 @@ public class CustomAppsDynamicMetaDataProducer
// create a custom widget for this app //
/////////////////////////////////////////
String widgetName = appName + "Widget";
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());
QWidgetMetaDataInterface widget = buildWidget(qInstance, app, customAppBackendConfigMap, widgetName);
CustomAppIcon appIcon = customAppIconMap.get(app.getCustomAppIconId());
QAppMetaData appMetaData = new QAppMetaData()
.withPermissionRules(qInstance.getDefaultPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(appName).withDenyBehavior(DenyBehavior.HIDDEN))
@@ -269,7 +217,7 @@ public class CustomAppsDynamicMetaDataProducer
/////////////////////////////////////////////////////
// make sure user has permission to this container //
/////////////////////////////////////////////////////
if(!permitted.test(containerName))
if(!QContext.getQSession().hasPermission(containerName + ".hasAccess"))
{
continue;
}
@@ -300,8 +248,6 @@ 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());
}
@@ -310,34 +256,6 @@ 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
*******************************************************************************/
@@ -353,19 +271,10 @@ 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(
@@ -378,7 +287,7 @@ public class CustomAppsDynamicMetaDataProducer
QWidgetMetaData lookerWidget = new QWidgetMetaData()
.withName(widgetName)
.withPermissionRules(qInstance.getDefaultPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(customApp.getName() == null ? widgetName : CustomAppsUtils.getCamelCaseName(customApp.getName(), CustomApp.TABLE_NAME)).withDenyBehavior(DenyBehavior.HIDDEN))
.withPermissionRules(qInstance.getDefaultPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(widgetName + ".hasAccess").withDenyBehavior(DenyBehavior.HIDDEN))
.withType(WidgetType.CUSTOM_COMPONENT.getType())
.withCodeReference(new QCodeReference(LookerWidgetRenderer.class))
.withGridColumns(12)
@@ -395,51 +304,5 @@ 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) {}
}
@@ -1,117 +0,0 @@
/*
* 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", "widgetName", "permissionId"))
.withSection(SectionFactory.defaultT2("name", "customAppIconId", "customAppBackendConfigId", "customAppSectionId", "sequenceNo", "lookerDashboardId", "permissionId"))
.withSection(new QFieldSection("users", new QIcon().withName("person"), Tier.T2).withWidgetName("customAppPermissions"))
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"))
@@ -115,12 +115,9 @@ public class CustomApp extends QRecordEntity implements Serializable
@QField(isEditable = false, possibleValueSourceName = Permission.TABLE_NAME)
private Integer permissionId;
@QField(label = "Looker Dashboard Id")
@QField(isRequired = true)
private Integer lookerDashboardId;
@QField(label = "Local Widget", maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String widgetName;
@QField(isEditable = false)
private Instant createDate;
@@ -456,35 +453,4 @@ 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,8 +33,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMeta
public enum CustomAppBackendEnum implements PossibleValueEnum<Integer>
{
LOOKER(1, "Looker"),
URL(2, "URL"),
LOCAL_WIDGET(3, "Local widget");
URL(2, "URL");
private final Integer id;
private final String label;
@@ -1,395 +0,0 @@
/*
* 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)));
}
}
}
@@ -1,87 +0,0 @@
/*
* 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" : "");
}
}