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