From fae160896a7d40e6ada264b0e0a35bd6ae67ef75 Mon Sep 17 00:00:00 2001
From: pandor4u <103976470+pandor4u@users.noreply.github.com>
Date: Fri, 27 Feb 2026 18:17:34 -0600
Subject: [PATCH 1/2] feat: PostgreSQL-backed search/document storage as
ElasticSearch alternative
Add a new 'postgres' ElasticClient type that implements the full
ElasticFacade.ElasticClient interface using PostgreSQL with JSONB
document storage, tsvector full-text search, and GIN indexes.
Key changes:
- PostgresElasticClient: full ElasticClient implementation backed by
PostgreSQL tables (moqui_search_index, moqui_logs, moqui_http_log)
- ElasticQueryTranslator: translates ES Query DSL (bool, term, terms,
range, nested, exists, match_all, query_string, ids) into
parameterized PostgreSQL SQL with sanitized field names
- PostgresSearchLogger: Log4j2 appender writing to PostgreSQL
- SearchEntities.xml: entity definitions with JSONB, tsvector, GIN indexes
- Security hardening: field name sanitization, parameterized queries,
env-var credentials in Docker, TLS 1.2 minimum
- Comprehensive test suite: 83 tests covering query translation, CRUD,
bulk indexing, search, and SQL injection prevention
Configuration: set elastic-client type="postgres" in Moqui XML conf.
No external search engine dependency required.
---
.gitignore | 12 +
docker/moqui-postgres-only-compose.yml | 88 ++
framework/build.gradle | 3 +
framework/entity/SearchEntities.xml | 115 ++
.../impl/context/ElasticFacadeImpl.groovy | 55 +-
.../context/ElasticQueryTranslator.groovy | 659 +++++++++
.../impl/context/PostgresElasticClient.groovy | 1270 +++++++++++++++++
.../impl/util/PostgresSearchLogger.groovy | 244 ++++
.../webapp/ElasticRequestLogFilter.groovy | 10 +-
.../src/main/resources/MoquiDefaultConf.xml | 11 +-
framework/src/test/groovy/MoquiSuite.groovy | 3 +-
.../groovy/PostgresElasticClientTests.groovy | 701 +++++++++
.../test/groovy/PostgresSearchSuite.groovy | 24 +
.../PostgresSearchTranslatorTests.groovy | 478 +++++++
framework/xsd/moqui-conf-3.xsd | 25 +-
15 files changed, 3666 insertions(+), 32 deletions(-)
create mode 100644 docker/moqui-postgres-only-compose.yml
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/.gitignore b/.gitignore
index c2affbc78..8c4f0c9b1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,18 @@ build
/moqui*.war
/Save*.zip
+# Sensitive files — do not commit
+/cookies.txt
+
+# Playwright MCP (local browser automation tool logs)
+.playwright-mcp/
+
+# Planning / design docs (not part of shipped code)
+/POSTGRES_SEARCH_PLAN.md
+
+# Log files
+*.log
+
# runtime directory (separate repository so ignore directory entirely)
/runtime
/execwartmp
diff --git a/docker/moqui-postgres-only-compose.yml b/docker/moqui-postgres-only-compose.yml
new file mode 100644
index 000000000..3a8633143
--- /dev/null
+++ b/docker/moqui-postgres-only-compose.yml
@@ -0,0 +1,88 @@
+# A Docker Compose application with Moqui and PostgreSQL ONLY — no OpenSearch/ElasticSearch.
+# All document storage, full-text search, and logging are handled by PostgreSQL using JSONB
+# and tsvector, via the PostgresElasticClient (type="postgres") backend.
+
+# Run with something like this for detached mode:
+# $ docker compose -f moqui-postgres-only-compose.yml -p moqui up -d
+
+# Or via the compose-run.sh helper:
+# $ ./compose-run.sh moqui-postgres-only-compose.yml
+
+# To configure Moqui, add the following to your runtime/conf/MoquiConf.xml:
+#
+#
+#
+#
+
+version: "2"
+services:
+ nginx-proxy:
+ image: jwilder/nginx-proxy
+ container_name: nginx-proxy
+ restart: always
+ ports:
+ - 80:80
+ - 443:443
+ volumes:
+ - /var/run/docker.sock:/tmp/docker.sock:ro
+ - ./certs:/etc/nginx/certs
+ - ./nginx/my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf
+ environment:
+ - DEFAULT_HOST=moqui.local
+ - SSL_POLICY=AWS-TLS-1-2-2017-01
+
+ moqui-server:
+ image: moqui
+ container_name: moqui-server
+ command: conf=conf/MoquiProductionConf.xml
+ restart: always
+ links:
+ - moqui-database
+ volumes:
+ - ./runtime/conf:/opt/moqui/runtime/conf
+ - ./runtime/lib:/opt/moqui/runtime/lib
+ - ./runtime/classes:/opt/moqui/runtime/classes
+ - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent
+ - ./runtime/log:/opt/moqui/runtime/log
+ - ./runtime/txlog:/opt/moqui/runtime/txlog
+ - ./runtime/sessions:/opt/moqui/runtime/sessions
+ environment:
+ - "JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m"
+ - instance_purpose=production
+ - entity_ds_db_conf=postgres
+ - entity_ds_host=moqui-database
+ - entity_ds_port=5432
+ - entity_ds_database=moqui
+ - entity_ds_schema=public
+ - entity_ds_user=${MOQUI_DS_USER:-moqui}
+ - entity_ds_password=${MOQUI_DS_PASSWORD:?Set MOQUI_DS_PASSWORD}
+ - entity_ds_crypt_pass=${MOQUI_DS_CRYPT_PASS:?Set MOQUI_DS_CRYPT_PASS}
+ # ---- PostgreSQL-backed search (no OpenSearch required) ----
+ # Override elastic-facade in your MoquiConf.xml:
+ #
+ #
+ #
+ # Or set an empty elasticsearch_url so the default cluster is skipped:
+ - elasticsearch_url=
+ # VIRTUAL_HOST for nginx-proxy
+ - VIRTUAL_HOST=moqui.local
+ - webapp_http_port=80
+ - webapp_https_port=443
+ - webapp_https_enabled=true
+ - webapp_client_ip_header=X-Real-IP
+ - default_locale=en_US
+ - default_time_zone=US/Pacific
+
+ moqui-database:
+ image: postgres:14.5
+ container_name: moqui-database
+ restart: always
+ ports:
+ - 127.0.0.1:5432:5432
+ volumes:
+ - ./db/postgres/data:/var/lib/postgresql/data
+ environment:
+ - POSTGRES_DB=${MOQUI_DS_DB:-moqui}
+ - POSTGRES_DB_SCHEMA=public
+ - POSTGRES_USER=${MOQUI_DS_USER:-moqui}
+ - POSTGRES_PASSWORD=${MOQUI_DS_PASSWORD:?Set MOQUI_DS_PASSWORD}
diff --git a/framework/build.gradle b/framework/build.gradle
index c19e9be57..a2b970809 100644
--- a/framework/build.gradle
+++ b/framework/build.gradle
@@ -196,6 +196,8 @@ dependencies {
testImplementation 'org.junit.platform:junit-platform-suite:1.12.1'
// junit-jupiter-api for using JUnit directly, not generally needed for Spock based tests
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.12.1'
+ // junit-jupiter-engine required to execute @Test-annotated methods via JUnit Platform
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.12.1'
// Spock Framework
testImplementation platform("org.spockframework:spock-bom:2.1-groovy-3.0") // Apache 2.0
testImplementation 'org.spockframework:spock-core:2.1-groovy-3.0' // Apache 2.0
@@ -234,6 +236,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 9b2b77107..3c068dd3d 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,22 @@ 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)) {
+ // PostgreSQL backend — url attribute is datasource group name (optional, default "transactional")
+ 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 +118,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, not initializing ElasticSearchLogger")
}
} 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 +168,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..4d67feb2d
--- /dev/null
+++ b/framework/src/main/groovy/org/moqui/impl/context/ElasticQueryTranslator.groovy
@@ -0,0 +1,659 @@
+/*
+ * 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