From 25534480311718da074c8f92cea20c3efa16dd92 Mon Sep 17 00:00:00 2001
From: pandor4u <103976470+pandor4u@users.noreply.github.com>
Date: Fri, 10 Apr 2026 17:46:36 -0500
Subject: [PATCH 1/3] feat: PostgreSQL-backed search/document storage as
ElasticSearch alternative
Simplified PR addressing review feedback:
- Consolidated duplicated INSERT...ON CONFLICT SQL into shared constant
- Removed fork-specific files (AGENTS.md, CLAUDE.md, GEMINI.md, docker compose, .gitignore)
- Moved PostgreSQL tests to separate opt-in suite (MoquiSuite untouched)
- Updated ElasticRequestLogFilter to use ElasticClient interface type
- 83 tests: 46 unit (query translation + SQL injection) + 37 integration
New files:
- PostgresElasticClient.groovy: Full ElasticClient impl using JSONB/tsvector
- ElasticQueryTranslator.groovy: ES Query DSL to PostgreSQL SQL
- PostgresSearchLogger.groovy: Log4j2 appender for PostgreSQL
- SearchEntities.xml: Entity definitions for search tables
- PostgresSearchSuite.groovy: Separate JUnit test suite
- PostgresSearchTranslatorTests.groovy: Unit tests
- PostgresElasticClientTests.groovy: Integration tests
Modified files:
- ElasticFacadeImpl.groovy: type=postgres routing
- ElasticRequestLogFilter.groovy: Interface type usage
- MoquiDefaultConf.xml: Postgres config + entity load
- moqui-conf-3.xsd: type attribute with elastic/postgres enum
- build.gradle: Test dependencies and suite include
---
framework/build.gradle | 3 +
framework/entity/SearchEntities.xml | 115 ++
.../impl/context/ElasticFacadeImpl.groovy | 54 +-
.../context/ElasticQueryTranslator.groovy | 675 ++++++++++
.../impl/context/PostgresElasticClient.groovy | 1176 +++++++++++++++++
.../impl/util/PostgresSearchLogger.groovy | 244 ++++
.../webapp/ElasticRequestLogFilter.groovy | 10 +-
.../src/main/resources/MoquiDefaultConf.xml | 11 +-
.../groovy/PostgresElasticClientTests.groovy | 701 ++++++++++
.../test/groovy/PostgresSearchSuite.groovy | 30 +
.../PostgresSearchTranslatorTests.groovy | 478 +++++++
framework/xsd/moqui-conf-3.xsd | 25 +-
12 files changed, 3491 insertions(+), 31 deletions(-)
create mode 100644 framework/entity/SearchEntities.xml
create mode 100644 framework/src/main/groovy/org/moqui/impl/context/ElasticQueryTranslator.groovy
create mode 100644 framework/src/main/groovy/org/moqui/impl/context/PostgresElasticClient.groovy
create mode 100644 framework/src/main/groovy/org/moqui/impl/util/PostgresSearchLogger.groovy
create mode 100644 framework/src/test/groovy/PostgresElasticClientTests.groovy
create mode 100644 framework/src/test/groovy/PostgresSearchSuite.groovy
create mode 100644 framework/src/test/groovy/PostgresSearchTranslatorTests.groovy
diff --git a/framework/build.gradle b/framework/build.gradle
index 335fe6a97..d4d00afd9 100644
--- a/framework/build.gradle
+++ b/framework/build.gradle
@@ -171,6 +171,8 @@ dependencies {
testImplementation 'org.junit.platform:junit-platform-suite:6.0.1'
// junit-jupiter-api for using JUnit directly, not generally needed for Spock based tests
testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.1'
+ // junit-jupiter-engine required to execute @Test-annotated methods via JUnit Platform
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:6.0.1'
// Spock Framework
testImplementation platform('org.spockframework:spock-bom:2.4-groovy-5.0') // Apache 2.0
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' // Apache 2.0
@@ -201,6 +203,7 @@ test {
dependsOn cleanTest
include '**/*MoquiSuite.class'
+ include '**/*PostgresSearchSuite.class'
systemProperty 'moqui.runtime', '../runtime'
systemProperty 'moqui.conf', 'conf/MoquiDevConf.xml'
diff --git a/framework/entity/SearchEntities.xml b/framework/entity/SearchEntities.xml
new file mode 100644
index 000000000..0ed222b5c
--- /dev/null
+++ b/framework/entity/SearchEntities.xml
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/framework/src/main/groovy/org/moqui/impl/context/ElasticFacadeImpl.groovy b/framework/src/main/groovy/org/moqui/impl/context/ElasticFacadeImpl.groovy
index 72ffd6ddc..8f26ba848 100644
--- a/framework/src/main/groovy/org/moqui/impl/context/ElasticFacadeImpl.groovy
+++ b/framework/src/main/groovy/org/moqui/impl/context/ElasticFacadeImpl.groovy
@@ -32,6 +32,7 @@ import org.moqui.impl.entity.EntityDefinition
import org.moqui.impl.entity.EntityJavaUtil
import org.moqui.impl.entity.FieldInfo
import org.moqui.impl.util.ElasticSearchLogger
+import org.moqui.impl.util.PostgresSearchLogger
import org.moqui.util.LiteStringMap
import org.moqui.util.MNode
import org.moqui.util.RestClient
@@ -69,8 +70,9 @@ class ElasticFacadeImpl implements ElasticFacade {
}
public final ExecutionContextFactoryImpl ecfi
- private final Map clientByClusterName = new LinkedHashMap<>()
+ private final Map clientByClusterName = new LinkedHashMap<>()
private ElasticSearchLogger esLogger = null
+ private PostgresSearchLogger pgLogger = null
ElasticFacadeImpl(ExecutionContextFactoryImpl ecfi) {
this.ecfi = ecfi
@@ -90,14 +92,21 @@ class ElasticFacadeImpl implements ElasticFacade {
logger.warn("ElasticFacade Client for cluster ${clusterName} already initialized, skipping")
continue
}
- if (!clusterUrl) {
- logger.warn("ElasticFacade Client for cluster ${clusterName} has no url, skipping")
- continue
- }
+ String clusterType = clusterNode.attribute("type") ?: "elastic"
try {
- ElasticClientImpl elci = new ElasticClientImpl(clusterNode, ecfi)
- clientByClusterName.put(clusterName, elci)
+ if ("postgres".equals(clusterType)) {
+ PostgresElasticClient pgc = new PostgresElasticClient(clusterNode, ecfi)
+ clientByClusterName.put(clusterName, pgc)
+ logger.info("Initialized PostgresElasticClient for cluster ${clusterName}")
+ } else {
+ if (!clusterUrl) {
+ logger.warn("ElasticFacade Client for cluster ${clusterName} has no url, skipping")
+ continue
+ }
+ ElasticClientImpl elci = new ElasticClientImpl(clusterNode, ecfi)
+ clientByClusterName.put(clusterName, elci)
+ }
} catch (Throwable t) {
Throwable cause = t.getCause()
if (cause != null && cause.message.contains("refused")) {
@@ -108,22 +117,29 @@ class ElasticFacadeImpl implements ElasticFacade {
}
}
- // init ElasticSearchLogger
- if (esLogger == null || !esLogger.isInitialized()) {
- ElasticClientImpl loggerEci = clientByClusterName.get("logger") ?: clientByClusterName.get("default")
- if (loggerEci != null) {
- logger.info("Initializing ElasticSearchLogger with cluster ${loggerEci.getClusterName()}")
- esLogger = new ElasticSearchLogger(loggerEci, ecfi)
+ // init ElasticSearchLogger / PostgresSearchLogger depending on backend type
+ ElasticClient loggerClient = clientByClusterName.get("logger") ?: clientByClusterName.get("default")
+ if (loggerClient instanceof PostgresElasticClient) {
+ if (pgLogger == null || !pgLogger.isInitialized()) {
+ logger.info("Initializing PostgresSearchLogger with cluster ${loggerClient.getClusterName()}")
+ pgLogger = new PostgresSearchLogger((PostgresElasticClient) loggerClient, ecfi)
+ } else {
+ logger.warn("PostgresSearchLogger in place and initialized, skipping")
+ }
+ } else if (loggerClient instanceof ElasticClientImpl) {
+ if (esLogger == null || !esLogger.isInitialized()) {
+ logger.info("Initializing ElasticSearchLogger with cluster ${loggerClient.getClusterName()}")
+ esLogger = new ElasticSearchLogger((ElasticClientImpl) loggerClient, ecfi)
} else {
- logger.warn("No Elastic Client found with name 'logger' or 'default', not initializing ElasticSearchLogger")
+ logger.warn("ElasticSearchLogger in place and initialized, skipping")
}
} else {
- logger.warn("ElasticSearchLogger in place and initialized, not initializing ElasticSearchLogger")
+ logger.warn("No Elastic/Postgres Client found with name 'logger' or 'default', not initializing search logger")
}
// Index DataFeed with indexOnStartEmpty=Y
try {
- ElasticClientImpl defaultEci = clientByClusterName.get("default")
+ ElasticClient defaultEci = clientByClusterName.get("default")
if (defaultEci != null) {
EntityList dataFeedList = ecfi.entityFacade.find("moqui.entity.feed.DataFeed")
.condition("indexOnStartEmpty", "Y").disableAuthz().list()
@@ -151,7 +167,11 @@ class ElasticFacadeImpl implements ElasticFacade {
void destroy() {
if (esLogger != null) esLogger.destroy()
- for (ElasticClientImpl eci in clientByClusterName.values()) eci.destroy()
+ if (pgLogger != null) pgLogger.destroy()
+ for (ElasticClient eci in clientByClusterName.values()) {
+ if (eci instanceof ElasticClientImpl) ((ElasticClientImpl) eci).destroy()
+ else if (eci instanceof PostgresElasticClient) ((PostgresElasticClient) eci).destroy()
+ }
}
@Override ElasticClient getDefault() { return clientByClusterName.get("default") }
diff --git a/framework/src/main/groovy/org/moqui/impl/context/ElasticQueryTranslator.groovy b/framework/src/main/groovy/org/moqui/impl/context/ElasticQueryTranslator.groovy
new file mode 100644
index 000000000..887aa1237
--- /dev/null
+++ b/framework/src/main/groovy/org/moqui/impl/context/ElasticQueryTranslator.groovy
@@ -0,0 +1,675 @@
+/*
+ * This software is in the public domain under CC0 1.0 Universal plus a
+ * Grant of Patent License.
+ *
+ * To the extent possible under law, the author(s) have dedicated all
+ * copyright and related and neighboring rights to this software to the
+ * public domain worldwide. This software is distributed without any
+ * warranty.
+ *
+ * You should have received a copy of the CC0 Public Domain Dedication
+ * along with this software (see the LICENSE.md file). If not, see
+ * .
+ */
+package org.moqui.impl.context
+
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+/**
+ * Translates ElasticSearch/OpenSearch Query DSL (Map structures) into PostgreSQL SQL WHERE clauses,
+ * ORDER BY expressions, and OFFSET/LIMIT pagination for use by PostgresElasticClient.
+ *
+ * Supports the query types used by Moqui's SearchServices.xml and entity condition makeSearchFilter() methods:
+ * - query_string (→ websearch_to_tsquery / plainto_tsquery on content_tsv)
+ * - bool (must / should / must_not / filter)
+ * - term, terms
+ * - range
+ * - match_all
+ * - exists
+ * - nested (→ jsonb_array_elements EXISTS subquery)
+ */
+class ElasticQueryTranslator {
+ private final static Logger logger = LoggerFactory.getLogger(ElasticQueryTranslator.class)
+
+ /** Regex pattern for valid field names — alphanumeric, underscores, dots, hyphens, and @ (for @timestamp) */
+ private static final java.util.regex.Pattern SAFE_FIELD_PATTERN = java.util.regex.Pattern.compile('^[a-zA-Z0-9_@][a-zA-Z0-9_.\\-]*$')
+
+ /**
+ * Validate that a field name is safe for interpolation into SQL.
+ * Rejects any field containing SQL metacharacters (quotes, semicolons, parentheses, etc.)
+ * @throws IllegalArgumentException if the field name contains unsafe characters
+ */
+ static String sanitizeFieldName(String field) {
+ if (field == null || field.isEmpty()) throw new IllegalArgumentException("Field name must not be empty")
+ if (!SAFE_FIELD_PATTERN.matcher(field).matches()) {
+ throw new IllegalArgumentException("Unsafe field name rejected: '${field}' — only alphanumeric, underscore, dot, hyphen, and @ allowed")
+ }
+ if (field.contains("--")) {
+ throw new IllegalArgumentException("Unsafe field name rejected: '${field}' — double-hyphen (SQL comment) not allowed")
+ }
+ if (field.length() > 256) {
+ throw new IllegalArgumentException("Field name too long (max 256 chars): '${field}'")
+ }
+ return field
+ }
+
+ /** Holds the result of translating a query DSL fragment or full search request */
+ static class TranslatedQuery {
+ /** SQL WHERE clause fragment (without the "WHERE" keyword), or "TRUE" if no filter */
+ String whereClause = "TRUE"
+ /** JDBC bind parameters in order corresponding to ? placeholders in whereClause */
+ List