You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by mc...@apache.org on 2026/08/01 19:59:57 UTC
(hop) branch main updated: Add Redis tech plugin with Input/Output transforms and connection metadata (#7741)
This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 5140956fd1 Add Redis tech plugin with Input/Output transforms and connection metadata (#7741)
5140956fd1 is described below
commit 5140956fd1e2d682ed969ed491fd62418f093f71
Author: Lance <le...@gmail.com>
AuthorDate: Sun Aug 2 03:59:51 2026 +0800
Add Redis tech plugin with Input/Output transforms and connection metadata (#7741)
* Add Redis tech plugin with Input/Output transforms and connection metadata
Signed-off-by: lance <le...@gmail.com>
* Fix redis cluster connect error
Signed-off-by: lance <le...@gmail.com>
* Enhance Redis unit tests
Signed-off-by: lance <le...@gmail.com>
* Add Redis integration tests for STRING write/read roundtrip
Run Redis in Docker Compose and verify Redis Output/Input with a
golden-dataset unit test so the marketplace Redis plugin is exercised
end-to-end in the nightly IT suite.
Signed-off-by: mattcasters <ma...@gmail.com>
* Fix Redis plugin Checkstyle 9.3 parse errors
Rewrite Java 21 record patterns, pattern switches, and multi-label
switch arrows that Checkstyle 9.3 cannot parse. Spotless applied.
Signed-off-by: mattcasters <ma...@gmail.com>
* Apply spotless: restore LF line endings in UI sources
The Redis plugin branch accidentally introduced CRLF endings in two UI
files; spotless:apply normalizes them back to LF.
Signed-off-by: mattcasters <ma...@gmail.com>
---------
Signed-off-by: lance <le...@gmail.com>
Signed-off-by: mattcasters <ma...@gmail.com>
Co-authored-by: mattcasters <ma...@gmail.com>
---
assemblies/plugins/pom.xml | 7 +
.../hop/metadata/api/HopMetadataPropertyType.java | 1 +
.../integration-tests/integration-tests-redis.yaml | 38 ++
docs/hop-user-manual/modules/ROOT/nav.adoc | 4 +
.../modules/ROOT/pages/metadata-types/index.adoc | 1 +
.../metadata-types/redis/redis-connection.adoc | 98 +++
.../modules/ROOT/pages/pipeline/transforms.adoc | 2 +
.../pages/pipeline/transforms/redis-input.adoc | 106 ++++
.../pages/pipeline/transforms/redis-output.adoc | 189 ++++++
.../modules/ROOT/pages/technology/redis/index.adoc | 38 ++
.../modules/ROOT/pages/technology/technology.adoc | 1 +
integration-tests/redis/0001-redis-read-string.hpl | 128 ++++
.../redis/0001-redis-write-string.hpl | 125 ++++
.../redis/datasets/0001-golden-redis-string.csv | 4 +
integration-tests/redis/dev-env-config.json | 14 +
integration-tests/redis/hop-config.json | 290 +++++++++
.../redis/main-0001-redis-string-roundtrip.hwf | 111 ++++
.../metadata/dataset/0001-golden-redis-string.json | 24 +
.../metadata/pipeline-run-configuration/local.json | 17 +
.../redis/metadata/redis-connection/redis.json | 16 +
.../unit-test/0001-redis-read-string UNIT.json | 33 +
.../metadata/workflow-run-configuration/local.json | 9 +
integration-tests/redis/project-config.json | 15 +
.../apache/hop/marketplace/optional-plugins.yaml | 6 +
plugins/tech/pom.xml | 1 +
plugins/tech/redis/pom.xml | 76 +++
plugins/tech/redis/src/assembly/assembly.xml | 37 ++
.../apache/hop/redis/client/IRedisCommands.java | 253 ++++++++
.../hop/redis/client/RedisClientFactory.java | 447 +++++++++++++
.../hop/redis/client/RedisClientSession.java | 108 ++++
.../org/apache/hop/redis/codec/RedisCodecType.java | 45 ++
.../org/apache/hop/redis/codec/RedisCodecs.java | 235 +++++++
.../apache/hop/redis/codec/RedisValueCodec.java | 28 +
.../apache/hop/redis/metadata/RedisConnection.java | 257 ++++++++
.../hop/redis/metadata/RedisConnectionEditor.java | 292 +++++++++
.../hop/redis/metadata/RedisDeploymentMode.java | 43 ++
.../hop/redis/transforms/RedisDataStructure.java | 45 ++
.../redis/transforms/RedisListPushDirection.java | 34 +
.../redis/transforms/redisinput/RedisInput.java | 264 ++++++++
.../transforms/redisinput/RedisInputData.java | 60 ++
.../transforms/redisinput/RedisInputDialog.java | 316 ++++++++++
.../transforms/redisinput/RedisInputField.java | 97 +++
.../transforms/redisinput/RedisInputMeta.java | 99 +++
.../redis/transforms/redisoutput/RedisOutput.java | 379 +++++++++++
.../transforms/redisoutput/RedisOutputData.java | 67 ++
.../transforms/redisoutput/RedisOutputDialog.java | 699 +++++++++++++++++++++
.../transforms/redisoutput/RedisOutputField.java | 112 ++++
.../transforms/redisoutput/RedisOutputMeta.java | 112 ++++
.../redisoutput/RedisOutputWriteMode.java | 51 ++
.../metadata/messages/messages_en_US.properties | 53 ++
.../redisinput/messages/messages_en_US.properties | 31 +
.../redisoutput/messages/messages_en_US.properties | 40 ++
.../tech/redis/src/main/resources/redis-input.svg | 20 +
.../tech/redis/src/main/resources/redis-output.svg | 27 +
plugins/tech/redis/src/main/resources/redis.svg | 16 +
plugins/tech/redis/src/main/resources/version.xml | 20 +
.../input/redis-input-multi-structure.hpl | 199 ++++++
.../transforms/output/redis-output-hash.hpl | 157 +++++
.../transforms/output/redis-output-list.hpl | 145 +++++
.../samples/transforms/output/redis-output-set.hpl | 145 +++++
.../transforms/output/redis-output-stream.hpl | 207 ++++++
.../transforms/output/redis-output-string.hpl | 169 +++++
.../hop/redis/client/IRedisCommandsTest.java | 111 ++++
.../client/RedisClientFactoryClusterTest.java | 86 +++
.../hop/redis/client/RedisClientFactoryTest.java | 46 ++
.../hop/redis/client/RedisClientSessionTest.java | 86 +++
.../apache/hop/redis/codec/RedisCodecTypeTest.java | 37 ++
.../apache/hop/redis/codec/RedisCodecsTest.java | 122 ++++
.../hop/redis/metadata/RedisConnectionTest.java | 68 ++
.../redis/metadata/RedisDeploymentModeTest.java | 47 ++
.../redis/transforms/RedisDataStructureTest.java | 41 ++
.../transforms/RedisListPushDirectionTest.java | 34 +
.../transforms/redisinput/RedisInputDataTest.java | 67 ++
.../transforms/redisinput/RedisInputFieldTest.java | 54 ++
.../redisinput/RedisInputMetaDefaultsTest.java | 39 ++
.../redisinput/RedisInputMetaFieldsTest.java | 89 +++
.../transforms/redisinput/RedisInputMetaTest.java | 93 +++
.../transforms/redisinput/RedisInputTest.java | 288 +++++++++
.../redisoutput/RedisOutputDataTest.java | 70 +++
.../redisoutput/RedisOutputFieldTest.java | 63 ++
.../redisoutput/RedisOutputMetaDefaultsTest.java | 66 ++
.../redisoutput/RedisOutputMetaTest.java | 125 ++++
.../transforms/redisoutput/RedisOutputTest.java | 327 ++++++++++
.../redisoutput/RedisOutputWriteModeTest.java | 36 ++
.../hop/ui/core/gui/GuiCompositeWidgets.java | 6 +-
85 files changed, 8663 insertions(+), 1 deletion(-)
diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index ff455aabc6..4c93a1cee8 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -709,6 +709,7 @@
<!-- marketplace optional: hop-tech-neo4j -->
<!-- marketplace optional: hop-tech-opensearch -->
<!-- marketplace optional: hop-tech-parquet -->
+ <!-- marketplace optional: hop-tech-redis -->
<!-- marketplace optional: hop-tech-salesforce -->
<dependency>
<groupId>org.apache.hop</groupId>
@@ -1706,6 +1707,12 @@
<version>${project.version}</version>
<type>zip</type>
</dependency>
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-tech-redis</artifactId>
+ <version>${project.version}</version>
+ <type>zip</type>
+ </dependency>
<dependency>
<groupId>org.apache.hop</groupId>
<artifactId>hop-tech-salesforce</artifactId>
diff --git a/core/src/main/java/org/apache/hop/metadata/api/HopMetadataPropertyType.java b/core/src/main/java/org/apache/hop/metadata/api/HopMetadataPropertyType.java
index e9936a0e6c..427c1f23ff 100644
--- a/core/src/main/java/org/apache/hop/metadata/api/HopMetadataPropertyType.java
+++ b/core/src/main/java/org/apache/hop/metadata/api/HopMetadataPropertyType.java
@@ -28,6 +28,7 @@ public enum HopMetadataPropertyType {
PARTITION_SCHEMA,
CASSANDRA_CONNECTION,
MONGODB_CONNECTION,
+ REDIS_CONNECTION,
SPLUNK_CONNECTION,
// PIPELINE
diff --git a/docker/integration-tests/integration-tests-redis.yaml b/docker/integration-tests/integration-tests-redis.yaml
new file mode 100644
index 0000000000..de4cb7c45b
--- /dev/null
+++ b/docker/integration-tests/integration-tests-redis.yaml
@@ -0,0 +1,38 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+services:
+ integration_test_redis:
+ extends:
+ file: integration-tests-base.yaml
+ service: integration_test
+ depends_on:
+ redis:
+ condition: service_healthy
+ links:
+ - redis
+
+ redis:
+ # Pin version for CI stability; tests use compose DNS (redis:6379) only.
+ image: redis:7.2-bookworm
+ hostname: redis
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+ start_period: 5s
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc b/docs/hop-user-manual/modules/ROOT/nav.adoc
index dd0909eb25..2b5f548131 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -231,6 +231,8 @@ under the License.
*** xref:pipeline/transforms/propertyinput.adoc[Properties file Input]
*** xref:pipeline/transforms/propertyoutput.adoc[Properties file Output]
*** xref:pipeline/transforms/redshift-bulkloader.adoc[Redshift Bulk Loader]
+*** xref:pipeline/transforms/redis-input.adoc[Redis Input]
+*** xref:pipeline/transforms/redis-output.adoc[Redis Output]
*** xref:pipeline/transforms/regexeval.adoc[Regex Evaluation]
*** xref:pipeline/transforms/repeatfields.adoc[Repeat fields]
*** xref:pipeline/transforms/replacestring.adoc[Replace in String]
@@ -476,6 +478,7 @@ under the License.
** xref:metadata-types/pipeline-run-config.adoc[Pipeline Run Configuration]
** xref:metadata-types/pipeline-unit-test.adoc[Pipeline Unit Test]
** xref:metadata-types/rdbms-connection.adoc[Relational Database Connection]
+** xref:metadata-types/redis/redis-connection.adoc[Redis Connection]
** xref:metadata-types/rest-connection.adoc[REST Connection]
** xref:metadata-types/salesforce-connection.adoc[Salesforce Connection]
** xref:metadata-types/splunk-connection.adoc[Splunk Connection]
@@ -506,6 +509,7 @@ under the License.
** xref:technology/neo4j/index.adoc[Neo4j]
*** xref:technology/neo4j/working-with-neo4j-data.adoc[Working with Neo4j data]
*** xref:technology/neo4j/neo4j-logging-schema.adoc[Neo4j Logging Schema]
+** xref:technology/redis/index.adoc[Redis]
** xref:technology/google/index.adoc[Google]
* xref:hop-server/index.adoc[Hop Server]
** xref:hop-server/rest-api.adoc[REST api]
diff --git a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc
index c4c716ac2d..68d9c4bb9a 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc
@@ -58,6 +58,7 @@ xref:metadata-types/data-stream/data-stream.adoc[Data Stream]: A Data Stream can
* xref:metadata-types/pipeline-run-config.adoc[Pipeline Run Configuration]: Describes how and with which engine a pipeline is to be executed
* xref:metadata-types/pipeline-unit-test.adoc[Pipeline Unit Test]: Describes a test for a pipeline with alternative data sets as input from a certain transform and testing output against golden data
* xref:metadata-types/rdbms-connection.adoc[Relational Database Connection]: Describes all the metadata needed to connect to a relational database
+* xref:metadata-types/redis/redis-connection.adoc[Redis Connection]: Describes a reusable Redis connection (Standalone, Sentinel or Cluster)
* xref:metadata-types/rest-connection.adoc[REST Connection]: Describes all the metadata needed to connect to a REST api.
* xref:metadata-types/salesforce-connection.adoc[Salesforce Connection]: Describes a reusable Salesforce connection that can be used across multiple Salesforce transforms
* xref:metadata-types/splunk-connection.adoc[Splunk Connection]: Describes a Splunk connection
diff --git a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/redis/redis-connection.adoc b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/redis/redis-connection.adoc
new file mode 100644
index 0000000000..ea6e5ef425
--- /dev/null
+++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/redis/redis-connection.adoc
@@ -0,0 +1,98 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+ http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+:documentationPath: /metadata-types/redis/
+:language: en_US
+
+= Redis Connection
+
+== Description
+
+A Redis Connection describes how Apache Hop connects to Redis in Standalone, Sentinel or Cluster mode.
+Connections are managed in the Metadata perspective and serialized as JSON under:
+
+`metadata/redis-connection/`
+
+Use the *Test* button to verify connectivity with a Redis `PING`.
+
+The editor is split into two groups:
+
+* *Connection config* -- topology, authentication and timeouts (fields change with deployment mode)
+* *Pool config* -- shared Lettuce connection-pool settings for all three modes
+
+== Connection config
+
+|===
+|Option |Description
+
+|Deployment mode
+|`STANDALONE`, `SENTINEL` or `CLUSTER`
+
+|Hostname / Port
+|Standalone host and port (default `6379`). Hidden in Sentinel/Cluster mode.
+
+|Database
+|Logical database index for Standalone/Sentinel (default `0`). Hidden in Cluster mode (always `0`).
+
+|Master name
+|Sentinel master name. Visible only in Sentinel mode.
+
+|Sentinel nodes
+|Sentinel nodes as `host:port`, one per line (comma or semicolon also accepted). Visible only in Sentinel mode. Multi-line text field.
+
+|Cluster nodes
+|Cluster seed nodes as `host:port`, one per line (comma or semicolon also accepted). Visible only in Cluster mode. Multi-line text field.
+
+|Username / Password
+|Optional ACL credentials
+
+|Use SSL/TLS
+|Enable TLS for the connection
+
+|Timeout (ms)
+|Command timeout in milliseconds
+
+|===
+
+== Pool config
+
+Pool settings are **shared** by Standalone, Sentinel and Cluster.
+Enable pooling when you want Lettuce to manage a commons-pool2 connection pool.
+
+|===
+|Option |Description
+
+|Enable connection pooling
+|Turn on Lettuce `ConnectionPoolSupport` pooling
+
+|Max total
+|Maximum connections in the pool (default `8`)
+
+|Max idle
+|Maximum idle connections (default `8`)
+
+|Min idle
+|Minimum idle connections (default `0`)
+
+|Max wait (ms)
+|Borrow timeout (`-1` = wait forever)
+
+|===
+
+== Related transforms
+
+* xref:pipeline/transforms/redis-input.adoc[Redis Input]
+* xref:pipeline/transforms/redis-output.adoc[Redis Output]
diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
index c4f117eec5..30d43b0a3c 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
@@ -181,6 +181,8 @@ The pages nested under this topic contain information on how to use the transfor
* xref:pipeline/transforms/propertyinput.adoc[Properties file Input]
* xref:pipeline/transforms/propertyoutput.adoc[Properties file Output]
* xref:pipeline/transforms/redshift-bulkloader.adoc[Redshift Bulk Loader]
+* xref:pipeline/transforms/redis-input.adoc[Redis Input]
+* xref:pipeline/transforms/redis-output.adoc[Redis Output]
* xref:pipeline/transforms/regexeval.adoc[Regex Evaluation]
* xref:pipeline/transforms/replacestring.adoc[Replace in String]
* xref:pipeline/transforms/reservoirsampling.adoc[Reservoir Sampling]
diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/redis-input.adoc b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/redis-input.adoc
new file mode 100644
index 0000000000..1fb7c3c49c
--- /dev/null
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/redis-input.adoc
@@ -0,0 +1,106 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+ http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+:documentationPath: /pipeline/transforms/
+:language: en_US
+:description: Read STRING, HASH, SET or LIST values from Redis.
+
+= Redis Input
+
+== Description
+
+Redis Input enriches each incoming pipeline row by reading one or more Redis values using a shared xref:metadata-types/redis/redis-connection.adoc[Redis Connection].
+
+Configure a *fields* table: each row is one mapping that adds an output column to the same pipeline row. This transform needs upstream rows that supply Redis keys (or you can use literal / variable keys in the table).
+
+Supported operations:
+
+|===
+|Data structure |Command |Value field content
+
+|STRING
+|`GET`
+|Decoded scalar value (or null if missing)
+
+|HASH
+|`HGET`
+|Decoded hash value (Hash field is required). `HGETALL` is not supported.
+
+|SET
+|`SMEMBERS`
+|JSON array string of decoded members, e.g. `["a","b"]` (empty set → `[]`)
+
+|LIST
+|`LRANGE`
+|JSON array string of decoded elements for the configured range (empty → `[]`)
+
+|===
+
+`SISMEMBER` is not supported.
+
+[#key-resolution]
+== Redis key and hash field resolution
+
+*Redis key* and *Hash field* are editable combo boxes: pick an upstream field name or type a value.
+
+. If the text matches an incoming stream field name, Redis uses *that field's value* from the current row.
+. Otherwise the text is treated as a literal (variables such as `${PREFIX}:user` are resolved).
+
+== Options
+
+|===
+|Option |Description
+
+|Redis connection
+|Name of the Redis connection metadata
+
+|Fields table
+|One or more read mappings (see below). Use *Get fields* to add one mapping per upstream field (defaults: data structure `STRING`, Redis key = field name, value field = `{fieldName}Value`, codecs `STRING`, hash columns empty, list start/stop `0` / `-1`).
+
+|===
+
+=== Fields table columns
+
+|===
+|Column |Description
+
+|Redis key
+|Stream field name (use that field's value) or literal / variable — see <<key-resolution>>
+
+|Redis key codec
+|Encoding for the Redis key
+
+|Data structure
+|`STRING`, `HASH`, `SET`, or `LIST`
+
+|Hash field
+|Required for `HASH` (`HGET`). Stream field name or literal. Ignored otherwise.
+
+|Hash field codec
+|Encoding for the hash field name (`HASH` only)
+
+|Value field
+|Name of the output column added to the stream
+
+|Value codec
+|Encoding for STRING/HASH values, and for each SET/LIST element before building the JSON array
+
+|List start / List stop
+|`LRANGE` bounds (default `0` / `-1`). Use the same index twice (e.g. `5` / `5`) to read a single element as a one-item array. Ignored for non-LIST rows.
+
+|===
+
+Codecs: `STRING`, `JSON`, `JAVA_OBJECT`, `BYTE`.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/redis-output.adoc b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/redis-output.adoc
new file mode 100644
index 0000000000..27aeed2ab8
--- /dev/null
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/redis-output.adoc
@@ -0,0 +1,189 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+ http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+:documentationPath: /pipeline/transforms/
+:language: en_US
+:description: Write STRING, HASH, SET or LIST values to Redis.
+
+= Redis Output
+
+== Description
+
+Redis Output writes stream rows to Redis using a shared xref:metadata-types/redis/redis-connection.adoc[Redis Connection].
+
+Choose a *write mode* depending on whether you need one Redis write per pipeline row, or several mappings from the same row.
+
+== Write modes
+
+=== KEY_VALUE
+
+Use this mode for a single, explicit mapping: pick which stream fields supply the Redis key and value (and hash key / hash value when writing a HASH).
+
+* One data structure for the whole transform (`STRING`, `HASH`, `SET`, or `LIST`)
+* One write per incoming pipeline row
+* Key / value / hash fields are selected from the stream; each has its own codec
+* TTL and list push direction are configured once for the transform
+
+Typical use: every row writes one Redis entry, for example `SET user:{id}` from fields `id` and `payload`.
+
+=== STREAM_FIELDS
+
+Use this mode when one pipeline row should produce multiple Redis writes, possibly with different data structures, keys, codecs, or TTLs.
+
+* Configure a *fields* table: each row is one mapping
+* *Stream field* is the value written to Redis (string value, hash value, set member, or list element)
+* *Redis key* (and *Hash field* for HASH) decide where that value is written — see <<key-resolution>>
+* Data structure, codecs, and TTL are set *per mapping row*
+* Mappings that share the same Redis key and use HASH are batched into one `HSET` for that pipeline row
+
+Typical use: cache several attributes from the same row (for example a STRING profile key and a HASH of detail fields), or write mixed structures in one pass.
+
+*Get fields* fills the table with one mapping per upstream field. Defaults: data structure `STRING`, Redis key = stream field name, Redis key / value codecs `STRING`, hash columns empty, TTL `0`.
+
+[#key-resolution]
+== Redis key and hash field resolution (STREAM_FIELDS)
+
+In the fields table, *Redis key* and *Hash field* are editable combo boxes: you can pick an upstream field name or type a value yourself.
+
+Resolution at runtime:
+
+. If the configured text matches an incoming stream field name, Redis uses *that field's value* from the current row as the Redis key (or hash field name).
+. Otherwise the text is treated as a literal (variables such as `${PREFIX}:user` are resolved), and that string is used as the Redis key (or hash field name).
+
+Examples:
+
+|===
+|Configured Redis key |Incoming fields |Redis key used
+
+|`userId`
+|`userId` = `42`
+|`42` (value from the stream)
+
+|`cache:user`
+|(no field named `cache:user`)
+|`cache:user` (literal)
+
+|`${APP}:session`
+|(no matching field; `APP` = `shop`)
+|`shop:session` (after variable resolution)
+
+|===
+
+*Hash field* follows the same rules and is required when the mapping's data structure is `HASH`. It is ignored for `STRING`, `SET`, and `LIST`.
+
+NOTE: In `KEY_VALUE` mode, *Key field* / *Hash key field* always refer to stream fields whose *values* become the Redis key / hash field. There is no literal-key option in that mode; use `STREAM_FIELDS` (or a prior transform that builds the key into a field) if you need a fixed or composed key string.
+
+== Data structures
+
+|===
+|Structure |Command(s) |`KEY_VALUE` |`STREAM_FIELDS` (per table row)
+
+|STRING
+|`SET` (+ optional `EXPIRE`)
+|Key field + value field
+|Stream field value → `SET` with resolved key
+
+|HASH
+|`HSET` (+ optional `EXPIRE` on the key)
+|Key field + hash key field + hash value field
+|Stream field value → `HSET` with resolved Redis key and hash field (same Redis key rows are batched)
+
+|SET
+|`SADD` (+ optional `EXPIRE`)
+|Key field + value field (member)
+|Stream field value → `SADD` with resolved key
+
+|LIST
+|`RPUSH` or `LPUSH` (+ optional `EXPIRE`)
+|Key field + value field; list push direction configurable
+|Stream field value → `RPUSH` (list push direction not configurable yet)
+
+|===
+
+Codecs (`STRING`, `JSON`, `JAVA_OBJECT`, `BYTE`) encode keys and values when talking to Redis. Configure them on the transform in `KEY_VALUE`, or per mapping row in `STREAM_FIELDS`.
+
+TTL: values greater than `0` call `EXPIRE` after the write; `0` or empty means no expiry. In `KEY_VALUE` TTL is transform-wide; in `STREAM_FIELDS` it is per mapping row.
+
+== Options
+
+=== Common
+
+|===
+|Option |Description
+
+|Redis connection
+|Name of the Redis connection metadata
+
+|Write mode
+|`KEY_VALUE` or `STREAM_FIELDS` (see <<Write modes>>)
+
+|===
+
+=== KEY_VALUE options
+
+|===
+|Option |Description
+
+|Data structure
+|`STRING`, `HASH`, `SET`, or `LIST` (controls which fields are shown)
+
+|Key field / key codec
+|Stream field whose *value* is the Redis key, and how it is encoded
+
+|Hash key field / hash key codec
+|Shown only for `HASH`: stream field whose *value* is the hash field name
+
+|Value field / value codec
+|Stream field for the string value, set member, list element, or (for HASH) hash value
+
+|List push direction
+|`RPUSH` or `LPUSH` (LIST only)
+
+|TTL seconds
+|Optional key expiry after each write (`> 0` sets expiry)
+
+|===
+
+=== STREAM_FIELDS options (fields table)
+
+|===
+|Column |Description
+
+|Stream field
+|Incoming field whose *value* is written to Redis
+
+|Data structure
+|`STRING`, `HASH`, `SET`, or `LIST` for this mapping
+
+|Redis key
+|Where to write: stream field name (use that field's value) or literal / variable text — see <<key-resolution>>
+
+|Redis key codec
+|Encoding for the Redis key
+
+|Hash field
+|HASH only: Redis hash field name, resolved like *Redis key*. Leave empty for non-HASH rows
+
+|Hash field codec
+|HASH only: encoding for the hash field name
+
+|Value codec
+|Encoding for the stream field value written to Redis
+
+|TTL seconds
+|Per-row expiry; `> 0` sets `EXPIRE`, `0` or empty means no expiry
+
+|===
diff --git a/docs/hop-user-manual/modules/ROOT/pages/technology/redis/index.adoc b/docs/hop-user-manual/modules/ROOT/pages/technology/redis/index.adoc
new file mode 100644
index 0000000000..e2d315c4e2
--- /dev/null
+++ b/docs/hop-user-manual/modules/ROOT/pages/technology/redis/index.adoc
@@ -0,0 +1,38 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+ http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+:documentationPath: /technology/redis/
+:language: en_US
+:description: Apache Hop supports Redis through a connection metadata type and input/output transforms.
+
+= Redis Technology
+
+https://redis.io/[Redis] is an in-memory data structure store used as a database, cache and message broker.
+
+== Redis Support in Hop
+
+Hop supports Redis Standalone, Sentinel and Cluster topologies with the following:
+
+=== Metadata Types
+
+* xref:metadata-types/redis/redis-connection.adoc[Redis Connection]: Create a reusable Redis connection.
+
+=== Pipeline Transforms
+
+* xref:pipeline/transforms/redis-input.adoc[Redis Input]: Read STRING, HASH, SET or LIST values.
+* xref:pipeline/transforms/redis-output.adoc[Redis Output]: Write STRING, HASH, SET or LIST values.
+
+The Redis plugin is distributed as an optional marketplace technology plugin (`hop-tech-redis`).
diff --git a/docs/hop-user-manual/modules/ROOT/pages/technology/technology.adoc b/docs/hop-user-manual/modules/ROOT/pages/technology/technology.adoc
index 7aed61c202..80cb9dc4cf 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/technology/technology.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/technology/technology.adoc
@@ -32,3 +32,4 @@ The technologies supported by Hop currently include:
* xref:technology/memgraph/index.adoc[Memgraph]
* xref:technology/neo4j/index.adoc[Neo4j]
** xref:technology/neo4j/working-with-neo4j-data.adoc[Working with Neo4j data]
+* xref:technology/redis/index.adoc[Redis]
diff --git a/integration-tests/redis/0001-redis-read-string.hpl b/integration-tests/redis/0001-redis-read-string.hpl
new file mode 100644
index 0000000000..72cc952019
--- /dev/null
+++ b/integration-tests/redis/0001-redis-read-string.hpl
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0001-redis-read-string</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Read STRING values from Redis for golden-dataset validation</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters/>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/08/01 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/08/01 20:00:00.000</modified_date>
+ </info>
+ <notepads/>
+ <order>
+ <hop>
+ <from>Data grid</from>
+ <to>Redis input</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Redis input</from>
+ <to>Validate</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Data grid</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <name>redis_key</name>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>hop:it:user:1</item>
+ </line>
+ <line>
+ <item>hop:it:user:2</item>
+ </line>
+ <line>
+ <item>hop:it:user:3</item>
+ </line>
+ </data>
+ <attributes/>
+ </transform>
+ <transform>
+ <name>Redis input</name>
+ <type>RedisInput</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <connection>redis</connection>
+ <fields>
+ <field>
+ <redis_key>redis_key</redis_key>
+ <redis_key_codec>STRING</redis_key_codec>
+ <data_structure>STRING</data_structure>
+ <hash_field/>
+ <value_field>redis_value</value_field>
+ <value_codec>STRING</value_codec>
+ <list_start>0</list_start>
+ <list_stop>-1</list_stop>
+ </field>
+ </fields>
+ <attributes/>
+ </transform>
+ <transform>
+ <name>Validate</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/integration-tests/redis/0001-redis-write-string.hpl b/integration-tests/redis/0001-redis-write-string.hpl
new file mode 100644
index 0000000000..c2beade067
--- /dev/null
+++ b/integration-tests/redis/0001-redis-write-string.hpl
@@ -0,0 +1,125 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0001-redis-write-string</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Write STRING key/value pairs into Redis for the integration test roundtrip</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters/>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/08/01 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/08/01 20:00:00.000</modified_date>
+ </info>
+ <notepads/>
+ <order>
+ <hop>
+ <from>Data grid</from>
+ <to>Redis output</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Data grid</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <name>redis_key</name>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <name>redis_value</name>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>hop:it:user:1</item>
+ <item>Alice</item>
+ </line>
+ <line>
+ <item>hop:it:user:2</item>
+ <item>Bob</item>
+ </line>
+ <line>
+ <item>hop:it:user:3</item>
+ <item>Carol</item>
+ </line>
+ </data>
+ <attributes/>
+ </transform>
+ <transform>
+ <name>Redis output</name>
+ <type>RedisOutput</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <connection>redis</connection>
+ <write_mode>KEY_VALUE</write_mode>
+ <data_structure>STRING</data_structure>
+ <key_codec>STRING</key_codec>
+ <value_codec>STRING</value_codec>
+ <hash_key_codec>STRING</hash_key_codec>
+ <hash_value_codec>STRING</hash_value_codec>
+ <key_field>redis_key</key_field>
+ <value_field>redis_value</value_field>
+ <hash_key_field/>
+ <hash_value_field/>
+ <ttl_seconds/>
+ <list_push_direction>RPUSH</list_push_direction>
+ <fields/>
+ <attributes/>
+ </transform>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/integration-tests/redis/datasets/0001-golden-redis-string.csv b/integration-tests/redis/datasets/0001-golden-redis-string.csv
new file mode 100644
index 0000000000..a69afcb7be
--- /dev/null
+++ b/integration-tests/redis/datasets/0001-golden-redis-string.csv
@@ -0,0 +1,4 @@
+redis_key,redis_value
+hop:it:user:1,Alice
+hop:it:user:2,Bob
+hop:it:user:3,Carol
diff --git a/integration-tests/redis/dev-env-config.json b/integration-tests/redis/dev-env-config.json
new file mode 100644
index 0000000000..a120656c86
--- /dev/null
+++ b/integration-tests/redis/dev-env-config.json
@@ -0,0 +1,14 @@
+{
+ "variables": [
+ {
+ "name": "REDIS_HOSTNAME",
+ "value": "redis",
+ "description": "Redis hostname on the docker integration-test network"
+ },
+ {
+ "name": "REDIS_PORT",
+ "value": "6379",
+ "description": "Redis port"
+ }
+ ]
+}
diff --git a/integration-tests/redis/hop-config.json b/integration-tests/redis/hop-config.json
new file mode 100644
index 0000000000..d9e1e6562e
--- /dev/null
+++ b/integration-tests/redis/hop-config.json
@@ -0,0 +1,290 @@
+{
+ "variables": [
+ {
+ "name": "HOP_LENIENT_STRING_TO_NUMBER_CONVERSION",
+ "value": "N",
+ "description": "System wide flag to allow lenient string to number conversion for backward compatibility. If this setting is set to \"Y\", an string starting with digits will be converted successfully into a number. (example: 192.168.1.1 will be converted into 192 or 192.168 or 192168 depending on the decimal and grouping symbol). The default (N) will be to throw an error if non-numeric symbols are found in the string."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_DB_IGNORE_TIMEZONE",
+ "value": "N",
+ "description": "System wide flag to ignore timezone while writing date/timestamp value to the database."
+ },
+ {
+ "name": "HOP_LOG_SIZE_LIMIT",
+ "value": "0",
+ "description": "The log size limit for all pipelines and workflows that don't have the \"log size limit\" property set in their respective properties."
+ },
+ {
+ "name": "HOP_EMPTY_STRING_DIFFERS_FROM_NULL",
+ "value": "N",
+ "description": "NULL vs Empty String. If this setting is set to Y, an empty string and null are different. Otherwise they are not."
+ },
+ {
+ "name": "HOP_MAX_LOG_SIZE_IN_LINES",
+ "value": "0",
+ "description": "The maximum number of log lines that are kept internally by Hop. Set to 0 to keep all rows (default)"
+ },
+ {
+ "name": "HOP_MAX_LOG_TIMEOUT_IN_MINUTES",
+ "value": "1440",
+ "description": "The maximum age (in minutes) of a log line while being kept internally by Hop. Set to 0 to keep all rows indefinitely (default)"
+ },
+ {
+ "name": "HOP_MAX_WORKFLOW_TRACKER_SIZE",
+ "value": "5000",
+ "description": "The maximum number of workflow trackers kept in memory"
+ },
+ {
+ "name": "HOP_MAX_ACTIONS_LOGGED",
+ "value": "5000",
+ "description": "The maximum number of action results kept in memory for logging purposes."
+ },
+ {
+ "name": "HOP_MAX_LOGGING_REGISTRY_SIZE",
+ "value": "10000",
+ "description": "The maximum number of logging registry entries kept in memory for logging purposes."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_DELAY",
+ "value": "1000",
+ "description": "The hop log tab refresh delay."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_PERIOD",
+ "value": "1000",
+ "description": "The hop log tab refresh period."
+ },
+ {
+ "name": "HOP_PLUGIN_CLASSES",
+ "value": null,
+ "description": "A comma delimited list of classes to scan for plugin annotations"
+ },
+ {
+ "name": "HOP_PLUGIN_PACKAGES",
+ "value": null,
+ "description": "A comma delimited list of packages to scan for plugin annotations (warning: slow!!)"
+ },
+ {
+ "name": "HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT",
+ "value": "0",
+ "description": "The maximum number of transform performance snapshots to keep in memory. Set to 0 to keep all snapshots indefinitely (default)"
+ },
+ {
+ "name": "HOP_ROWSET_GET_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an alternative rowset get timeout (in ms). This only makes a difference for extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_ROWSET_PUT_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an alternative rowset put timeout (in ms). This only makes a difference for extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_CORE_TRANSFORMS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the alternative location of the hop-transforms.xml file. You can use this to customize the list of available internal transforms outside of the codebase."
+ },
+ {
+ "name": "HOP_CORE_WORKFLOW_ACTIONS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the alternative location of the hop-workflow-actions.xml file."
+ },
+ {
+ "name": "HOP_SERVER_OBJECT_TIMEOUT_MINUTES",
+ "value": "1440",
+ "description": "This project variable will set a time-out after which waiting, completed or stopped pipelines and workflows will be automatically cleaned up. The default value is 1440 (one day)."
+ },
+ {
+ "name": "HOP_PIPELINE_PAN_JVM_EXIT_CODE",
+ "value": null,
+ "description": "Set this variable to an integer that will be returned as the Pan JVM exit code."
+ },
+ {
+ "name": "HOP_DISABLE_CONSOLE_LOGGING",
+ "value": "N",
+ "description": "Set this variable to Y to disable standard Hop logging to the console. (stdout)"
+ },
+ {
+ "name": "HOP_REDIRECT_STDERR",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stderr to Hop logging."
+ },
+ {
+ "name": "HOP_REDIRECT_STDOUT",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stdout to Hop logging."
+ },
+ {
+ "name": "HOP_DEFAULT_NUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default number format"
+ },
+ {
+ "name": "HOP_DEFAULT_BIGNUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default bignumber format"
+ },
+ {
+ "name": "HOP_DEFAULT_INTEGER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default integer format"
+ },
+ {
+ "name": "HOP_DEFAULT_DATE_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default date format"
+ },
+ {
+ "name": "HOP_DEFAULT_TIMESTAMP_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default timestamp format"
+ },
+ {
+ "name": "HOP_DEFAULT_SERVLET_ENCODING",
+ "value": null,
+ "description": "Defines the default encoding for servlets, leave it empty to use Java default encoding"
+ },
+ {
+ "name": "HOP_FAIL_ON_LOGGING_ERROR",
+ "value": "N",
+ "description": "Set this variable to Y when you want the workflow/pipeline fail with an error when the related logging process (e.g. to a database) fails."
+ },
+ {
+ "name": "HOP_AGGREGATION_MIN_NULL_IS_VALUED",
+ "value": "N",
+ "description": "Set this variable to Y to set the minimum to NULL if NULL is within an aggregate. Otherwise by default NULL is ignored by the MIN aggregate and MIN is set to the minimum value that is not NULL. See also the variable HOP_AGGREGATION_ALL_NULLS_ARE_ZERO."
+ },
+ {
+ "name": "HOP_AGGREGATION_ALL_NULLS_ARE_ZERO",
+ "value": "N",
+ "description": "Set this variable to Y to return 0 when all values within an aggregate are NULL. Otherwise by default a NULL is returned when all values are NULL."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_TEXT_FILE_OUTPUT_APPEND_NO_HEADER",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for the Text File Output transform. Setting this to Ywill add no header row at all when the append option is enabled, regardless if the file is existing or not."
+ },
+ {
+ "name": "HOP_PASSWORD_ENCODER_PLUGIN",
+ "value": "Hop",
+ "description": "Specifies the password encoder plugin to use by ID (Hop is the default)."
+ },
+ {
+ "name": "HOP_SYSTEM_HOSTNAME",
+ "value": null,
+ "description": "You can use this variable to speed up hostname lookup. Hostname lookup is performed by Hop so that it is capable of logging the server on which a workflow or pipeline is executed."
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPTORS",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptors for Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPT_QUEUE_SIZE",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptQueueSize for Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_RES_MAX_IDLE_TIME",
+ "value": null,
+ "description": "A variable to configure jetty option: lowResourcesMaxIdleTime for Carte"
+ },
+ {
+ "name": "HOP_COMPATIBILITY_MERGE_ROWS_USE_REFERENCE_STREAM_WHEN_IDENTICAL",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for the Merge Rows (diff) transform. Setting this to Y will use the data from the reference stream (instead of the comparison stream) in case the compared rows are identical."
+ },
+ {
+ "name": "HOP_SPLIT_FIELDS_REMOVE_ENCLOSURE",
+ "value": "false",
+ "description": "Set this variable to false to preserve enclosure symbol after splitting the string in the Split fields transform. Changing it to true will remove first and last enclosure symbol from the resulting string chunks."
+ },
+ {
+ "name": "HOP_ALLOW_EMPTY_FIELD_NAMES_AND_TYPES",
+ "value": "false",
+ "description": "Set this variable to TRUE to allow your pipeline to pass 'null' fields and/or empty types."
+ },
+ {
+ "name": "HOP_GLOBAL_LOG_VARIABLES_CLEAR_ON_EXPORT",
+ "value": "false",
+ "description": "Set this variable to false to preserve global log variables defined in pipeline / workflow Properties -> Log panel. Changing it to true will clear it when export pipeline / workflow."
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_COUNT",
+ "value": "1024",
+ "description": "This project variable is used by the Text File Output transform. It defines the max number of simultaneously open files within the transform. The transform will close/reopen files as necessary to insure the max is not exceeded"
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_LIFE",
+ "value": "0",
+ "description": "This project variable is used by the Text File Output transform. It defines the max number of milliseconds between flushes of files opened by the transform."
+ },
+ {
+ "name": "HOP_USE_NATIVE_FILE_DIALOG",
+ "value": "N",
+ "description": "Set this value to Y if you want to use the system file open/save dialog when browsing files"
+ },
+ {
+ "name": "HOP_AUTO_CREATE_CONFIG",
+ "value": "Y",
+ "description": "Set this value to N if you don't want to automatically create a hop configuration file (hop-config.json) when it's missing"
+ }
+ ],
+ "LocaleDefault": "en_BE",
+ "guiProperties": {
+ "FontFixedSize": "13",
+ "MaxUndo": "100",
+ "DarkMode": "Y",
+ "FontNoteSize": "13",
+ "ShowOSLook": "Y",
+ "FontFixedStyle": "0",
+ "FontNoteName": ".AppleSystemUIFont",
+ "FontFixedName": "Monospaced",
+ "FontGraphStyle": "0",
+ "FontDefaultSize": "13",
+ "GraphColorR": "255",
+ "FontGraphSize": "13",
+ "IconSize": "32",
+ "BackgroundColorB": "255",
+ "FontNoteStyle": "0",
+ "FontGraphName": ".AppleSystemUIFont",
+ "FontDefaultName": ".AppleSystemUIFont",
+ "GraphColorG": "255",
+ "UseGlobalFileBookmarks": "Y",
+ "FontDefaultStyle": "0",
+ "GraphColorB": "255",
+ "BackgroundColorR": "255",
+ "BackgroundColorG": "255",
+ "WorkflowDialogStyle": "RESIZE,MAX,MIN",
+ "LineWidth": "1",
+ "ContextDialogShowCategories": "Y"
+ },
+ "projectsConfig": {
+ "enabled": true,
+ "projectMandatory": true,
+ "environmentMandatory": false,
+ "defaultProject": "default",
+ "defaultEnvironment": null,
+ "standardParentProject": "default",
+ "standardProjectsFolder": null,
+ "projectConfigurations": [
+ {
+ "projectName": "default",
+ "projectHome": "${HOP_CONFIG_FOLDER}",
+ "configFilename": "project-config.json"
+ }
+ ],
+ "lifecycleEnvironments": [
+ {
+ "name": "dev",
+ "purpose": "Testing",
+ "projectName": "default",
+ "configurationFiles": [
+ "${PROJECT_HOME}/dev-env-config.json"
+ ]
+ }
+ ],
+ "projectLifecycles": []
+ }
+}
\ No newline at end of file
diff --git a/integration-tests/redis/main-0001-redis-string-roundtrip.hwf b/integration-tests/redis/main-0001-redis-string-roundtrip.hwf
new file mode 100644
index 0000000000..344bd92129
--- /dev/null
+++ b/integration-tests/redis/main-0001-redis-string-roundtrip.hwf
@@ -0,0 +1,111 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+ <name>main-0001-redis-string-roundtrip</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Write STRING values to Redis, then read them back and compare to a golden dataset</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/08/01 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/08/01 20:00:00.000</modified_date>
+ <parameters/>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>64</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>0001-redis-write-string.hpl</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+ <filename>${PROJECT_HOME}/0001-redis-write-string.hpl</filename>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Run Pipeline Unit Tests</name>
+ <description/>
+ <type>RunPipelineTests</type>
+ <attributes/>
+ <test_names>
+ <test_name>
+ <name>0001-redis-read-string UNIT</name>
+ </test_name>
+ </test_names>
+ <parallel>N</parallel>
+ <xloc>480</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>0001-redis-write-string.hpl</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>0001-redis-write-string.hpl</from>
+ <to>Run Pipeline Unit Tests</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads/>
+ <attributes/>
+</workflow>
diff --git a/integration-tests/redis/metadata/dataset/0001-golden-redis-string.json b/integration-tests/redis/metadata/dataset/0001-golden-redis-string.json
new file mode 100644
index 0000000000..89e96038b5
--- /dev/null
+++ b/integration-tests/redis/metadata/dataset/0001-golden-redis-string.json
@@ -0,0 +1,24 @@
+{
+ "base_filename": "0001-golden-redis-string.csv",
+ "name": "0001-golden-redis-string",
+ "description": "Expected Redis STRING key/value pairs after write/read roundtrip",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "redis_key",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "redis_value",
+ "field_format": ""
+ }
+ ],
+ "folder_name": ""
+}
diff --git a/integration-tests/redis/metadata/pipeline-run-configuration/local.json b/integration-tests/redis/metadata/pipeline-run-configuration/local.json
new file mode 100644
index 0000000000..d8e37459d8
--- /dev/null
+++ b/integration-tests/redis/metadata/pipeline-run-configuration/local.json
@@ -0,0 +1,17 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "feedback_size": "50000",
+ "sample_size": "100",
+ "sample_type_in_gui": "Last",
+ "rowset_size": "10000",
+ "safe_mode": false,
+ "show_feedback": false,
+ "topo_sort": false,
+ "gather_metrics": false
+ }
+ },
+ "configurationVariables": [],
+ "name": "local",
+ "description": "Runs your pipelines locally with the standard local Hop pipeline engine"
+}
diff --git a/integration-tests/redis/metadata/redis-connection/redis.json b/integration-tests/redis/metadata/redis-connection/redis.json
new file mode 100644
index 0000000000..f5ce91be97
--- /dev/null
+++ b/integration-tests/redis/metadata/redis-connection/redis.json
@@ -0,0 +1,16 @@
+{
+ "name": "redis",
+ "deploymentMode": "STANDALONE",
+ "hostname": "${REDIS_HOSTNAME}",
+ "port": "${REDIS_PORT}",
+ "database": "0",
+ "username": "",
+ "password": "",
+ "useSsl": false,
+ "timeoutMs": "10000",
+ "enablePooling": false,
+ "poolMaxTotal": "8",
+ "poolMaxIdle": "8",
+ "poolMinIdle": "0",
+ "poolMaxWaitMs": "-1"
+}
diff --git a/integration-tests/redis/metadata/unit-test/0001-redis-read-string UNIT.json b/integration-tests/redis/metadata/unit-test/0001-redis-read-string UNIT.json
new file mode 100644
index 0000000000..e73b4c3eeb
--- /dev/null
+++ b/integration-tests/redis/metadata/unit-test/0001-redis-read-string UNIT.json
@@ -0,0 +1,33 @@
+{
+ "database_replacements": [],
+ "autoOpening": true,
+ "description": "Validate Redis STRING values read back after write",
+ "persist_filename": "",
+ "test_type": "UNIT_TEST",
+ "variableValues": [],
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "redis_key",
+ "data_set_field": "redis_key"
+ },
+ {
+ "transform_field": "redis_value",
+ "data_set_field": "redis_value"
+ }
+ ],
+ "field_order": [
+ "redis_key",
+ "redis_value"
+ ],
+ "data_set_name": "0001-golden-redis-string",
+ "transform_name": "Validate"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0001-redis-read-string UNIT",
+ "trans_test_tweaks": [],
+ "pipeline_filename": "./0001-redis-read-string.hpl"
+}
diff --git a/integration-tests/redis/metadata/workflow-run-configuration/local.json b/integration-tests/redis/metadata/workflow-run-configuration/local.json
new file mode 100644
index 0000000000..ddb388679a
--- /dev/null
+++ b/integration-tests/redis/metadata/workflow-run-configuration/local.json
@@ -0,0 +1,9 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "safe_mode": false
+ }
+ },
+ "name": "local",
+ "description": "Runs your workflows locally with the standard local Hop workflow engine"
+}
diff --git a/integration-tests/redis/project-config.json b/integration-tests/redis/project-config.json
new file mode 100644
index 0000000000..241753d668
--- /dev/null
+++ b/integration-tests/redis/project-config.json
@@ -0,0 +1,15 @@
+{
+ "metadataBaseFolder": "${PROJECT_HOME}/metadata",
+ "unitTestsBasePath": "${PROJECT_HOME}",
+ "dataSetsCsvFolder": "${PROJECT_HOME}/datasets",
+ "enforcingExecutionInHome": true,
+ "config": {
+ "variables": [
+ {
+ "name": "HOP_LICENSE_HEADER_FILE",
+ "value": "${PROJECT_HOME}/../asf-header.txt",
+ "description": "This will automatically serialize the ASF license header into pipelines and workflows in the integration test projects"
+ }
+ ]
+ }
+}
diff --git a/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml b/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml
index 4c4c8fd68b..67ed3e73e0 100644
--- a/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml
+++ b/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml
@@ -137,6 +137,12 @@ plugins:
description: MongoDB
installPath: plugins/tech/mongodb
modulePath: plugins/tech/mongodb
+ - artifactId: hop-tech-redis
+ name: Redis
+ category: Technology
+ description: Redis connection and STRING/HASH/SET/LIST input/output transforms
+ installPath: plugins/tech/redis
+ modulePath: plugins/tech/redis
- artifactId: hop-tech-elastic
name: Elastic
category: Technology
diff --git a/plugins/tech/pom.xml b/plugins/tech/pom.xml
index b6592bc020..73832e5d59 100644
--- a/plugins/tech/pom.xml
+++ b/plugins/tech/pom.xml
@@ -43,6 +43,7 @@
<module>neo4j</module>
<module>opensearch</module>
<module>parquet</module>
+ <module>redis</module>
<module>salesforce</module>
<module>vault</module>
<module>webdav</module>
diff --git a/plugins/tech/redis/pom.xml b/plugins/tech/redis/pom.xml
new file mode 100644
index 0000000000..0639907640
--- /dev/null
+++ b/plugins/tech/redis/pom.xml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+<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">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-plugins-tech</artifactId>
+ <version>2.19.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>hop-tech-redis</artifactId>
+ <packaging>jar</packaging>
+ <name>Hop Plugins Technology Redis</name>
+
+ <properties>
+ <commons-pool2.version>2.12.1</commons-pool2.version>
+ <lettuce.version>6.5.5.RELEASE</lettuce.version>
+ </properties>
+
+ <dependencyManagement>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-libs</artifactId>
+ <version>${project.version}</version>
+ <type>pom</type>
+ <scope>import</scope>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
+
+ <dependencies>
+ <dependency>
+ <groupId>io.lettuce</groupId>
+ <artifactId>lettuce-core</artifactId>
+ <version>${lettuce.version}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>io.netty</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-pool2</artifactId>
+ <version>${commons-pool2.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>io.projectreactor</groupId>
+ <artifactId>reactor-core</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.reactivestreams</groupId>
+ <artifactId>reactive-streams</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+</project>
diff --git a/plugins/tech/redis/src/assembly/assembly.xml b/plugins/tech/redis/src/assembly/assembly.xml
new file mode 100644
index 0000000000..8fb0f10f33
--- /dev/null
+++ b/plugins/tech/redis/src/assembly/assembly.xml
@@ -0,0 +1,37 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+
+<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0 http://maven.apache.org/xsd/assembly-2.2.0.xsd">
+ <id>hop-tech-redis</id>
+ <formats>
+ <format>zip</format>
+ </formats>
+ <baseDirectory>.</baseDirectory>
+ <files>
+ <file>
+ <source>${project.basedir}/src/main/resources/version.xml</source>
+ <outputDirectory>${hop.plugin.libdir}</outputDirectory>
+ <filtered>true</filtered>
+ </file>
+ </files>
+
+ <componentDescriptors>
+ <componentDescriptor>${maven.multiModuleProjectDirectory}/assemblies/shared/hop-plugin-libs.xml</componentDescriptor>
+ </componentDescriptors>
+</assembly>
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/client/IRedisCommands.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/client/IRedisCommands.java
new file mode 100644
index 0000000000..5589c850b4
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/client/IRedisCommands.java
@@ -0,0 +1,253 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.client;
+
+import io.lettuce.core.api.sync.RedisCommands;
+import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Unified sync command API for Standalone/Sentinel and Cluster. */
+public interface IRedisCommands {
+
+ /** Redis {@code PING}. */
+ String ping();
+
+ /** Redis {@code GET}. */
+ byte[] getValue(byte[] key);
+
+ /** Redis {@code SET}. */
+ String setValue(byte[] key, byte[] value);
+
+ /** Redis {@code MGET}. */
+ List<KeyValue<byte[], byte[]>> multiGet(byte[]... keys);
+
+ /** Redis {@code HGET}. */
+ byte[] hashGet(byte[] key, byte[] field);
+
+ /** Redis {@code HGETALL}. */
+ Map<byte[], byte[]> hashGetAll(byte[] key);
+
+ /** Redis {@code HSET} for a single field. */
+ Boolean hashSet(byte[] key, byte[] field, byte[] value);
+
+ /** Redis {@code HSET} for multiple fields. */
+ Long hashSet(byte[] key, Map<byte[], byte[]> map);
+
+ /** Redis {@code SMEMBERS}. */
+ Set<byte[]> setMembers(byte[] key);
+
+ /** Redis {@code SISMEMBER}. */
+ Boolean isSetMember(byte[] key, byte[] member);
+
+ /** Redis {@code SADD}. */
+ Long setAdd(byte[] key, byte[]... members);
+
+ /** Redis {@code LRANGE}. */
+ List<byte[]> listRange(byte[] key, long start, long stop);
+
+ /** Redis {@code RPUSH}. */
+ Long listRightPush(byte[] key, byte[]... values);
+
+ /** Redis {@code LPUSH}. */
+ Long listLeftPush(byte[] key, byte[]... values);
+
+ /** Redis {@code EXPIRE}. */
+ Boolean expire(byte[] key, long seconds);
+
+ record KeyValue<K, V>(K key, V value) {}
+
+ static IRedisCommands createStandalone(RedisCommands<byte[], byte[]> commands) {
+ return new StandaloneCommands(commands);
+ }
+
+ static IRedisCommands createCluster(RedisAdvancedClusterCommands<byte[], byte[]> commands) {
+ return new ClusterCommands(commands);
+ }
+
+ final class StandaloneCommands implements IRedisCommands {
+ private final RedisCommands<byte[], byte[]> commands;
+
+ StandaloneCommands(RedisCommands<byte[], byte[]> commands) {
+ this.commands = commands;
+ }
+
+ @Override
+ public String ping() {
+ return commands.ping();
+ }
+
+ @Override
+ public byte[] getValue(byte[] key) {
+ return commands.get(key);
+ }
+
+ @Override
+ public String setValue(byte[] key, byte[] value) {
+ return commands.set(key, value);
+ }
+
+ @Override
+ public List<KeyValue<byte[], byte[]>> multiGet(byte[]... keys) {
+ return commands.mget(keys).stream()
+ .map(kv -> new KeyValue<>(kv.getKey(), kv.hasValue() ? kv.getValue() : null))
+ .toList();
+ }
+
+ @Override
+ public byte[] hashGet(byte[] key, byte[] field) {
+ return commands.hget(key, field);
+ }
+
+ @Override
+ public Map<byte[], byte[]> hashGetAll(byte[] key) {
+ return commands.hgetall(key);
+ }
+
+ @Override
+ public Boolean hashSet(byte[] key, byte[] field, byte[] value) {
+ return commands.hset(key, field, value);
+ }
+
+ @Override
+ public Long hashSet(byte[] key, Map<byte[], byte[]> map) {
+ return commands.hset(key, map);
+ }
+
+ @Override
+ public Set<byte[]> setMembers(byte[] key) {
+ return commands.smembers(key);
+ }
+
+ @Override
+ public Boolean isSetMember(byte[] key, byte[] member) {
+ return commands.sismember(key, member);
+ }
+
+ @Override
+ public Long setAdd(byte[] key, byte[]... members) {
+ return commands.sadd(key, members);
+ }
+
+ @Override
+ public List<byte[]> listRange(byte[] key, long start, long stop) {
+ return commands.lrange(key, start, stop);
+ }
+
+ @Override
+ public Long listRightPush(byte[] key, byte[]... values) {
+ return commands.rpush(key, values);
+ }
+
+ @Override
+ public Long listLeftPush(byte[] key, byte[]... values) {
+ return commands.lpush(key, values);
+ }
+
+ @Override
+ public Boolean expire(byte[] key, long seconds) {
+ return commands.expire(key, seconds);
+ }
+ }
+
+ final class ClusterCommands implements IRedisCommands {
+ private final RedisAdvancedClusterCommands<byte[], byte[]> commands;
+
+ ClusterCommands(RedisAdvancedClusterCommands<byte[], byte[]> commands) {
+ this.commands = commands;
+ }
+
+ @Override
+ public String ping() {
+ return commands.ping();
+ }
+
+ @Override
+ public byte[] getValue(byte[] key) {
+ return commands.get(key);
+ }
+
+ @Override
+ public String setValue(byte[] key, byte[] value) {
+ return commands.set(key, value);
+ }
+
+ @Override
+ public List<KeyValue<byte[], byte[]>> multiGet(byte[]... keys) {
+ return commands.mget(keys).stream()
+ .map(kv -> new KeyValue<>(kv.getKey(), kv.hasValue() ? kv.getValue() : null))
+ .toList();
+ }
+
+ @Override
+ public byte[] hashGet(byte[] key, byte[] field) {
+ return commands.hget(key, field);
+ }
+
+ @Override
+ public Map<byte[], byte[]> hashGetAll(byte[] key) {
+ return commands.hgetall(key);
+ }
+
+ @Override
+ public Boolean hashSet(byte[] key, byte[] field, byte[] value) {
+ return commands.hset(key, field, value);
+ }
+
+ @Override
+ public Long hashSet(byte[] key, Map<byte[], byte[]> map) {
+ return commands.hset(key, map);
+ }
+
+ @Override
+ public Set<byte[]> setMembers(byte[] key) {
+ return commands.smembers(key);
+ }
+
+ @Override
+ public Boolean isSetMember(byte[] key, byte[] member) {
+ return commands.sismember(key, member);
+ }
+
+ @Override
+ public Long setAdd(byte[] key, byte[]... members) {
+ return commands.sadd(key, members);
+ }
+
+ @Override
+ public List<byte[]> listRange(byte[] key, long start, long stop) {
+ return commands.lrange(key, start, stop);
+ }
+
+ @Override
+ public Long listRightPush(byte[] key, byte[]... values) {
+ return commands.rpush(key, values);
+ }
+
+ @Override
+ public Long listLeftPush(byte[] key, byte[]... values) {
+ return commands.lpush(key, values);
+ }
+
+ @Override
+ public Boolean expire(byte[] key, long seconds) {
+ return commands.expire(key, seconds);
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/client/RedisClientFactory.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/client/RedisClientFactory.java
new file mode 100644
index 0000000000..ff886886bf
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/client/RedisClientFactory.java
@@ -0,0 +1,447 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.client;
+
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.api.StatefulRedisConnection;
+import io.lettuce.core.cluster.ClusterClientOptions;
+import io.lettuce.core.cluster.ClusterTopologyRefreshOptions;
+import io.lettuce.core.cluster.PartitionSelectorException;
+import io.lettuce.core.cluster.RedisClusterClient;
+import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
+import io.lettuce.core.cluster.models.partitions.Partitions;
+import io.lettuce.core.cluster.models.partitions.RedisClusterNode;
+import io.lettuce.core.cluster.models.partitions.RedisClusterNode.NodeFlag;
+import io.lettuce.core.codec.ByteArrayCodec;
+import io.lettuce.core.support.ConnectionPoolSupport;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.pool2.impl.GenericObjectPool;
+import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.redis.metadata.RedisConnection;
+import org.apache.hop.redis.metadata.RedisDeploymentMode;
+
+/** Builds Lettuce clients for Standalone, Sentinel and Cluster. Pool settings are shared. */
+public final class RedisClientFactory {
+
+ private static final int CLUSTER_SLOT_COUNT = 16384;
+
+ private static final byte[] CLUSTER_WARMUP_KEY =
+ "hop:cluster:warmup".getBytes(StandardCharsets.UTF_8);
+
+ private RedisClientFactory() {}
+
+ public static RedisClientSession create(RedisConnection connection, IVariables variables)
+ throws HopException {
+ RedisDeploymentMode mode =
+ connection.getDeploymentMode() == null
+ ? RedisDeploymentMode.STANDALONE
+ : connection.getDeploymentMode();
+
+ Duration timeout = resolveTimeout(connection, variables);
+ String username = resolve(variables, connection.getUsername());
+ String password = Utils.resolvePassword(variables, connection.getPassword());
+ boolean useSsl = connection.isUseSsl();
+
+ switch (mode) {
+ case SENTINEL:
+ return createSentinel(connection, variables, timeout, username, password, useSsl);
+ case CLUSTER:
+ return createCluster(connection, variables, timeout, username, password, useSsl);
+ case STANDALONE:
+ default:
+ return createStandalone(connection, variables, timeout, username, password, useSsl);
+ }
+ }
+
+ private static RedisClientSession createStandalone(
+ RedisConnection connection,
+ IVariables variables,
+ Duration timeout,
+ String username,
+ String password,
+ boolean useSsl)
+ throws HopException {
+ String host = Const.NVL(resolve(variables, connection.getHostname()), "localhost");
+ int port = Const.toInt(resolve(variables, connection.getPort()), 6379);
+ int database = Const.toInt(resolve(variables, connection.getDatabase()), 0);
+
+ RedisURI.Builder builder =
+ RedisURI.builder().withHost(host).withPort(port).withDatabase(database);
+ applyAuthAndSsl(builder, username, password, useSsl, timeout);
+
+ RedisClient client = RedisClient.create(builder.build());
+ return openStandaloneSession(client, connection, variables);
+ }
+
+ private static RedisClientSession createSentinel(
+ RedisConnection connection,
+ IVariables variables,
+ Duration timeout,
+ String username,
+ String password,
+ boolean useSsl)
+ throws HopException {
+ String masterName = resolve(variables, connection.getMasterName());
+ if (StringUtils.isEmpty(masterName)) {
+ throw new HopException("Sentinel deployment requires a master name");
+ }
+ List<HostPort> nodes = parseNodes(resolve(variables, connection.getSentinelNodes()), 26379);
+ if (nodes.isEmpty()) {
+ throw new HopException("Sentinel deployment requires at least one sentinel node (host:port)");
+ }
+
+ int database = Const.toInt(resolve(variables, connection.getDatabase()), 0);
+ RedisURI.Builder builder =
+ RedisURI.builder().withSentinelMasterId(masterName).withDatabase(database);
+ for (HostPort node : nodes) {
+ builder.withSentinel(node.host(), node.port());
+ }
+ applyAuthAndSsl(builder, username, password, useSsl, timeout);
+
+ RedisClient client = RedisClient.create(builder.build());
+ return openStandaloneSession(client, connection, variables);
+ }
+
+ private static RedisClientSession createCluster(
+ RedisConnection connection,
+ IVariables variables,
+ Duration timeout,
+ String username,
+ String password,
+ boolean useSsl)
+ throws HopException {
+ List<HostPort> nodes = parseNodes(resolve(variables, connection.getClusterNodes()), 6379);
+ if (nodes.isEmpty()) {
+ throw new HopException("Cluster deployment requires at least one cluster node (host:port)");
+ }
+
+ List<RedisURI> uris = new ArrayList<>();
+ for (HostPort node : nodes) {
+ RedisURI.Builder builder = RedisURI.builder().withHost(node.host()).withPort(node.port());
+ applyAuthAndSsl(builder, username, password, useSsl, timeout);
+ uris.add(builder.build());
+ }
+
+ RedisClusterClient client = RedisClusterClient.create(uris);
+ client.setOptions(buildClusterClientOptions());
+ return openClusterSession(client, connection, variables);
+ }
+
+ private static ClusterClientOptions buildClusterClientOptions() {
+ ClusterTopologyRefreshOptions topologyRefreshOptions =
+ ClusterTopologyRefreshOptions.builder()
+ // Short-lived Hop sessions do not need periodic refresh; adaptive is enough and
+ // avoids rare empty-masterCache races during background refresh.
+ .enableAllAdaptiveRefreshTriggers()
+ .dynamicRefreshSources(true)
+ .closeStaleConnections(true)
+ .build();
+ return ClusterClientOptions.builder()
+ .topologyRefreshOptions(topologyRefreshOptions)
+ // Docker / NAT clusters often advertise node IPs that differ from the seed hosts
+ .validateClusterNodeMembership(false)
+ .build();
+ }
+
+ /**
+ * Pool/connection are handed to {@link RedisClientSession}, which closes them. try-with-resources
+ * cannot be used here: it would close the pool before the session can own it.
+ */
+ @SuppressWarnings({"java:S2093", "java:S2095"})
+ private static RedisClientSession openStandaloneSession(
+ RedisClient client, RedisConnection connection, IVariables variables) throws HopException {
+ GenericObjectPool<StatefulRedisConnection<byte[], byte[]>> pool = null;
+ try {
+ if (connection.isEnablePooling()) {
+ GenericObjectPoolConfig<StatefulRedisConnection<byte[], byte[]>> poolConfig =
+ buildPoolConfig(connection, variables);
+ pool =
+ ConnectionPoolSupport.createGenericObjectPool(
+ () -> client.connect(ByteArrayCodec.INSTANCE), poolConfig);
+ StatefulRedisConnection<byte[], byte[]> conn = pool.borrowObject();
+ return new RedisClientSession(
+ client, conn, IRedisCommands.createStandalone(conn.sync()), pool);
+ }
+ StatefulRedisConnection<byte[], byte[]> conn = client.connect(ByteArrayCodec.INSTANCE);
+ return new RedisClientSession(client, conn, IRedisCommands.createStandalone(conn.sync()));
+ } catch (Exception e) {
+ closePoolQuietly(pool);
+ client.shutdown();
+ throw new HopException("Unable to open Redis connection", e);
+ }
+ }
+
+ /**
+ * Pool/connection are handed to {@link RedisClientSession}, which closes them. try-with-resources
+ * cannot be used here: it would close the pool before the session can own it.
+ */
+ @SuppressWarnings({"java:S2093", "java:S2095"})
+ private static RedisClientSession openClusterSession(
+ RedisClusterClient client, RedisConnection connection, IVariables variables)
+ throws HopException {
+ GenericObjectPool<StatefulRedisClusterConnection<byte[], byte[]>> pool = null;
+ try {
+ // Load topology before the first key command. PING alone can succeed while the slot map
+ // is still empty/incomplete, which later fails with PartitionSelectorException.
+ ensureClusterPartitions(client);
+
+ if (connection.isEnablePooling()) {
+ GenericObjectPoolConfig<StatefulRedisClusterConnection<byte[], byte[]>> poolConfig =
+ buildPoolConfig(connection, variables);
+ pool =
+ ConnectionPoolSupport.createGenericObjectPool(
+ () -> client.connect(ByteArrayCodec.INSTANCE), poolConfig);
+ StatefulRedisClusterConnection<byte[], byte[]> conn = pool.borrowObject();
+ ensureClusterPartitions(client);
+ warmUpClusterRouting(client, conn);
+ return new RedisClientSession(
+ client, conn, IRedisCommands.createCluster(conn.sync()), pool);
+ }
+ StatefulRedisClusterConnection<byte[], byte[]> conn = client.connect(ByteArrayCodec.INSTANCE);
+ ensureClusterPartitions(client);
+ warmUpClusterRouting(client, conn);
+ return new RedisClientSession(client, conn, IRedisCommands.createCluster(conn.sync()));
+ } catch (HopException e) {
+ closePoolQuietly(pool);
+ client.shutdown();
+ throw e;
+ } catch (Exception e) {
+ closePoolQuietly(pool);
+ client.shutdown();
+ throw new HopException("Unable to open Redis cluster connection", e);
+ }
+ }
+
+ private static void closePoolQuietly(GenericObjectPool<?> pool) {
+ if (pool == null) {
+ return;
+ }
+ try {
+ pool.close();
+ } catch (Exception ignored) {
+ // best-effort cleanup after a failed open
+ }
+ }
+
+ /**
+ * Lettuce needs a complete slot map before key commands. An empty node list OR masters without
+ * full 0..16383 coverage both surface later as {@code Cannot determine a partition for slot ...}.
+ */
+ private static void ensureClusterPartitions(RedisClusterClient client) throws HopException {
+ try {
+ client.refreshPartitions();
+ } catch (Exception e) {
+ throw new HopException(
+ "Unable to refresh Redis cluster topology. "
+ + "Check Cluster nodes, credentials, and that the target is a Redis Cluster "
+ + "(CLUSTER SLOTS must succeed).",
+ e);
+ }
+
+ Partitions partitions = client.getPartitions();
+ if (partitions == null || partitions.isEmpty()) {
+ throw new HopException(
+ "Unable to load Redis cluster topology (empty partitions). "
+ + "Check Cluster nodes, that the target is a Redis Cluster (not Standalone), "
+ + "and that CLUSTER SLOTS is reachable from Hop.");
+ }
+
+ int coveredSlots = countCoveredSlots(partitions);
+ if (coveredSlots < CLUSTER_SLOT_COUNT) {
+ throw new HopException(
+ "Redis cluster topology is incomplete: "
+ + coveredSlots
+ + "/"
+ + CLUSTER_SLOT_COUNT
+ + " hash slots are assigned to masters. "
+ + "PING can still succeed, but key commands fail with "
+ + "'Cannot determine a partition for slot ...'. "
+ + "Nodes: "
+ + summarizePartitions(partitions)
+ + ". "
+ + "Fix: finish cluster creation (redis-cli --cluster create ...), "
+ + "or for Docker set cluster-announce-ip/port to addresses Hop can reach.");
+ }
+ }
+
+ /**
+ * Force one keyed command so routing uses the refreshed slot map. Retries once after refresh if
+ * Lettuce still has a stale empty master cache on the connection.
+ */
+ private static void warmUpClusterRouting(
+ RedisClusterClient client, StatefulRedisClusterConnection<byte[], byte[]> connection)
+ throws HopException {
+ try {
+ connection.sync().exists(CLUSTER_WARMUP_KEY);
+ } catch (Exception first) {
+ if (!isPartitionSelectorFailure(first)) {
+ throw new HopException("Redis cluster routing warmup failed", first);
+ }
+ ensureClusterPartitions(client);
+ try {
+ connection.sync().exists(CLUSTER_WARMUP_KEY);
+ } catch (Exception second) {
+ throw new HopException(
+ "Redis cluster cannot route key commands after topology refresh. "
+ + "Nodes: "
+ + summarizePartitions(client.getPartitions())
+ + ". "
+ + "Verify CLUSTER SLOTS covers all slots and advertised node addresses are "
+ + "reachable from Hop.",
+ second);
+ }
+ }
+ }
+
+ private static boolean isPartitionSelectorFailure(Throwable error) {
+ for (Throwable t = error; t != null; t = t.getCause()) {
+ if (t instanceof PartitionSelectorException) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ static int countCoveredSlots(Partitions partitions) {
+ BitSet covered = new BitSet(CLUSTER_SLOT_COUNT);
+ // Use getPartitions(): for-each on Partitions reads nodeReadView, which stays empty
+ // until updateCache() — addPartition() only invalidates the cache.
+ for (RedisClusterNode node : partitions.getPartitions()) {
+ if (node == null || isUnusableClusterNode(node)) {
+ continue;
+ }
+ // Masters own slots; replicas usually report none. Count any reported slot ranges.
+ for (Integer slot : node.getSlots()) {
+ if (slot != null && slot >= 0 && slot < CLUSTER_SLOT_COUNT) {
+ covered.set(slot);
+ }
+ }
+ }
+ return covered.cardinality();
+ }
+
+ private static boolean isUnusableClusterNode(RedisClusterNode node) {
+ return node.is(NodeFlag.FAIL)
+ || node.is(NodeFlag.EVENTUAL_FAIL)
+ || node.is(NodeFlag.NOADDR)
+ || node.is(NodeFlag.HANDSHAKE);
+ }
+
+ static String summarizePartitions(Partitions partitions) {
+ if (partitions == null) {
+ return "(null)";
+ }
+ List<String> parts = new ArrayList<>();
+ for (RedisClusterNode node : partitions.getPartitions()) {
+ if (node == null) {
+ continue;
+ }
+ String uri = node.getUri() != null ? node.getUri().toString() : "?";
+ parts.add(uri + "[slots=" + node.getSlots().size() + ", flags=" + node.getFlags() + "]");
+ }
+ return parts.isEmpty() ? "(none)" : String.join("; ", parts);
+ }
+
+ private static <T> GenericObjectPoolConfig<T> buildPoolConfig(
+ RedisConnection connection, IVariables variables) {
+ GenericObjectPoolConfig<T> config = new GenericObjectPoolConfig<>();
+ config.setMaxTotal(Const.toInt(resolve(variables, connection.getPoolMaxTotal()), 8));
+ config.setMaxIdle(Const.toInt(resolve(variables, connection.getPoolMaxIdle()), 8));
+ config.setMinIdle(Const.toInt(resolve(variables, connection.getPoolMinIdle()), 0));
+ long maxWaitMs = Const.toLong(resolve(variables, connection.getPoolMaxWaitMs()), -1L);
+ if (maxWaitMs < 0) {
+ config.setMaxWait(Duration.ofMillis(-1));
+ } else {
+ config.setMaxWait(Duration.ofMillis(maxWaitMs));
+ }
+ config.setTestOnBorrow(true);
+ return config;
+ }
+
+ private static void applyAuthAndSsl(
+ RedisURI.Builder builder,
+ String username,
+ String password,
+ boolean useSsl,
+ Duration timeout) {
+ if (StringUtils.isNotEmpty(password)) {
+ if (StringUtils.isNotEmpty(username)) {
+ builder.withAuthentication(username, password.toCharArray());
+ } else {
+ builder.withPassword(password.toCharArray());
+ }
+ }
+ if (useSsl) {
+ builder.withSsl(true);
+ }
+ if (timeout != null) {
+ builder.withTimeout(timeout);
+ }
+ }
+
+ private static Duration resolveTimeout(RedisConnection connection, IVariables variables) {
+ String timeoutMs = resolve(variables, connection.getTimeoutMs());
+ if (StringUtils.isEmpty(timeoutMs)) {
+ return Duration.ofSeconds(10);
+ }
+ long ms = Const.toLong(timeoutMs, 10000L);
+ return Duration.ofMillis(Math.max(ms, 1L));
+ }
+
+ private static String resolve(IVariables variables, String value) {
+ if (value == null) {
+ return null;
+ }
+ return variables.resolve(value);
+ }
+
+ static List<HostPort> parseNodes(String nodesText, int defaultPort) {
+ List<HostPort> nodes = new ArrayList<>();
+ if (StringUtils.isEmpty(nodesText)) {
+ return nodes;
+ }
+ Arrays.stream(nodesText.split("[,;\\s]+"))
+ .map(String::trim)
+ .filter(StringUtils::isNotEmpty)
+ .forEach(
+ token -> {
+ String host = token;
+ int port = defaultPort;
+ int colon = token.lastIndexOf(':');
+ if (colon > 0 && colon < token.length() - 1) {
+ host = token.substring(0, colon);
+ port = Const.toInt(token.substring(colon + 1), defaultPort);
+ }
+ nodes.add(new HostPort(host, port));
+ });
+ return nodes;
+ }
+
+ record HostPort(String host, int port) {}
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/client/RedisClientSession.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/client/RedisClientSession.java
new file mode 100644
index 0000000000..2a2e93af68
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/client/RedisClientSession.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.client;
+
+import io.lettuce.core.AbstractRedisClient;
+import io.lettuce.core.api.StatefulConnection;
+import io.lettuce.core.api.StatefulRedisConnection;
+import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
+import io.lettuce.core.codec.ByteArrayCodec;
+import lombok.Getter;
+import org.apache.commons.pool2.impl.GenericObjectPool;
+
+/** Holds an open Lettuce client/connection (optionally pooled) and exposes sync commands. */
+public final class RedisClientSession implements AutoCloseable {
+
+ private final AbstractRedisClient client;
+ private final StatefulConnection<byte[], byte[]> connection;
+ @Getter private final IRedisCommands commands;
+ private final boolean cluster;
+
+ @SuppressWarnings("rawtypes")
+ private final GenericObjectPool pool;
+
+ public RedisClientSession(
+ AbstractRedisClient client,
+ StatefulRedisConnection<byte[], byte[]> connection,
+ IRedisCommands commands) {
+ this(client, connection, commands, false, null);
+ }
+
+ public RedisClientSession(
+ AbstractRedisClient client,
+ StatefulRedisClusterConnection<byte[], byte[]> connection,
+ IRedisCommands commands) {
+ this(client, connection, commands, true, null);
+ }
+
+ @SuppressWarnings("rawtypes")
+ public RedisClientSession(
+ AbstractRedisClient client,
+ StatefulRedisConnection<byte[], byte[]> connection,
+ IRedisCommands commands,
+ GenericObjectPool pool) {
+ this(client, connection, commands, false, pool);
+ }
+
+ @SuppressWarnings("rawtypes")
+ public RedisClientSession(
+ AbstractRedisClient client,
+ StatefulRedisClusterConnection<byte[], byte[]> connection,
+ IRedisCommands commands,
+ GenericObjectPool pool) {
+ this(client, connection, commands, true, pool);
+ }
+
+ @SuppressWarnings("rawtypes")
+ private RedisClientSession(
+ AbstractRedisClient client,
+ StatefulConnection<byte[], byte[]> connection,
+ IRedisCommands commands,
+ boolean cluster,
+ GenericObjectPool pool) {
+ this.client = client;
+ this.connection = connection;
+ this.commands = commands;
+ this.cluster = cluster;
+ this.pool = pool;
+ }
+
+ public static ByteArrayCodec codec() {
+ return ByteArrayCodec.INSTANCE;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public void close() {
+ try {
+ if (pool != null) {
+ try {
+ pool.returnObject(connection);
+ } finally {
+ pool.close();
+ }
+ } else if (connection != null) {
+ connection.close();
+ }
+ } catch (Exception ignored) {
+ // best-effort close; client shutdown still runs below
+ } finally {
+ client.shutdown();
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/codec/RedisCodecType.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/codec/RedisCodecType.java
new file mode 100644
index 0000000000..306abb15cb
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/codec/RedisCodecType.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.codec;
+
+/** redis code type[string, java_object, json, byte] */
+public enum RedisCodecType {
+ STRING,
+ JAVA_OBJECT,
+ JSON,
+ BYTE;
+
+ public static String[] getNames() {
+ String[] names = new String[values().length];
+ for (int i = 0; i < names.length; i++) {
+ names[i] = values()[i].name();
+ }
+ return names;
+ }
+
+ public static RedisCodecType fromCode(String code) {
+ if (code == null || code.isBlank()) {
+ return STRING;
+ }
+ try {
+ return RedisCodecType.valueOf(code.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return STRING;
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/codec/RedisCodecs.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/codec/RedisCodecs.java
new file mode 100644
index 0000000000..9674c0c42c
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/codec/RedisCodecs.java
@@ -0,0 +1,235 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.codec;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Objects;
+import java.util.regex.Pattern;
+import org.apache.hop.core.exception.HopException;
+
+/**
+ * Factory and holders for the four RedisTemplate-style codec slots: key, value, hashKey and
+ * hashValue.
+ */
+public final class RedisCodecs {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final RedisValueCodec keyCodec;
+ private final RedisValueCodec valueCodec;
+ private final RedisValueCodec hashKeyCodec;
+ private final RedisValueCodec hashValueCodec;
+
+ public RedisCodecs(
+ RedisCodecType keyType,
+ RedisCodecType valueType,
+ RedisCodecType hashKeyType,
+ RedisCodecType hashValueType) {
+ this.keyCodec = create(keyType);
+ this.valueCodec = create(valueType);
+ this.hashKeyCodec = create(hashKeyType);
+ this.hashValueCodec = create(hashValueType);
+ }
+
+ public static RedisCodecs of(
+ RedisCodecType keyType,
+ RedisCodecType valueType,
+ RedisCodecType hashKeyType,
+ RedisCodecType hashValueType) {
+ return new RedisCodecs(
+ Objects.requireNonNullElse(keyType, RedisCodecType.STRING),
+ Objects.requireNonNullElse(valueType, RedisCodecType.STRING),
+ Objects.requireNonNullElse(hashKeyType, RedisCodecType.STRING),
+ Objects.requireNonNullElse(hashValueType, RedisCodecType.STRING));
+ }
+
+ public RedisValueCodec key() {
+ return keyCodec;
+ }
+
+ public RedisValueCodec value() {
+ return valueCodec;
+ }
+
+ public RedisValueCodec hashKey() {
+ return hashKeyCodec;
+ }
+
+ public RedisValueCodec hashValue() {
+ return hashValueCodec;
+ }
+
+ public static RedisValueCodec create(RedisCodecType type) {
+ RedisCodecType resolved = type == null ? RedisCodecType.STRING : type;
+ switch (resolved) {
+ case JSON:
+ return new JsonCodec();
+ case JAVA_OBJECT:
+ return new JavaObjectCodec();
+ case BYTE:
+ return new ByteCodec();
+ case STRING:
+ default:
+ return new StringCodec();
+ }
+ }
+
+ static final class StringCodec implements RedisValueCodec {
+ @Override
+ public byte[] encode(Object value) {
+ if (value == null) {
+ return new byte[0];
+ }
+ if (value instanceof byte[]) {
+ return (byte[]) value;
+ }
+ return String.valueOf(value).getBytes(StandardCharsets.UTF_8);
+ }
+
+ @Override
+ public Object decode(byte[] bytes) {
+ if (bytes == null) {
+ return null;
+ }
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+ }
+
+ static final class JsonCodec implements RedisValueCodec {
+ @Override
+ public byte[] encode(Object value) throws HopException {
+ try {
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof byte[]) {
+ return (byte[]) value;
+ }
+ if (value instanceof String) {
+ // Treat as JSON text already
+ return ((String) value).getBytes(StandardCharsets.UTF_8);
+ }
+ return OBJECT_MAPPER.writeValueAsBytes(value);
+ } catch (JsonProcessingException e) {
+ throw new HopException(e);
+ }
+ }
+
+ @Override
+ public Object decode(byte[] bytes) throws HopException {
+ if (bytes == null) {
+ return null;
+ }
+ // Return JSON as String for Hop row compatibility
+ try {
+ Object parsed = OBJECT_MAPPER.readValue(bytes, Object.class);
+ if (parsed instanceof String || parsed instanceof Number || parsed instanceof Boolean) {
+ return parsed;
+ }
+ return OBJECT_MAPPER.writeValueAsString(parsed);
+ } catch (IOException e) {
+ throw new HopException(e);
+ }
+ }
+ }
+
+ static final class JavaObjectCodec implements RedisValueCodec {
+ @Override
+ public byte[] encode(Object value) throws HopException {
+ if (value == null) {
+ return new byte[0];
+ }
+ if (value instanceof byte[]) {
+ return (byte[]) value;
+ }
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
+ oos.writeObject(value);
+ } catch (IOException e) {
+ throw new HopException(e);
+ }
+ return bos.toByteArray();
+ }
+
+ @Override
+ public Object decode(byte[] bytes) throws HopException {
+ if (bytes == null) {
+ return null;
+ }
+ try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
+ return ois.readObject();
+ } catch (Exception e) {
+ throw new HopException(e);
+ }
+ }
+ }
+
+ static final class ByteCodec implements RedisValueCodec {
+
+ /**
+ * Alphabet + optional end padding. Length multiple-of-4 is checked separately so short values
+ * like {@code "12"} or {@code "age"} stay UTF-8 text.
+ */
+ private static final Pattern BASE64_PATTERN = Pattern.compile("^[A-Za-z0-9+/]*={0,2}$");
+
+ @Override
+ public byte[] encode(Object value) {
+ if (value == null) {
+ return new byte[0];
+ }
+ if (value instanceof byte[]) {
+ return (byte[]) value;
+ }
+ // Numbers are stored as UTF-8 decimal text (not Base64 — "12" is valid Base64 for 0xD7).
+ if (value instanceof Number) {
+ return String.valueOf(value).getBytes(StandardCharsets.UTF_8);
+ }
+ String text = String.valueOf(value);
+ if (looksLikeBase64(text)) {
+ try {
+ return Base64.getDecoder().decode(text);
+ } catch (IllegalArgumentException e) {
+ // fall through to UTF-8
+ }
+ }
+ return text.getBytes(StandardCharsets.UTF_8);
+ }
+
+ @Override
+ public Object decode(byte[] bytes) {
+ return bytes;
+ }
+
+ /**
+ * Only treat padded, length-aligned strings as Base64 so short values like {@code "12"} or
+ * {@code "age"} are stored as UTF-8 text.
+ */
+ static boolean looksLikeBase64(String text) {
+ int len = text.length();
+ return len >= 4 && len % 4 == 0 && BASE64_PATTERN.matcher(text).matches();
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/codec/RedisValueCodec.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/codec/RedisValueCodec.java
new file mode 100644
index 0000000000..76432f1cf6
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/codec/RedisValueCodec.java
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.codec;
+
+import org.apache.hop.core.exception.HopException;
+
+/** Encodes and decodes Redis keys/values as byte arrays. */
+public interface RedisValueCodec {
+
+ byte[] encode(Object value) throws HopException;
+
+ Object decode(byte[] bytes) throws HopException;
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/metadata/RedisConnection.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/metadata/RedisConnection.java
new file mode 100644
index 0000000000..94f7ec1dfb
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/metadata/RedisConnection.java
@@ -0,0 +1,257 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.metadata;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.HopMetadata;
+import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.HopMetadataPropertyType;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.redis.client.RedisClientFactory;
+import org.apache.hop.redis.client.RedisClientSession;
+
+/** redis connection */
+@Getter
+@Setter
+@GuiPlugin
+@SuppressWarnings("java:S2160")
+@HopMetadata(
+ key = "redis-connection",
+ name = "i18n::RedisConnection.name",
+ description = "i18n::RedisConnection.description",
+ image = "redis.svg",
+ category = HopMetadataCategory.CONNECTIONS,
+ documentationUrl = "/metadata-types/redis/redis-connection.html",
+ hopMetadataPropertyType = HopMetadataPropertyType.REDIS_CONNECTION,
+ supportsGlobalReplace = true)
+public class RedisConnection extends HopMetadataBase implements IHopMetadata {
+
+ // --- Connection config widgets ---
+ public static final String WIDGET_ID_DEPLOYMENT_MODE = "10000-deployment-mode";
+ public static final String WIDGET_ID_HOSTNAME = "10100-hostname";
+ public static final String WIDGET_ID_PORT = "10200-port";
+ public static final String WIDGET_ID_DATABASE = "10300-database";
+ public static final String WIDGET_ID_MASTER_NAME = "10400-master-name";
+ public static final String WIDGET_ID_SENTINEL_NODES = "10500-sentinel-nodes";
+ public static final String WIDGET_ID_CLUSTER_NODES = "10600-cluster-nodes";
+ public static final String WIDGET_ID_USERNAME = "10700-username";
+ public static final String WIDGET_ID_PASSWORD = "10800-password";
+ public static final String WIDGET_ID_USE_SSL = "10900-use-ssl";
+ public static final String WIDGET_ID_TIMEOUT_MS = "11000-timeout-ms";
+
+ // --- Pool config widgets (shared by all deployment modes) ---
+ public static final String WIDGET_ID_ENABLE_POOLING = "20000-enable-pooling";
+ public static final String WIDGET_ID_POOL_MAX_TOTAL = "20100-pool-max-total";
+ public static final String WIDGET_ID_POOL_MAX_IDLE = "20200-pool-max-idle";
+ public static final String WIDGET_ID_POOL_MIN_IDLE = "20300-pool-min-idle";
+ public static final String WIDGET_ID_POOL_MAX_WAIT_MS = "20400-pool-max-wait-ms";
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_DEPLOYMENT_MODE,
+ type = GuiElementType.COMBO,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.DeploymentMode.Label",
+ toolTip = "i18n::RedisMetadata.DeploymentMode.ToolTip",
+ variables = false)
+ private RedisDeploymentMode deploymentMode = RedisDeploymentMode.STANDALONE;
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_HOSTNAME,
+ type = GuiElementType.TEXT,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.Hostname.Label",
+ toolTip = "i18n::RedisMetadata.Hostname.ToolTip")
+ private String hostname = "localhost";
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_PORT,
+ type = GuiElementType.TEXT,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.Port.Label",
+ toolTip = "i18n::RedisMetadata.Port.ToolTip")
+ private String port = "6379";
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_DATABASE,
+ type = GuiElementType.TEXT,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.Database.Label",
+ toolTip = "i18n::RedisMetadata.Database.ToolTip")
+ private String database = "0";
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_MASTER_NAME,
+ type = GuiElementType.TEXT,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.MasterName.Label",
+ toolTip = "i18n::RedisMetadata.MasterName.ToolTip")
+ private String masterName;
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_SENTINEL_NODES,
+ type = GuiElementType.MULTI_LINE_TEXT,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.SentinelNodes.Label",
+ toolTip = "i18n::RedisMetadata.SentinelNodes.ToolTip",
+ multiLineTextHeight = 4)
+ private String sentinelNodes;
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_CLUSTER_NODES,
+ type = GuiElementType.MULTI_LINE_TEXT,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.ClusterNodes.Label",
+ toolTip = "i18n::RedisMetadata.ClusterNodes.ToolTip",
+ multiLineTextHeight = 6)
+ private String clusterNodes;
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_USERNAME,
+ type = GuiElementType.TEXT,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.Username.Label",
+ toolTip = "i18n::RedisMetadata.Username.ToolTip")
+ private String username;
+
+ @HopMetadataProperty(password = true)
+ @GuiWidgetElement(
+ id = WIDGET_ID_PASSWORD,
+ type = GuiElementType.TEXT,
+ password = true,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.Password.Label",
+ toolTip = "i18n::RedisMetadata.Password.ToolTip")
+ private String password;
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_USE_SSL,
+ type = GuiElementType.CHECKBOX,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.UseSsl.Label",
+ toolTip = "i18n::RedisMetadata.UseSsl.ToolTip")
+ private boolean useSsl;
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_TIMEOUT_MS,
+ type = GuiElementType.TEXT,
+ parentId = RedisConnectionEditor.CONNECTION_WIDGET_ID,
+ label = "i18n::RedisMetadata.TimeoutMs.Label",
+ toolTip = "i18n::RedisMetadata.TimeoutMs.ToolTip")
+ private String timeoutMs = "10000";
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_ENABLE_POOLING,
+ type = GuiElementType.CHECKBOX,
+ parentId = RedisConnectionEditor.POOL_WIDGET_ID,
+ label = "i18n::RedisMetadata.EnablePooling.Label",
+ toolTip = "i18n::RedisMetadata.EnablePooling.ToolTip")
+ private boolean enablePooling;
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_POOL_MAX_TOTAL,
+ type = GuiElementType.TEXT,
+ parentId = RedisConnectionEditor.POOL_WIDGET_ID,
+ label = "i18n::RedisMetadata.PoolMaxTotal.Label",
+ toolTip = "i18n::RedisMetadata.PoolMaxTotal.ToolTip")
+ private String poolMaxTotal = "8";
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_POOL_MAX_IDLE,
+ type = GuiElementType.TEXT,
+ parentId = RedisConnectionEditor.POOL_WIDGET_ID,
+ label = "i18n::RedisMetadata.PoolMaxIdle.Label",
+ toolTip = "i18n::RedisMetadata.PoolMaxIdle.ToolTip")
+ private String poolMaxIdle = "8";
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_POOL_MIN_IDLE,
+ type = GuiElementType.TEXT,
+ parentId = RedisConnectionEditor.POOL_WIDGET_ID,
+ label = "i18n::RedisMetadata.PoolMinIdle.Label",
+ toolTip = "i18n::RedisMetadata.PoolMinIdle.ToolTip")
+ private String poolMinIdle = "0";
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_ID_POOL_MAX_WAIT_MS,
+ type = GuiElementType.TEXT,
+ parentId = RedisConnectionEditor.POOL_WIDGET_ID,
+ label = "i18n::RedisMetadata.PoolMaxWaitMs.Label",
+ toolTip = "i18n::RedisMetadata.PoolMaxWaitMs.ToolTip")
+ private String poolMaxWaitMs = "-1";
+
+ public RedisConnection() {}
+
+ public RedisConnection(RedisConnection c) {
+ super(c.name);
+ this.deploymentMode = c.deploymentMode;
+ this.hostname = c.hostname;
+ this.port = c.port;
+ this.database = c.database;
+ this.masterName = c.masterName;
+ this.sentinelNodes = c.sentinelNodes;
+ this.clusterNodes = c.clusterNodes;
+ this.username = c.username;
+ this.password = c.password;
+ this.useSsl = c.useSsl;
+ this.timeoutMs = c.timeoutMs;
+ this.enablePooling = c.enablePooling;
+ this.poolMaxTotal = c.poolMaxTotal;
+ this.poolMaxIdle = c.poolMaxIdle;
+ this.poolMinIdle = c.poolMinIdle;
+ this.poolMaxWaitMs = c.poolMaxWaitMs;
+ }
+
+ /**
+ * Test this connection with a Redis PING. For CLUSTER, {@link RedisClientFactory} also refreshes
+ * topology and verifies hash-slot routing before returning.
+ *
+ * @param variables variables for resolving connection fields
+ * @throws HopException if the connection, topology, or PING fails
+ */
+ public void test(IVariables variables) throws HopException {
+ try (RedisClientSession session = RedisClientFactory.create(this, variables)) {
+ String pong = session.getCommands().ping();
+ if (!"PONG".equalsIgnoreCase(pong)) {
+ throw new HopException("Unexpected Redis PING response: " + pong);
+ }
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/metadata/RedisConnectionEditor.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/metadata/RedisConnectionEditor.java
new file mode 100644
index 0000000000..aff5d290ae
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/metadata/RedisConnectionEditor.java
@@ -0,0 +1,292 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.metadata;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.hop.core.Const;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgetsAdapter;
+import org.apache.hop.ui.core.metadata.MetadataEditor;
+import org.apache.hop.ui.core.metadata.MetadataManager;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.ScrolledComposite;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Group;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Text;
+
+/** redis connection editor */
+@SuppressWarnings("java:S2160")
+public class RedisConnectionEditor extends MetadataEditor<RedisConnection> {
+
+ private static final Class<?> PKG = RedisConnection.class;
+
+ /** Widgets belonging to the Connection config group. */
+ public static final String CONNECTION_WIDGET_ID = "RedisConnectionEditor.Connection.ParentId";
+
+ /** Widgets belonging to the Pool config group (shared by all deployment modes). */
+ public static final String POOL_WIDGET_ID = "RedisConnectionEditor.Pool.ParentId";
+
+ private Composite parent;
+ private Text wName;
+ private GuiCompositeWidgets connectionWidgets;
+ private GuiCompositeWidgets poolWidgets;
+ private Group gConnection;
+ private Group gPool;
+ private ScrolledComposite wScrolled;
+ private Composite wContent;
+
+ public RedisConnectionEditor(
+ HopGui hopGui, MetadataManager<RedisConnection> manager, RedisConnection metadata) {
+ super(hopGui, manager, metadata);
+ }
+
+ @Override
+ public void createControl(Composite parent) {
+ this.parent = parent;
+
+ PropsUi props = PropsUi.getInstance();
+ int margin = PropsUi.getMargin();
+ int middle = props.getMiddlePct();
+
+ Label wlName = new Label(parent, SWT.RIGHT);
+ PropsUi.setLook(wlName);
+ wlName.setText(BaseMessages.getString(PKG, "RedisConnectionEditor.Name.Label"));
+ FormData fdlName = new FormData();
+ fdlName.top = new FormAttachment(0, margin * 2);
+ fdlName.left = new FormAttachment(0, 0);
+ fdlName.right = new FormAttachment(middle, 0);
+ wlName.setLayoutData(fdlName);
+ wName = new Text(parent, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(wName);
+ FormData fdName = new FormData();
+ fdName.top = new FormAttachment(wlName, 0, SWT.CENTER);
+ fdName.left = new FormAttachment(middle, margin);
+ fdName.right = new FormAttachment(100, 0);
+ wName.setLayoutData(fdName);
+
+ Label spacer = new Label(parent, SWT.HORIZONTAL | SWT.SEPARATOR);
+ FormData fdSpacer = new FormData();
+ fdSpacer.left = new FormAttachment(0, 0);
+ fdSpacer.top = new FormAttachment(wName, 15);
+ fdSpacer.right = new FormAttachment(100, 0);
+ spacer.setLayoutData(fdSpacer);
+
+ // Vertical scroll only: multi-line node lists must not force a horizontal bar via pack().
+ wScrolled = new ScrolledComposite(parent, SWT.V_SCROLL);
+ FormData fdScrolled = new FormData();
+ fdScrolled.left = new FormAttachment(0, 0);
+ fdScrolled.right = new FormAttachment(100, 0);
+ fdScrolled.top = new FormAttachment(spacer, 15);
+ fdScrolled.bottom = new FormAttachment(100, 0);
+ wScrolled.setLayoutData(fdScrolled);
+ wScrolled.setExpandHorizontal(true);
+ wScrolled.setExpandVertical(true);
+
+ wContent = new Composite(wScrolled, SWT.NONE);
+ PropsUi.setLook(wContent);
+ FormLayout contentLayout = new FormLayout();
+ contentLayout.marginWidth = 0;
+ contentLayout.marginHeight = 0;
+ wContent.setLayout(contentLayout);
+ wScrolled.setContent(wContent);
+
+ gConnection = new Group(wContent, SWT.SHADOW_ETCHED_IN);
+ PropsUi.setLook(gConnection);
+ gConnection.setText(BaseMessages.getString(PKG, "RedisConnectionEditor.ConnectionGroup.Label"));
+ FormLayout connectionLayout = new FormLayout();
+ connectionLayout.marginWidth = 10;
+ connectionLayout.marginHeight = 10;
+ gConnection.setLayout(connectionLayout);
+ FormData fdConnection = new FormData();
+ fdConnection.left = new FormAttachment(0, 0);
+ fdConnection.right = new FormAttachment(100, 0);
+ fdConnection.top = new FormAttachment(0, 0);
+ gConnection.setLayoutData(fdConnection);
+
+ connectionWidgets = new GuiCompositeWidgets(manager.getVariables());
+ connectionWidgets.createCompositeWidgets(
+ getMetadata(), null, gConnection, CONNECTION_WIDGET_ID, null);
+
+ gPool = new Group(wContent, SWT.SHADOW_ETCHED_IN);
+ PropsUi.setLook(gPool);
+ gPool.setText(BaseMessages.getString(PKG, "RedisConnectionEditor.PoolGroup.Label"));
+ FormLayout poolLayout = new FormLayout();
+ poolLayout.marginWidth = 10;
+ poolLayout.marginHeight = 10;
+ gPool.setLayout(poolLayout);
+ FormData fdPool = new FormData();
+ fdPool.left = new FormAttachment(0, 0);
+ fdPool.right = new FormAttachment(100, 0);
+ fdPool.top = new FormAttachment(gConnection, 15);
+ gPool.setLayoutData(fdPool);
+
+ poolWidgets = new GuiCompositeWidgets(manager.getVariables());
+ poolWidgets.createCompositeWidgets(getMetadata(), null, gPool, POOL_WIDGET_ID, null);
+
+ wScrolled.addListener(SWT.Resize, e -> relayoutScrolledContent());
+
+ setWidgetsContent();
+ updateVisibility();
+
+ wName.addListener(SWT.Modify, e -> setChanged());
+ GuiCompositeWidgetsAdapter listener =
+ new GuiCompositeWidgetsAdapter() {
+ @Override
+ public void widgetModified(
+ GuiCompositeWidgets compositeWidgets, Control changedWidget, String widgetId) {
+ setChanged();
+ if (RedisConnection.WIDGET_ID_DEPLOYMENT_MODE.equals(widgetId)) {
+ updateVisibility();
+ }
+ }
+ };
+ connectionWidgets.setWidgetsListener(listener);
+ poolWidgets.setWidgetsListener(listener);
+ }
+
+ @Override
+ public void setWidgetsContent() {
+ RedisConnection meta = getMetadata();
+ wName.setText(Const.NVL(meta.getName(), ""));
+ connectionWidgets.setWidgetsContents(meta, gConnection, CONNECTION_WIDGET_ID);
+ poolWidgets.setWidgetsContents(meta, gPool, POOL_WIDGET_ID);
+ updateVisibility();
+ }
+
+ @Override
+ public void getWidgetsContent(RedisConnection meta) {
+ meta.setName(wName.getText());
+ connectionWidgets.getWidgetsContents(meta, CONNECTION_WIDGET_ID);
+ poolWidgets.getWidgetsContents(meta, POOL_WIDGET_ID);
+ if (meta.getDeploymentMode() == RedisDeploymentMode.CLUSTER) {
+ meta.setDatabase("0");
+ }
+ }
+
+ /**
+ * Show only the Connection-config fields that apply to the selected deployment mode. Pool config
+ * is always visible and shared across Standalone / Sentinel / Cluster.
+ */
+ private void updateVisibility() {
+ if (connectionWidgets == null) {
+ return;
+ }
+ RedisDeploymentMode mode = readDeploymentMode();
+ Set<String> hidden = new HashSet<>();
+
+ boolean standalone = mode == RedisDeploymentMode.STANDALONE;
+ boolean sentinel = mode == RedisDeploymentMode.SENTINEL;
+ boolean cluster = mode == RedisDeploymentMode.CLUSTER;
+
+ hideUnless(
+ hidden, standalone, RedisConnection.WIDGET_ID_HOSTNAME, RedisConnection.WIDGET_ID_PORT);
+ hideUnless(
+ hidden,
+ sentinel,
+ RedisConnection.WIDGET_ID_MASTER_NAME,
+ RedisConnection.WIDGET_ID_SENTINEL_NODES);
+ hideUnless(hidden, cluster, RedisConnection.WIDGET_ID_CLUSTER_NODES);
+ hideUnless(hidden, standalone || sentinel, RedisConnection.WIDGET_ID_DATABASE);
+
+ connectionWidgets.setWidgetsHidden(getMetadata(), hidden);
+
+ if (gConnection != null && !gConnection.isDisposed()) {
+ gConnection.layout(true, true);
+ }
+ if (gPool != null && !gPool.isDisposed()) {
+ gPool.layout(true, true);
+ }
+ relayoutScrolledContent();
+ }
+
+ /**
+ * Size the scrolled content to the client width so multi-line node fields (long host:port lines)
+ * do not create a horizontal scrollbar between the name field and the button bar.
+ */
+ private void relayoutScrolledContent() {
+ if (wScrolled == null || wScrolled.isDisposed() || wContent == null || wContent.isDisposed()) {
+ return;
+ }
+ wContent.layout(true, true);
+ Rectangle client = wScrolled.getClientArea();
+ int width = Math.max(client.width, 1);
+ Point size = wContent.computeSize(width, SWT.DEFAULT);
+ wScrolled.setMinWidth(width);
+ wScrolled.setMinHeight(size.y);
+ wContent.setSize(width, size.y);
+ }
+
+ private void hideUnless(Set<String> hidden, boolean applies, String... widgetIds) {
+ if (!applies) {
+ hidden.addAll(List.of(widgetIds));
+ }
+ }
+
+ private RedisDeploymentMode readDeploymentMode() {
+ Control control =
+ connectionWidgets.getWidgetsMap().get(RedisConnection.WIDGET_ID_DEPLOYMENT_MODE);
+ if (control instanceof Combo) {
+ return RedisDeploymentMode.fromCode(((Combo) control).getText());
+ }
+ RedisDeploymentMode mode = getMetadata().getDeploymentMode();
+ return mode == null ? RedisDeploymentMode.STANDALONE : mode;
+ }
+
+ @Override
+ public Button[] createButtonsForButtonBar(Composite parent) {
+ Button wbTest = new Button(parent, SWT.PUSH | SWT.CENTER);
+ PropsUi.setLook(wbTest);
+ wbTest.setText("Test");
+ wbTest.addListener(SWT.Selection, e -> test());
+ return new Button[] {wbTest};
+ }
+
+ public void test() {
+ try {
+ RedisConnection meta = new RedisConnection();
+ getWidgetsContent(meta);
+ meta.test(manager.getVariables());
+ MessageBox box = new MessageBox(parent.getShell(), SWT.ICON_INFORMATION | SWT.OK);
+ box.setText("Success!");
+ box.setMessage(
+ "Connected successfully (PING → PONG)!"
+ + (meta.getDeploymentMode() == RedisDeploymentMode.CLUSTER
+ ? " Cluster topology and slot routing were also verified."
+ : ""));
+ box.open();
+ } catch (Exception e) {
+ new ErrorDialog(parent.getShell(), "Error", "We couldn't connect using this information", e);
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/metadata/RedisDeploymentMode.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/metadata/RedisDeploymentMode.java
new file mode 100644
index 0000000000..22af7e2673
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/metadata/RedisDeploymentMode.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.metadata;
+
+public enum RedisDeploymentMode {
+ STANDALONE,
+ SENTINEL,
+ CLUSTER;
+
+ public static String[] getNames() {
+ String[] names = new String[values().length];
+ for (int i = 0; i < names.length; i++) {
+ names[i] = values()[i].name();
+ }
+ return names;
+ }
+
+ public static RedisDeploymentMode fromCode(String code) {
+ if (code == null) {
+ return STANDALONE;
+ }
+ try {
+ return RedisDeploymentMode.valueOf(code.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return STANDALONE;
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/RedisDataStructure.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/RedisDataStructure.java
new file mode 100644
index 0000000000..5ac1c28223
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/RedisDataStructure.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms;
+
+/** Support redis data structure */
+public enum RedisDataStructure {
+ STRING,
+ HASH,
+ SET,
+ LIST;
+
+ public static String[] getNames() {
+ String[] names = new String[values().length];
+ for (int i = 0; i < names.length; i++) {
+ names[i] = values()[i].name();
+ }
+ return names;
+ }
+
+ public static RedisDataStructure fromCode(String code) {
+ if (code == null || code.isBlank()) {
+ return STRING;
+ }
+ try {
+ return RedisDataStructure.valueOf(code.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return STRING;
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/RedisListPushDirection.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/RedisListPushDirection.java
new file mode 100644
index 0000000000..d5275bca45
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/RedisListPushDirection.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms;
+
+public enum RedisListPushDirection {
+ RPUSH,
+ LPUSH;
+
+ public static RedisListPushDirection fromCode(String code) {
+ if (code == null || code.isBlank()) {
+ return RPUSH;
+ }
+ try {
+ return RedisListPushDirection.valueOf(code.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return RPUSH;
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInput.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInput.java
new file mode 100644
index 0000000000..f087a3649a
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInput.java
@@ -0,0 +1,264 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.RowDataUtil;
+import org.apache.hop.metadata.api.IHopMetadataSerializer;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.redis.client.IRedisCommands;
+import org.apache.hop.redis.client.RedisClientFactory;
+import org.apache.hop.redis.client.RedisClientSession;
+import org.apache.hop.redis.codec.RedisCodecs;
+import org.apache.hop.redis.metadata.RedisConnection;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.redis.transforms.redisinput.RedisInputData.Mapping;
+
+public class RedisInput extends BaseTransform<RedisInputMeta, RedisInputData> {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ public RedisInput(
+ TransformMeta transformMeta,
+ RedisInputMeta meta,
+ RedisInputData data,
+ int copyNr,
+ PipelineMeta pipelineMeta,
+ Pipeline pipeline) {
+ super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
+ }
+
+ @Override
+ public boolean processRow() throws HopException {
+ Object[] row = getRow();
+ if (row == null) {
+ setOutputDone();
+ return false;
+ }
+
+ try {
+ if (first) {
+ first = false;
+ initFirst();
+ }
+
+ IRedisCommands commands = data.getSession().getCommands();
+ Object[] output = RowDataUtil.createResizedCopy(row, data.getOutputRowMeta().size());
+
+ for (Mapping mapping : data.getMappings()) {
+ Object keyObj =
+ mapping.getKeyFieldIndex() >= 0
+ ? row[mapping.getKeyFieldIndex()]
+ : resolve(mapping.getKeyLiteral());
+ byte[] keyBytes = mapping.getKeyCodec().encode(keyObj);
+ output[mapping.getValueOutputIndex()] = readValue(commands, mapping, row, keyBytes);
+ }
+
+ putRow(data.getOutputRowMeta(), output);
+ return true;
+ } catch (Exception e) {
+ throw new HopException("Error reading from Redis", e);
+ }
+ }
+
+ private void initFirst() throws HopException {
+ data.setOutputRowMeta(getInputRowMeta().clone());
+ meta.getFields(data.getOutputRowMeta(), getTransformName(), null, null, this, metadataProvider);
+ data.setFirstValueIndex(getInputRowMeta().size());
+ data.setSession(openSession());
+ data.setMappings(buildMappings());
+ }
+
+ private RedisClientSession openSession() throws HopException {
+ if (StringUtils.isEmpty(meta.getConnectionName())) {
+ throw new HopException("A Redis connection name is required");
+ }
+ IHopMetadataSerializer<RedisConnection> serializer =
+ metadataProvider.getSerializer(RedisConnection.class);
+ RedisConnection connection = serializer.load(meta.getConnectionName());
+ if (connection == null) {
+ throw new HopException("Redis connection '" + meta.getConnectionName() + "' not found");
+ }
+ return RedisClientFactory.create(connection, this);
+ }
+
+ private Mapping[] buildMappings() throws HopException {
+ List<RedisInputField> fields = meta.getFields();
+ if (fields == null || fields.isEmpty()) {
+ throw new HopException("At least one field mapping is required");
+ }
+
+ Mapping[] mappings = new Mapping[fields.size()];
+ for (int i = 0; i < fields.size(); i++) {
+ mappings[i] = buildMapping(fields.get(i), i + 1, data.getFirstValueIndex() + i);
+ }
+ return mappings;
+ }
+
+ private Mapping buildMapping(RedisInputField field, int rowNum, int valueOutputIndex)
+ throws HopException {
+ if (StringUtils.isEmpty(field.getRedisKey())) {
+ throw new HopException("Redis key is required for mapping row " + rowNum);
+ }
+ if (StringUtils.isEmpty(field.getValueField())) {
+ throw new HopException("Value field is required for mapping row " + rowNum);
+ }
+
+ Mapping mapping = new Mapping();
+ mapping.setStructure(field.resolveDataStructure());
+ mapping.setKeyCodec(RedisCodecs.create(field.getRedisKeyCodec()));
+ mapping.setValueCodec(RedisCodecs.create(field.getValueCodec()));
+ mapping.setValueOutputIndex(valueOutputIndex);
+ bindKeyExpression(mapping, field.getRedisKey());
+ configureHashMapping(mapping, field, rowNum);
+ configureListMapping(mapping, field);
+ return mapping;
+ }
+
+ private void bindKeyExpression(Mapping mapping, String keyExpr) {
+ int keyIndex = getInputRowMeta().indexOfValue(keyExpr);
+ if (keyIndex >= 0) {
+ mapping.setKeyFieldIndex(keyIndex);
+ } else {
+ mapping.setKeyLiteral(keyExpr);
+ }
+ }
+
+ private void configureHashMapping(Mapping mapping, RedisInputField field, int rowNum)
+ throws HopException {
+ if (mapping.getStructure() != RedisDataStructure.HASH) {
+ return;
+ }
+ if (StringUtils.isEmpty(field.getHashField())) {
+ throw new HopException("Hash field is required for HASH mapping row " + rowNum);
+ }
+ mapping.setHashFieldCodec(RedisCodecs.create(field.getHashFieldCodec()));
+ String hashExpr = field.getHashField();
+ int hashIndex = getInputRowMeta().indexOfValue(hashExpr);
+ if (hashIndex >= 0) {
+ mapping.setHashFieldIndex(hashIndex);
+ } else {
+ mapping.setHashFieldLiteral(hashExpr);
+ }
+ }
+
+ private void configureListMapping(Mapping mapping, RedisInputField field) {
+ if (mapping.getStructure() != RedisDataStructure.LIST) {
+ return;
+ }
+ mapping.setListStart(Const.toLong(resolve(field.resolveListStart()), 0L));
+ mapping.setListStop(Const.toLong(resolve(field.resolveListStop()), -1L));
+ }
+
+ private Object readValue(IRedisCommands commands, Mapping mapping, Object[] row, byte[] keyBytes)
+ throws Exception {
+ switch (mapping.getStructure()) {
+ case STRING:
+ {
+ byte[] value = commands.getValue(keyBytes);
+ return value == null ? null : mapping.getValueCodec().decode(value);
+ }
+ case HASH:
+ {
+ Object hashObj =
+ mapping.getHashFieldIndex() >= 0
+ ? row[mapping.getHashFieldIndex()]
+ : resolve(mapping.getHashFieldLiteral());
+ byte[] fieldBytes = mapping.getHashFieldCodec().encode(hashObj);
+ byte[] value = commands.hashGet(keyBytes, fieldBytes);
+ return value == null ? null : mapping.getValueCodec().decode(value);
+ }
+ case SET:
+ {
+ Set<byte[]> members = commands.setMembers(keyBytes);
+ return toJsonArray(members == null ? List.of() : new ArrayList<>(members), mapping);
+ }
+ case LIST:
+ {
+ List<byte[]> elements =
+ commands.listRange(keyBytes, mapping.getListStart(), mapping.getListStop());
+ return toJsonArray(elements == null ? List.of() : elements, mapping);
+ }
+ default:
+ throw new HopException("Unsupported Redis data structure: " + mapping.getStructure());
+ }
+ }
+
+ /**
+ * Decode each Redis value with the mapping codec, then serialize as a JSON array string for Hop
+ * row compatibility. {@code byte[]} from the BYTE codec is converted to UTF-8 text when valid,
+ * otherwise to a Base64 string (Jackson would otherwise emit opaque Base64 for raw byte arrays).
+ */
+ private Object toJsonArray(List<byte[]> rawValues, Mapping mapping) throws Exception {
+ List<Object> decoded = new ArrayList<>(rawValues.size());
+ for (byte[] raw : rawValues) {
+ if (raw == null) {
+ decoded.add(null);
+ continue;
+ }
+ decoded.add(toJsonArrayElement(mapping.getValueCodec().decode(raw)));
+ }
+ return OBJECT_MAPPER.writeValueAsString(decoded);
+ }
+
+ static Object toJsonArrayElement(Object decoded) {
+ if (!(decoded instanceof byte[] bytes)) {
+ return decoded;
+ }
+ if (isUtf8Text(bytes)) {
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+ return Base64.getEncoder().encodeToString(bytes);
+ }
+
+ private static boolean isUtf8Text(byte[] bytes) {
+ CharsetDecoder decoder =
+ StandardCharsets.UTF_8
+ .newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT);
+ try {
+ decoder.decode(java.nio.ByteBuffer.wrap(bytes));
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ @Override
+ public void dispose() {
+ if (data.getSession() != null) {
+ data.getSession().close();
+ data.setSession(null);
+ }
+ super.dispose();
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputData.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputData.java
new file mode 100644
index 0000000000..42ed312913
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputData.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.pipeline.transform.BaseTransformData;
+import org.apache.hop.pipeline.transform.ITransformData;
+import org.apache.hop.redis.client.RedisClientSession;
+import org.apache.hop.redis.codec.RedisValueCodec;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+
+@Getter
+@Setter
+public class RedisInputData extends BaseTransformData implements ITransformData {
+ private IRowMeta outputRowMeta;
+ private RedisClientSession session;
+ private Mapping[] mappings;
+
+ /** Index in the output row where the first mapping value is written. */
+ private int firstValueIndex;
+
+ @Getter
+ @Setter
+ public static final class Mapping {
+ private RedisDataStructure structure;
+
+ /** Index of Redis-key stream field, or -1 when {@link #keyLiteral} is used. */
+ private int keyFieldIndex = -1;
+
+ private String keyLiteral;
+ private RedisValueCodec keyCodec;
+
+ /** Index of hash-field stream field, or -1 when literal / N/A. */
+ private int hashFieldIndex = -1;
+
+ private String hashFieldLiteral;
+ private RedisValueCodec hashFieldCodec;
+ private RedisValueCodec valueCodec;
+ private int valueOutputIndex;
+ private long listStart;
+ private long listStop;
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputDialog.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputDialog.java
new file mode 100644
index 0000000000..d994eacaa7
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputDialog.java
@@ -0,0 +1,316 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.metadata.RedisConnection;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.MetaSelectionLine;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.ScrolledComposite;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.TableItem;
+
+/**
+ * Redis Input dialog: connection plus a per-row mapping table (STRING / HGET / SMEMBERS / LRANGE).
+ */
+public class RedisInputDialog extends BaseTransformDialog {
+
+ private static final Class<?> PKG = RedisInputMeta.class;
+
+ private final RedisInputMeta input;
+
+ private MetaSelectionLine<RedisConnection> wConnection;
+ private TableView wFields;
+
+ public RedisInputDialog(
+ Shell parent, IVariables variables, RedisInputMeta transformMeta, PipelineMeta pipelineMeta) {
+ super(parent, variables, transformMeta, pipelineMeta);
+ this.input = transformMeta;
+ }
+
+ @Override
+ public String open() {
+ createShell(BaseMessages.getString(PKG, "RedisInput.Name"));
+ buildButtonBar().ok(e -> ok()).get(e -> getFields()).cancel(e -> cancel()).build();
+
+ ModifyListener lsMod = e -> input.setChanged();
+ changed = input.hasChanged();
+
+ ScrolledComposite wScrolledComposite =
+ new ScrolledComposite(shell, SWT.V_SCROLL | SWT.H_SCROLL);
+ FormLayout scFormLayout = new FormLayout();
+ wScrolledComposite.setLayout(scFormLayout);
+ FormData fdSComposite = new FormData();
+ fdSComposite.left = new FormAttachment(0, 0);
+ fdSComposite.right = new FormAttachment(100, 0);
+ fdSComposite.top = new FormAttachment(wSpacer, 0);
+ fdSComposite.bottom = new FormAttachment(wOk, -margin);
+ wScrolledComposite.setLayoutData(fdSComposite);
+
+ Composite wComposite = new Composite(wScrolledComposite, SWT.NONE);
+ PropsUi.setLook(wComposite);
+ FormData fdComposite = new FormData();
+ fdComposite.left = new FormAttachment(0, 0);
+ fdComposite.right = new FormAttachment(100, 0);
+ fdComposite.top = new FormAttachment(0, 0);
+ fdComposite.bottom = new FormAttachment(100, 0);
+ wComposite.setLayoutData(fdComposite);
+ FormLayout formLayout = new FormLayout();
+ formLayout.marginWidth = PropsUi.getFormMargin();
+ formLayout.marginHeight = PropsUi.getFormMargin();
+ wComposite.setLayout(formLayout);
+
+ String[] fieldNames = new String[0];
+ try {
+ fieldNames = pipelineMeta.getPrevTransformFields(variables, transformMeta).getFieldNames();
+ } catch (HopException e) {
+ log.logError("Error getting input fields", e);
+ }
+
+ wConnection =
+ new MetaSelectionLine<>(
+ variables,
+ metadataProvider,
+ RedisConnection.class,
+ wComposite,
+ SWT.NONE,
+ BaseMessages.getString(PKG, "RedisInputDialog.Connection.Label"),
+ BaseMessages.getString(PKG, "RedisInputDialog.Connection.Tooltip"));
+ PropsUi.setLook(wConnection);
+ FormData fdConnection = new FormData();
+ fdConnection.left = new FormAttachment(0, 0);
+ fdConnection.right = new FormAttachment(100, 0);
+ fdConnection.top = new FormAttachment(0, margin);
+ wConnection.setLayoutData(fdConnection);
+ try {
+ wConnection.fillItems();
+ } catch (Exception e) {
+ new ErrorDialog(shell, "Error", "Error loading Redis connections", e);
+ }
+ wConnection.addModifyListener(lsMod);
+ Control last = wConnection;
+
+ Label wlFields = new Label(wComposite, SWT.LEFT);
+ wlFields.setText(BaseMessages.getString(PKG, "RedisInputDialog.Fields.Label"));
+ PropsUi.setLook(wlFields);
+ FormData fdlFields = new FormData();
+ fdlFields.left = new FormAttachment(0, 0);
+ fdlFields.top = new FormAttachment(last, margin);
+ wlFields.setLayoutData(fdlFields);
+
+ ColumnInfo[] columns =
+ new ColumnInfo[] {
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisInputDialog.Column.RedisKey"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ fieldNames,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisInputDialog.Column.RedisKeyCodec"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ RedisCodecType.getNames(),
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisInputDialog.Column.DataStructure"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ RedisDataStructure.getNames(),
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisInputDialog.Column.HashField"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ fieldNames,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisInputDialog.Column.HashFieldCodec"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ RedisCodecType.getNames(),
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisInputDialog.Column.ValueField"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisInputDialog.Column.ValueCodec"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ RedisCodecType.getNames(),
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisInputDialog.Column.ListStart"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisInputDialog.Column.ListStop"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false)
+ };
+ columns[5].setUsingVariables(true);
+ columns[7].setUsingVariables(true);
+ columns[8].setUsingVariables(true);
+
+ int fieldRows =
+ input.getFields() == null || input.getFields().isEmpty() ? 5 : input.getFields().size();
+ wFields =
+ new TableView(
+ variables,
+ wComposite,
+ SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+ columns,
+ fieldRows,
+ lsMod,
+ props);
+ FormData fdFields = new FormData();
+ fdFields.left = new FormAttachment(0, 0);
+ fdFields.right = new FormAttachment(100, 0);
+ fdFields.top = new FormAttachment(wlFields, margin);
+ fdFields.bottom = new FormAttachment(100, 0);
+ wFields.setLayoutData(fdFields);
+
+ wComposite.pack();
+ Rectangle bounds = wComposite.getBounds();
+ wScrolledComposite.setContent(wComposite);
+ wScrolledComposite.setExpandHorizontal(true);
+ wScrolledComposite.setExpandVertical(true);
+ wScrolledComposite.setMinWidth(bounds.width);
+ wScrolledComposite.setMinHeight(bounds.height);
+
+ getData();
+ focusTransformName();
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+ return transformName;
+ }
+
+ private void getFields() {
+ try {
+ IRowMeta rowMeta = pipelineMeta.getPrevTransformFields(variables, transformName);
+ BaseTransformDialog.getFieldsFromPrevious(
+ rowMeta,
+ wFields,
+ 1,
+ new int[] {1},
+ new int[] {},
+ -1,
+ -1,
+ (item, valueMeta) -> {
+ String name = Const.NVL(valueMeta.getName(), "");
+ item.setText(2, RedisCodecType.STRING.name());
+ item.setText(3, RedisDataStructure.STRING.name());
+ item.setText(4, "");
+ item.setText(5, "");
+ // Avoid clashing with the upstream field used as Redis key
+ item.setText(6, StringUtils.isEmpty(name) ? "" : name + "Value");
+ item.setText(7, RedisCodecType.STRING.name());
+ item.setText(8, "0");
+ item.setText(9, "-1");
+ return true;
+ });
+ input.setChanged();
+ } catch (Exception e) {
+ new ErrorDialog(shell, "Error", "Error getting fields", e);
+ }
+ }
+
+ private void getData() {
+ wConnection.setText(Const.NVL(input.getConnectionName(), ""));
+ wFields.clearAll(false);
+ if (input.getFields() != null) {
+ for (RedisInputField field : input.getFields()) {
+ TableItem item = new TableItem(wFields.table, SWT.NONE);
+ item.setText(1, Const.NVL(field.getRedisKey(), ""));
+ item.setText(2, codecName(field.getRedisKeyCodec()));
+ item.setText(3, field.resolveDataStructure().name());
+ item.setText(4, Const.NVL(field.getHashField(), ""));
+ item.setText(5, optionalCodecName(field.getHashFieldCodec()));
+ item.setText(6, Const.NVL(field.getValueField(), ""));
+ item.setText(7, codecName(field.getValueCodec()));
+ item.setText(8, Const.NVL(field.getListStart(), "0"));
+ item.setText(9, Const.NVL(field.getListStop(), "-1"));
+ }
+ }
+ wFields.removeEmptyRows();
+ wFields.setRowNums();
+ wFields.optWidth(true);
+ }
+
+ private static String codecName(RedisCodecType type) {
+ return type == null ? RedisCodecType.STRING.name() : type.name();
+ }
+
+ private static String optionalCodecName(RedisCodecType type) {
+ return type == null ? "" : type.name();
+ }
+
+ private void cancel() {
+ transformName = null;
+ input.setChanged(changed);
+ dispose();
+ }
+
+ private void ok() {
+ if (StringUtils.isEmpty(wTransformName.getText())) {
+ return;
+ }
+ transformName = wTransformName.getText();
+ input.setConnectionName(wConnection.getText());
+
+ List<RedisInputField> fields = new ArrayList<>();
+ for (TableItem item : wFields.getNonEmptyItems()) {
+ RedisInputField field = new RedisInputField();
+ field.setRedisKey(item.getText(1));
+ field.setRedisKeyCodec(RedisCodecType.fromCode(item.getText(2)));
+ field.setDataStructure(RedisDataStructure.fromCode(item.getText(3)));
+ field.setHashField(item.getText(4));
+ String hashCodec = item.getText(5);
+ field.setHashFieldCodec(
+ StringUtils.isEmpty(hashCodec) ? null : RedisCodecType.fromCode(hashCodec));
+ field.setValueField(item.getText(6));
+ field.setValueCodec(RedisCodecType.fromCode(item.getText(7)));
+ String listStart = item.getText(8);
+ field.setListStart(StringUtils.isEmpty(listStart) ? "0" : listStart);
+ String listStop = item.getText(9);
+ field.setListStop(StringUtils.isEmpty(listStop) ? "-1" : listStop);
+ fields.add(field);
+ }
+ input.setFields(fields);
+ input.setChanged();
+ dispose();
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputField.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputField.java
new file mode 100644
index 0000000000..9aa8f84d8f
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputField.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+
+/**
+ * One mapping row for Redis Input.
+ *
+ * <p>Supports STRING ({@code GET}), HASH ({@code HGET} only), SET ({@code SMEMBERS} as JSON array),
+ * and LIST ({@code LRANGE} as JSON array). HGETALL and SISMEMBER are not supported.
+ */
+@Getter
+@Setter
+public class RedisInputField implements Cloneable {
+
+ /** Redis key: stream field name or literal / variable text. */
+ @HopMetadataProperty(key = "redis_key")
+ private String redisKey;
+
+ @HopMetadataProperty(key = "redis_key_codec")
+ private RedisCodecType redisKeyCodec = RedisCodecType.STRING;
+
+ @HopMetadataProperty(key = "data_structure")
+ private RedisDataStructure dataStructure = RedisDataStructure.STRING;
+
+ /** Hash field: stream field name or literal. Required for HASH. */
+ @HopMetadataProperty(key = "hash_field")
+ private String hashField;
+
+ @HopMetadataProperty(key = "hash_field_codec")
+ private RedisCodecType hashFieldCodec;
+
+ /** Output field name for the decoded value (or JSON array for SET/LIST). */
+ @HopMetadataProperty(key = "value_field")
+ private String valueField;
+
+ @HopMetadataProperty(key = "value_codec")
+ private RedisCodecType valueCodec = RedisCodecType.STRING;
+
+ @HopMetadataProperty(key = "list_start")
+ private String listStart = "0";
+
+ @HopMetadataProperty(key = "list_stop")
+ private String listStop = "-1";
+
+ public RedisInputField() {}
+
+ public RedisInputField(RedisInputField other) {
+ this.redisKey = other.redisKey;
+ this.redisKeyCodec = other.redisKeyCodec;
+ this.dataStructure = other.dataStructure;
+ this.hashField = other.hashField;
+ this.hashFieldCodec = other.hashFieldCodec;
+ this.valueField = other.valueField;
+ this.valueCodec = other.valueCodec;
+ this.listStart = other.listStart;
+ this.listStop = other.listStop;
+ }
+
+ @Override
+ public RedisInputField clone() {
+ return new RedisInputField(this);
+ }
+
+ public RedisDataStructure resolveDataStructure() {
+ return dataStructure == null ? RedisDataStructure.STRING : dataStructure;
+ }
+
+ public String resolveListStart() {
+ return StringUtils.isEmpty(listStart) ? "0" : listStart;
+ }
+
+ public String resolveListStop() {
+ return StringUtils.isEmpty(listStop) ? "-1" : listStop;
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputMeta.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputMeta.java
new file mode 100644
index 0000000000..3e75bfdeeb
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisinput/RedisInputMeta.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.value.ValueMetaBinary;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.HopMetadataPropertyType;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+
+@Getter
+@Setter
+@Transform(
+ id = "RedisInput",
+ image = "redis-input.svg",
+ name = "i18n::RedisInput.Name",
+ description = "i18n::RedisInput.Description",
+ documentationUrl = "/pipeline/transforms/redis-input.html",
+ keywords = "i18n::RedisInputMeta.keyword",
+ categoryDescription = "i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.Input")
+public class RedisInputMeta extends BaseTransformMeta<RedisInput, RedisInputData> {
+
+ @HopMetadataProperty(
+ key = "connection",
+ hopMetadataPropertyType = HopMetadataPropertyType.REDIS_CONNECTION)
+ private String connectionName;
+
+ /** Per-row Redis read mappings. */
+ @HopMetadataProperty(key = "field", groupKey = "fields")
+ private List<RedisInputField> fields = new ArrayList<>();
+
+ @Override
+ public void setDefault() {
+ connectionName = null;
+ fields = new ArrayList<>();
+ }
+
+ @Override
+ public void getFields(
+ IRowMeta inputRowMeta,
+ String name,
+ IRowMeta[] info,
+ TransformMeta nextTransform,
+ IVariables variables,
+ IHopMetadataProvider metadataProvider)
+ throws HopTransformException {
+
+ if (fields == null) {
+ return;
+ }
+ for (RedisInputField field : fields) {
+ if (field == null || StringUtils.isEmpty(field.getValueField())) {
+ continue;
+ }
+ String fieldName = variables.resolve(field.getValueField());
+ RedisDataStructure structure = field.resolveDataStructure();
+ // SET/LIST are always a JSON array string; BYTE codec yields binary for STRING/HASH.
+ if (structure != RedisDataStructure.SET
+ && structure != RedisDataStructure.LIST
+ && field.getValueCodec() == RedisCodecType.BYTE) {
+ ValueMetaBinary meta = new ValueMetaBinary(fieldName);
+ meta.setOrigin(name);
+ inputRowMeta.addValueMeta(meta);
+ } else {
+ ValueMetaString meta = new ValueMetaString(fieldName);
+ meta.setOrigin(name);
+ inputRowMeta.addValueMeta(meta);
+ }
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutput.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutput.java
new file mode 100644
index 0000000000..bac334288e
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutput.java
@@ -0,0 +1,379 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.metadata.api.IHopMetadataSerializer;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.redis.client.IRedisCommands;
+import org.apache.hop.redis.client.RedisClientFactory;
+import org.apache.hop.redis.codec.RedisCodecs;
+import org.apache.hop.redis.metadata.RedisConnection;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.redis.transforms.RedisListPushDirection;
+import org.apache.hop.redis.transforms.redisoutput.RedisOutputData.StreamMapping;
+import org.jspecify.annotations.NonNull;
+
+public class RedisOutput extends BaseTransform<RedisOutputMeta, RedisOutputData> {
+
+ public RedisOutput(
+ TransformMeta transformMeta,
+ RedisOutputMeta meta,
+ RedisOutputData data,
+ int copyNr,
+ PipelineMeta pipelineMeta,
+ Pipeline pipeline) {
+ super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
+ }
+
+ @Override
+ public boolean processRow() throws HopException {
+ Object[] row = getRow();
+ if (row == null) {
+ setOutputDone();
+ return false;
+ }
+
+ try {
+ if (first) {
+ first = false;
+ initFirst();
+ }
+
+ RedisOutputWriteMode writeMode =
+ meta.getWriteMode() == null ? RedisOutputWriteMode.KEY_VALUE : meta.getWriteMode();
+ if (writeMode == RedisOutputWriteMode.STREAM_FIELDS) {
+ writeStreamFields(row);
+ } else {
+ writeKeyValue(row);
+ }
+
+ putRow(data.getOutputRowMeta(), row);
+ return true;
+ } catch (Exception e) {
+ throw new HopException("Error writing to Redis", e);
+ }
+ }
+
+ private void initFirst() throws HopException {
+ data.setOutputRowMeta(getInputRowMeta().clone());
+
+ if (StringUtils.isEmpty(meta.getConnectionName())) {
+ throw new HopException("A Redis connection name is required");
+ }
+ IHopMetadataSerializer<RedisConnection> serializer =
+ metadataProvider.getSerializer(RedisConnection.class);
+ RedisConnection connection = serializer.load(meta.getConnectionName());
+ if (connection == null) {
+ throw new HopException("Redis connection '" + meta.getConnectionName() + "' not found");
+ }
+ data.setSession(RedisClientFactory.create(connection, this));
+ data.setCodecs(
+ RedisCodecs.of(
+ meta.getKeyCodec(),
+ meta.getValueCodec(),
+ meta.getHashKeyCodec(),
+ meta.getHashValueCodec()));
+
+ RedisOutputWriteMode writeMode =
+ meta.getWriteMode() == null ? RedisOutputWriteMode.KEY_VALUE : meta.getWriteMode();
+ if (writeMode == RedisOutputWriteMode.STREAM_FIELDS) {
+ initStreamFields();
+ } else {
+ RedisDataStructure structure =
+ meta.getDataStructure() == null ? RedisDataStructure.STRING : meta.getDataStructure();
+ initKeyValue(structure);
+ // Component-level TTL only applies to KEY_VALUE mode
+ String ttl = resolve(meta.getTtlSeconds());
+ if (StringUtils.isNotEmpty(ttl)) {
+ long ttlSeconds = Const.toLong(ttl, 0L);
+ if (ttlSeconds > 0) {
+ data.setTtlSeconds(ttlSeconds);
+ }
+ }
+ }
+ }
+
+ private void initKeyValue(RedisDataStructure structure) throws HopException {
+ data.setKeyFieldIndex(getInputRowMeta().indexOfValue(meta.getKeyField()));
+ if (data.getKeyFieldIndex() < 0) {
+ throw new HopException("Unable to find key field '" + meta.getKeyField() + "'");
+ }
+
+ switch (structure) {
+ case STRING:
+ case SET:
+ case LIST:
+ data.setValueFieldIndex(getInputRowMeta().indexOfValue(meta.getValueField()));
+ if (data.getValueFieldIndex() < 0) {
+ throw new HopException("Unable to find value field '" + meta.getValueField() + "'");
+ }
+ break;
+ case HASH:
+ data.setHashKeyFieldIndex(getInputRowMeta().indexOfValue(meta.getHashKeyField()));
+ data.setHashValueFieldIndex(getInputRowMeta().indexOfValue(meta.getHashValueField()));
+ if (data.getHashKeyFieldIndex() < 0) {
+ throw new HopException("Unable to find hash key field '" + meta.getHashKeyField() + "'");
+ }
+ if (data.getHashValueFieldIndex() < 0) {
+ throw new HopException(
+ "Unable to find hash value field '" + meta.getHashValueField() + "'");
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ private void initStreamFields() throws HopException {
+ List<RedisOutputField> fields = meta.getFields();
+ if (fields == null || fields.isEmpty()) {
+ throw new HopException("At least one stream field is required in STREAM_FIELDS mode");
+ }
+
+ StreamMapping[] streamMappings = new StreamMapping[fields.size()];
+ for (int i = 0; i < fields.size(); i++) {
+ streamMappings[i] = buildStreamMapping(fields.get(i), i + 1);
+ }
+ data.setStreamMappings(streamMappings);
+ }
+
+ private StreamMapping buildStreamMapping(RedisOutputField field, int rowNum) throws HopException {
+ int streamIndex = requireStreamFieldIndex(field, rowNum);
+ String keyExpr = field.resolveKey();
+ if (StringUtils.isEmpty(keyExpr)) {
+ throw new HopException("Key is required for mapping row " + rowNum);
+ }
+
+ StreamMapping mapping = new StreamMapping();
+ mapping.setStructure(field.resolveDataStructure());
+ mapping.setStreamFieldIndex(streamIndex);
+ mapping.setKeyCodec(RedisCodecs.create(field.getKeyCodec()));
+ mapping.setValueCodec(RedisCodecs.create(field.getValueCodec()));
+ bindKeyExpression(mapping, keyExpr);
+ configureHashMapping(mapping, field, rowNum);
+ mapping.setTtlSeconds(resolveTtlSeconds(field.getTtlSeconds()));
+ return mapping;
+ }
+
+ private int requireStreamFieldIndex(RedisOutputField field, int rowNum) throws HopException {
+ if (StringUtils.isEmpty(field.getStreamField())) {
+ throw new HopException("Stream field is required for mapping row " + rowNum);
+ }
+ int streamIndex = getInputRowMeta().indexOfValue(field.getStreamField());
+ if (streamIndex < 0) {
+ throw new HopException("Unable to find stream field '" + field.getStreamField() + "'");
+ }
+ return streamIndex;
+ }
+
+ private void bindKeyExpression(StreamMapping mapping, String keyExpr) {
+ int keyIndex = getInputRowMeta().indexOfValue(keyExpr);
+ if (keyIndex >= 0) {
+ mapping.setKeyFieldIndex(keyIndex);
+ } else {
+ mapping.setKeyLiteral(keyExpr);
+ }
+ }
+
+ private void configureHashMapping(StreamMapping mapping, RedisOutputField field, int rowNum)
+ throws HopException {
+ if (mapping.getStructure() != RedisDataStructure.HASH) {
+ return;
+ }
+ String hashKeyExpr = field.resolveHashKey();
+ if (StringUtils.isEmpty(hashKeyExpr)) {
+ throw new HopException("Hash key is required for HASH mapping row " + rowNum);
+ }
+ mapping.setHashKeyCodec(RedisCodecs.create(field.getHashKeyCodec()));
+ int hashKeyIndex = getInputRowMeta().indexOfValue(hashKeyExpr);
+ if (hashKeyIndex >= 0) {
+ mapping.setHashKeyFieldIndex(hashKeyIndex);
+ } else {
+ mapping.setHashKeyLiteral(hashKeyExpr);
+ }
+ }
+
+ private Long resolveTtlSeconds(String ttlExpression) {
+ if (StringUtils.isEmpty(ttlExpression)) {
+ return null;
+ }
+ long ttlSeconds = Const.toLong(resolve(ttlExpression), 0L);
+ return ttlSeconds > 0 ? ttlSeconds : null;
+ }
+
+ private void writeKeyValue(Object[] row) throws Exception {
+ IRedisCommands commands = data.getSession().getCommands();
+ byte[] keyBytes = data.getCodecs().key().encode(row[data.getKeyFieldIndex()]);
+ RedisDataStructure structure =
+ meta.getDataStructure() == null ? RedisDataStructure.STRING : meta.getDataStructure();
+
+ switch (structure) {
+ case STRING:
+ {
+ byte[] valueBytes = data.getCodecs().value().encode(row[data.getValueFieldIndex()]);
+ commands.setValue(keyBytes, valueBytes);
+ break;
+ }
+ case HASH:
+ {
+ byte[] fieldBytes = data.getCodecs().hashKey().encode(row[data.getHashKeyFieldIndex()]);
+ byte[] valueBytes =
+ data.getCodecs().hashValue().encode(row[data.getHashValueFieldIndex()]);
+ commands.hashSet(keyBytes, fieldBytes, valueBytes);
+ break;
+ }
+ case SET:
+ {
+ byte[] memberBytes = data.getCodecs().value().encode(row[data.getValueFieldIndex()]);
+ commands.setAdd(keyBytes, memberBytes);
+ break;
+ }
+ case LIST:
+ {
+ byte[] elementBytes = data.getCodecs().value().encode(row[data.getValueFieldIndex()]);
+ pushList(commands, keyBytes, elementBytes);
+ break;
+ }
+ default:
+ break;
+ }
+
+ expireIfNeeded(commands, keyBytes);
+ }
+
+ private void writeStreamFields(Object[] row) throws Exception {
+ IRedisCommands commands = data.getSession().getCommands();
+ // Batch HASH fields that share the same Redis key within this pipeline row
+ Map<KeyRef, Map<byte[], byte[]>> hashBatches = new HashMap<>();
+ Map<KeyRef, Long> hashTtls = new HashMap<>();
+
+ for (StreamMapping mapping : data.getStreamMappings()) {
+ Object keyObj =
+ mapping.getKeyFieldIndex() >= 0
+ ? row[mapping.getKeyFieldIndex()]
+ : resolve(mapping.getKeyLiteral());
+ byte[] keyBytes = mapping.getKeyCodec().encode(keyObj);
+ byte[] valueBytes = mapping.getValueCodec().encode(row[mapping.getStreamFieldIndex()]);
+
+ switch (mapping.getStructure()) {
+ case STRING:
+ commands.setValue(keyBytes, valueBytes);
+ expireIfNeeded(commands, keyBytes, mapping.getTtlSeconds());
+ break;
+ case HASH:
+ {
+ Object hashKeyObj =
+ mapping.getHashKeyFieldIndex() >= 0
+ ? row[mapping.getHashKeyFieldIndex()]
+ : resolve(mapping.getHashKeyLiteral());
+ byte[] hashKeyBytes = mapping.getHashKeyCodec().encode(hashKeyObj);
+ KeyRef keyRef = new KeyRef(keyBytes);
+ hashBatches.computeIfAbsent(keyRef, k -> new HashMap<>()).put(hashKeyBytes, valueBytes);
+ if (mapping.getTtlSeconds() != null) {
+ hashTtls.merge(keyRef, mapping.getTtlSeconds(), Math::max);
+ }
+ break;
+ }
+ case SET:
+ commands.setAdd(keyBytes, valueBytes);
+ expireIfNeeded(commands, keyBytes, mapping.getTtlSeconds());
+ break;
+ case LIST:
+ // List push direction is not configured in STREAM_FIELDS yet; default RPUSH
+ commands.listRightPush(keyBytes, valueBytes);
+ expireIfNeeded(commands, keyBytes, mapping.getTtlSeconds());
+ break;
+ default:
+ break;
+ }
+ }
+
+ for (Map.Entry<KeyRef, Map<byte[], byte[]>> entry : hashBatches.entrySet()) {
+ KeyRef keyRef = entry.getKey();
+ byte[] keyBytes = keyRef.bytes();
+ commands.hashSet(keyBytes, entry.getValue());
+ expireIfNeeded(commands, keyBytes, hashTtls.get(keyRef));
+ }
+ }
+
+ private void pushList(IRedisCommands commands, byte[] keyBytes, byte[] elementBytes) {
+ RedisListPushDirection direction =
+ meta.getListPushDirection() == null
+ ? RedisListPushDirection.RPUSH
+ : meta.getListPushDirection();
+ if (direction == RedisListPushDirection.LPUSH) {
+ commands.listLeftPush(keyBytes, elementBytes);
+ } else {
+ commands.listRightPush(keyBytes, elementBytes);
+ }
+ }
+
+ private void expireIfNeeded(IRedisCommands commands, byte[] keyBytes) {
+ expireIfNeeded(commands, keyBytes, data.getTtlSeconds());
+ }
+
+ private void expireIfNeeded(IRedisCommands commands, byte[] keyBytes, Long ttlSeconds) {
+ if (ttlSeconds != null && ttlSeconds > 0) {
+ commands.expire(keyBytes, ttlSeconds);
+ }
+ }
+
+ @Override
+ public void dispose() {
+ if (data.getSession() != null) {
+ data.getSession().close();
+ data.setSession(null);
+ }
+ super.dispose();
+ }
+
+ /** Wrapper so HASH batches can group by key bytes (content equality, not reference). */
+ private record KeyRef(byte[] bytes) {
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof KeyRef)) {
+ return false;
+ }
+ KeyRef other = (KeyRef) o;
+ return Arrays.equals(bytes, other.bytes);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(bytes);
+ }
+
+ @Override
+ public @NonNull String toString() {
+ return "KeyRef" + Arrays.toString(bytes);
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputData.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputData.java
new file mode 100644
index 0000000000..fb9782b7df
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputData.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.pipeline.transform.BaseTransformData;
+import org.apache.hop.pipeline.transform.ITransformData;
+import org.apache.hop.redis.client.RedisClientSession;
+import org.apache.hop.redis.codec.RedisCodecs;
+import org.apache.hop.redis.codec.RedisValueCodec;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+
+@Getter
+@Setter
+public class RedisOutputData extends BaseTransformData implements ITransformData {
+ private IRowMeta outputRowMeta;
+ private RedisClientSession session;
+ private RedisCodecs codecs;
+ private int keyFieldIndex = -1;
+ private int valueFieldIndex = -1;
+ private int hashKeyFieldIndex = -1;
+ private int hashValueFieldIndex = -1;
+ private Long ttlSeconds;
+
+ /** Prepared STREAM_FIELDS mappings. */
+ private StreamMapping[] streamMappings;
+
+ @Getter
+ @Setter
+ public static final class StreamMapping {
+ private RedisDataStructure structure;
+ private int streamFieldIndex;
+
+ /** Index of key stream field, or -1 when {@link #keyLiteral} is used. */
+ private int keyFieldIndex = -1;
+
+ private String keyLiteral;
+ private RedisValueCodec keyCodec;
+
+ /** Index of hash-key stream field, or -1 when literal / N/A. */
+ private int hashKeyFieldIndex = -1;
+
+ private String hashKeyLiteral;
+ private RedisValueCodec hashKeyCodec;
+ private RedisValueCodec valueCodec;
+
+ /** Per-row TTL; null means no expire. */
+ private Long ttlSeconds;
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputDialog.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputDialog.java
new file mode 100644
index 0000000000..5aa2526f68
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputDialog.java
@@ -0,0 +1,699 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.metadata.RedisConnection;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.redis.transforms.RedisListPushDirection;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.MetaSelectionLine;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CCombo;
+import org.eclipse.swt.custom.ScrolledComposite;
+import org.eclipse.swt.custom.StackLayout;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.TableItem;
+
+/**
+ * Redis Output dialog.
+ *
+ * <p>Write mode switches a {@link StackLayout}. KEY_VALUE shows a structure-driven form.
+ * STREAM_FIELDS shows a per-row mapping table (including per-row TTL).
+ */
+public class RedisOutputDialog extends BaseTransformDialog {
+
+ private static final Class<?> PKG = RedisOutputMeta.class;
+
+ private final RedisOutputMeta input;
+
+ private MetaSelectionLine<RedisConnection> wConnection;
+ private CCombo wWriteMode;
+
+ // KEY_VALUE page
+ private Composite wKeyValuePage;
+ private CCombo wDataStructure;
+ private CCombo wKeyField;
+ private CCombo wKeyCodec;
+ private CCombo wHashKeyField;
+ private CCombo wHashKeyCodec;
+ private CCombo wListPushDirection;
+ private CCombo wValueField;
+ private CCombo wValueCodec;
+ private TextVar wTtlSeconds;
+
+ private Label wlListPushDirection;
+
+ private Composite wKeyRow;
+ private Composite wHashKeyRow;
+ private Composite wValueRow;
+
+ // STREAM_FIELDS page
+ private Composite wStreamPage;
+
+ private TableView wFields;
+
+ private Composite wModeStack;
+ private StackLayout modeStackLayout;
+
+ public RedisOutputDialog(
+ Shell parent,
+ IVariables variables,
+ RedisOutputMeta transformMeta,
+ PipelineMeta pipelineMeta) {
+ super(parent, variables, transformMeta, pipelineMeta);
+ this.input = transformMeta;
+ }
+
+ @Override
+ public String open() {
+ createShell(BaseMessages.getString(PKG, "RedisOutput.Name"));
+ buildButtonBar().ok(e -> ok()).get(e -> getFields()).cancel(e -> cancel()).build();
+
+ ModifyListener lsMod = e -> input.setChanged();
+ changed = input.hasChanged();
+
+ ScrolledComposite wScrolledComposite =
+ new ScrolledComposite(shell, SWT.V_SCROLL | SWT.H_SCROLL);
+ FormLayout scFormLayout = new FormLayout();
+ wScrolledComposite.setLayout(scFormLayout);
+ FormData fdSComposite = new FormData();
+ fdSComposite.left = new FormAttachment(0, 0);
+ fdSComposite.right = new FormAttachment(100, 0);
+ fdSComposite.top = new FormAttachment(wSpacer, 0);
+ fdSComposite.bottom = new FormAttachment(wOk, -margin);
+ wScrolledComposite.setLayoutData(fdSComposite);
+
+ Composite wComposite = new Composite(wScrolledComposite, SWT.NONE);
+ PropsUi.setLook(wComposite);
+ FormData fdComposite = new FormData();
+ fdComposite.left = new FormAttachment(0, 0);
+ fdComposite.right = new FormAttachment(100, 0);
+ fdComposite.top = new FormAttachment(0, 0);
+ fdComposite.bottom = new FormAttachment(100, 0);
+ wComposite.setLayoutData(fdComposite);
+ FormLayout formLayout = new FormLayout();
+ formLayout.marginWidth = PropsUi.getFormMargin();
+ formLayout.marginHeight = PropsUi.getFormMargin();
+ wComposite.setLayout(formLayout);
+
+ String[] fieldNames = new String[0];
+ try {
+ fieldNames = pipelineMeta.getPrevTransformFields(variables, transformMeta).getFieldNames();
+ } catch (HopException e) {
+ log.logError("Error getting input fields", e);
+ }
+
+ wConnection =
+ new MetaSelectionLine<>(
+ variables,
+ metadataProvider,
+ RedisConnection.class,
+ wComposite,
+ SWT.NONE,
+ BaseMessages.getString(PKG, "RedisOutputDialog.Connection.Label"),
+ BaseMessages.getString(PKG, "RedisOutputDialog.Connection.Tooltip"));
+ PropsUi.setLook(wConnection);
+ FormData fdConnection = new FormData();
+ fdConnection.left = new FormAttachment(0, 0);
+ fdConnection.right = new FormAttachment(100, 0);
+ fdConnection.top = new FormAttachment(0, margin);
+ wConnection.setLayoutData(fdConnection);
+ try {
+ wConnection.fillItems();
+ } catch (Exception e) {
+ new ErrorDialog(shell, "Error", "Error loading Redis connections", e);
+ }
+ wConnection.addModifyListener(lsMod);
+ Control last = wConnection;
+
+ wWriteMode = new CCombo(wComposite, SWT.BORDER | SWT.READ_ONLY);
+ last =
+ addLabeledCombo(
+ wComposite,
+ last,
+ BaseMessages.getString(PKG, "RedisOutputDialog.WriteMode.Label"),
+ wWriteMode,
+ RedisOutputWriteMode.getNames(),
+ lsMod,
+ null);
+ wWriteMode.addListener(SWT.Selection, e -> updateVisibility());
+
+ modeStackLayout = new StackLayout();
+ wModeStack = new Composite(wComposite, SWT.NONE);
+ PropsUi.setLook(wModeStack);
+ wModeStack.setLayout(modeStackLayout);
+ FormData fdModeStack = new FormData();
+ fdModeStack.left = new FormAttachment(0, 0);
+ fdModeStack.right = new FormAttachment(100, 0);
+ fdModeStack.top = new FormAttachment(last, margin);
+ fdModeStack.bottom = new FormAttachment(100, -margin);
+ wModeStack.setLayoutData(fdModeStack);
+
+ buildKeyValuePage(lsMod, fieldNames);
+ buildStreamPage(lsMod, fieldNames);
+ modeStackLayout.topControl = wKeyValuePage;
+
+ wComposite.pack();
+ Rectangle bounds = wComposite.getBounds();
+ wScrolledComposite.setContent(wComposite);
+ wScrolledComposite.setExpandHorizontal(true);
+ wScrolledComposite.setExpandVertical(true);
+ wScrolledComposite.setMinWidth(bounds.width);
+ wScrolledComposite.setMinHeight(bounds.height);
+
+ getData();
+ updateVisibility();
+ focusTransformName();
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+ return transformName;
+ }
+
+ private void buildKeyValuePage(ModifyListener lsMod, String[] fieldNames) {
+ Label wlValueCodec;
+ Composite wTtlRow;
+ Label wlTtl;
+ Label wlKeyCodec;
+ Label wlHashKeyCodec;
+ Label wlHashKeyField;
+ Label wlKeyField;
+ wKeyValuePage = new Composite(wModeStack, SWT.NONE);
+ PropsUi.setLook(wKeyValuePage);
+ wKeyValuePage.setLayout(new FormLayout());
+
+ // Data structure — full width
+ wDataStructure = new CCombo(wKeyValuePage, SWT.BORDER | SWT.READ_ONLY);
+ addLabeledCombo(
+ wKeyValuePage,
+ null,
+ BaseMessages.getString(PKG, "RedisOutputDialog.DataStructure.Label"),
+ wDataStructure,
+ RedisDataStructure.getNames(),
+ lsMod,
+ new Label(wKeyValuePage, SWT.RIGHT));
+ wDataStructure.addListener(SWT.Selection, e -> updateKeyValueStructureVisibility());
+
+ // key field | codec
+ wKeyRow = createRowComposite(wKeyValuePage, wDataStructure);
+ wKeyField = new CCombo(wKeyRow, SWT.BORDER);
+ wlKeyField = new Label(wKeyRow, SWT.RIGHT);
+ wKeyCodec = new CCombo(wKeyRow, SWT.BORDER | SWT.READ_ONLY);
+ wlKeyCodec = new Label(wKeyRow, SWT.RIGHT);
+ addPairToRow(
+ new LabeledCombo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.KeyField.Label"),
+ wlKeyField,
+ wKeyField,
+ fieldNames),
+ new LabeledCombo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.KeyCodec.Label"),
+ wlKeyCodec,
+ wKeyCodec,
+ RedisCodecType.getNames()),
+ lsMod);
+
+ // hash key field | hash key codec (HASH only)
+ wHashKeyRow = createRowComposite(wKeyValuePage, wKeyRow);
+ wHashKeyField = new CCombo(wHashKeyRow, SWT.BORDER);
+ wlHashKeyField = new Label(wHashKeyRow, SWT.RIGHT);
+ wHashKeyCodec = new CCombo(wHashKeyRow, SWT.BORDER | SWT.READ_ONLY);
+ wlHashKeyCodec = new Label(wHashKeyRow, SWT.RIGHT);
+ addPairToRow(
+ new LabeledCombo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.HashKeyField.Label"),
+ wlHashKeyField,
+ wHashKeyField,
+ fieldNames),
+ new LabeledCombo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.HashKeyCodec.Label"),
+ wlHashKeyCodec,
+ wHashKeyCodec,
+ RedisCodecType.getNames()),
+ lsMod);
+
+ // value field | value codec
+ wValueRow = createRowComposite(wKeyValuePage, wHashKeyRow);
+ wValueField = new CCombo(wValueRow, SWT.BORDER);
+ Label wlValueField = new Label(wValueRow, SWT.RIGHT);
+ wValueCodec = new CCombo(wValueRow, SWT.BORDER | SWT.READ_ONLY);
+ wlValueCodec = new Label(wValueRow, SWT.RIGHT);
+ addPairToRow(
+ new LabeledCombo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.ValueField.Label"),
+ wlValueField,
+ wValueField,
+ fieldNames),
+ new LabeledCombo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.ValueCodec.Label"),
+ wlValueCodec,
+ wValueCodec,
+ RedisCodecType.getNames()),
+ lsMod);
+
+ // TTL | list push direction
+ wTtlRow = createRowComposite(wKeyValuePage, wValueRow);
+ wlTtl = new Label(wTtlRow, SWT.RIGHT);
+ wlTtl.setText(BaseMessages.getString(PKG, "RedisOutputDialog.TtlSeconds.Label"));
+ PropsUi.setLook(wlTtl);
+ FormData fdlTtl = new FormData();
+ fdlTtl.left = new FormAttachment(0, 0);
+ fdlTtl.top = new FormAttachment(0, 0);
+ fdlTtl.width = 260;
+ wlTtl.setLayoutData(fdlTtl);
+
+ wTtlSeconds = new TextVar(variables, wTtlRow, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(wTtlSeconds);
+ FormData fdTtl = new FormData();
+ fdTtl.left = new FormAttachment(wlTtl, margin);
+ fdTtl.right = new FormAttachment(50, -margin);
+ fdTtl.top = new FormAttachment(wlTtl, 0, SWT.CENTER);
+ wTtlSeconds.setLayoutData(fdTtl);
+ wTtlSeconds.addModifyListener(lsMod);
+
+ wlListPushDirection = new Label(wTtlRow, SWT.RIGHT);
+ wlListPushDirection.setText(
+ BaseMessages.getString(PKG, "RedisOutputDialog.ListPushDirection.Label"));
+ PropsUi.setLook(wlListPushDirection);
+ FormData fdlList = new FormData();
+ fdlList.left = new FormAttachment(50, margin);
+ fdlList.top = new FormAttachment(0, 0);
+ fdlList.width = 260;
+ wlListPushDirection.setLayoutData(fdlList);
+
+ wListPushDirection = new CCombo(wTtlRow, SWT.BORDER | SWT.READ_ONLY);
+ wListPushDirection.setItems(
+ new String[] {RedisListPushDirection.RPUSH.name(), RedisListPushDirection.LPUSH.name()});
+ PropsUi.setLook(wListPushDirection);
+ FormData fdList = new FormData();
+ fdList.left = new FormAttachment(wlListPushDirection, margin);
+ fdList.right = new FormAttachment(100, 0);
+ fdList.top = new FormAttachment(wlListPushDirection, 0, SWT.CENTER);
+ wListPushDirection.setLayoutData(fdList);
+ wListPushDirection.addModifyListener(lsMod);
+ }
+
+ private Composite createRowComposite(Composite parent, Control previous) {
+ Composite row = new Composite(parent, SWT.NONE);
+ PropsUi.setLook(row);
+ row.setLayout(new FormLayout());
+ FormData fdRow = new FormData();
+ fdRow.left = new FormAttachment(0, 0);
+ fdRow.right = new FormAttachment(100, 0);
+ if (previous == null) {
+ fdRow.top = new FormAttachment(0, 0);
+ } else {
+ fdRow.top = new FormAttachment(previous, margin);
+ }
+ row.setLayoutData(fdRow);
+ return row;
+ }
+
+ private void addPairToRow(LabeledCombo left, LabeledCombo right, ModifyListener lsMod) {
+ layoutLabeledCombo(left, 0, 50, lsMod);
+ layoutLabeledCombo(right, 50, 100, lsMod);
+ }
+
+ private void layoutLabeledCombo(
+ LabeledCombo labeledCombo, int leftPercent, int rightPercent, ModifyListener lsMod) {
+ labeledCombo.fieldLabel.setText(labeledCombo.labelText);
+ PropsUi.setLook(labeledCombo.fieldLabel);
+ FormData fdl = new FormData();
+ fdl.left = new FormAttachment(leftPercent, leftPercent == 0 ? 0 : margin);
+ fdl.top = new FormAttachment(0, 0);
+ fdl.width = 260;
+ labeledCombo.fieldLabel.setLayoutData(fdl);
+
+ labeledCombo.combo.setItems(labeledCombo.items);
+ PropsUi.setLook(labeledCombo.combo);
+ FormData fd = new FormData();
+ fd.left = new FormAttachment(labeledCombo.fieldLabel, margin);
+ fd.right = new FormAttachment(rightPercent, rightPercent == 100 ? 0 : -margin);
+ fd.top = new FormAttachment(labeledCombo.fieldLabel, 0, SWT.CENTER);
+ labeledCombo.combo.setLayoutData(fd);
+ labeledCombo.combo.addModifyListener(lsMod);
+ }
+
+ /**
+ * Simple holder for a label + combo pair. Kept as a plain class (not a record) so Checkstyle 9.3
+ * used by the project can parse this source file.
+ */
+ private static final class LabeledCombo {
+ private final String labelText;
+ private final Label fieldLabel;
+ private final CCombo combo;
+ private final String[] items;
+
+ private LabeledCombo(String labelText, Label fieldLabel, CCombo combo, String[] items) {
+ this.labelText = labelText;
+ this.fieldLabel = fieldLabel;
+ this.combo = combo;
+ this.items = items;
+ }
+ }
+
+ private void buildStreamPage(ModifyListener lsMod, String[] fieldNames) {
+ Label wlStreamFields;
+ wStreamPage = new Composite(wModeStack, SWT.NONE);
+ PropsUi.setLook(wStreamPage);
+ wStreamPage.setLayout(new FormLayout());
+
+ wlStreamFields = new Label(wStreamPage, SWT.LEFT);
+ wlStreamFields.setText(BaseMessages.getString(PKG, "RedisOutputDialog.Fields.Label"));
+ PropsUi.setLook(wlStreamFields);
+ FormData fdlFields = new FormData();
+ fdlFields.left = new FormAttachment(0, 0);
+ fdlFields.top = new FormAttachment(0, 0);
+ wlStreamFields.setLayoutData(fdlFields);
+
+ ColumnInfo[] columns =
+ new ColumnInfo[] {
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.Column.StreamField"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ fieldNames,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.Column.DataStructure"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ RedisDataStructure.getNames(),
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.Column.Key"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ fieldNames,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.Column.KeyCodec"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ RedisCodecType.getNames(),
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.Column.HashKey"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ fieldNames,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.Column.HashKeyCodec"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ RedisCodecType.getNames(),
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.Column.ValueCodec"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ RedisCodecType.getNames(),
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "RedisOutputDialog.Column.TtlSeconds"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false)
+ };
+ columns[7].setUsingVariables(true);
+
+ int fieldRows =
+ input.getFields() == null || input.getFields().isEmpty() ? 5 : input.getFields().size();
+ wFields =
+ new TableView(
+ variables,
+ wStreamPage,
+ SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+ columns,
+ fieldRows,
+ lsMod,
+ props);
+ FormData fdFields = new FormData();
+ fdFields.left = new FormAttachment(0, 0);
+ fdFields.right = new FormAttachment(100, 0);
+ fdFields.top = new FormAttachment(wlStreamFields, margin);
+ fdFields.bottom = new FormAttachment(100, 0);
+ wFields.setLayoutData(fdFields);
+ }
+
+ private Control addLabeledCombo(
+ Composite parent,
+ Control last,
+ String label,
+ CCombo combo,
+ String[] items,
+ ModifyListener lsMod,
+ Label existingLabel) {
+ Label wl = existingLabel != null ? existingLabel : new Label(parent, SWT.RIGHT);
+ wl.setText(label);
+ PropsUi.setLook(wl);
+ FormData fdl = new FormData();
+ fdl.left = new FormAttachment(0, 0);
+ fdl.right = new FormAttachment(middle, -margin);
+ if (last == null) {
+ fdl.top = new FormAttachment(0, 0);
+ } else {
+ fdl.top = new FormAttachment(last, margin);
+ }
+ wl.setLayoutData(fdl);
+ combo.setItems(items);
+ PropsUi.setLook(combo);
+ FormData fd = new FormData();
+ fd.left = new FormAttachment(middle, 0);
+ fd.right = new FormAttachment(100, 0);
+ fd.top = new FormAttachment(wl, 0, SWT.CENTER);
+ combo.setLayoutData(fd);
+ combo.addModifyListener(lsMod);
+ return combo;
+ }
+
+ private void updateVisibility() {
+ RedisOutputWriteMode writeMode = RedisOutputWriteMode.fromCode(wWriteMode.getText());
+ boolean streamFields = writeMode == RedisOutputWriteMode.STREAM_FIELDS;
+
+ if (streamFields) {
+ modeStackLayout.topControl = wStreamPage;
+ } else {
+ modeStackLayout.topControl = wKeyValuePage;
+ updateKeyValueStructureVisibility();
+ }
+ wModeStack.layout(true, true);
+
+ if (wGet != null) {
+ wGet.setEnabled(streamFields);
+ }
+ }
+
+ /**
+ * KEY_VALUE 2-column layout by data structure:
+ *
+ * <pre>
+ * Data structure
+ * Key field | Key codec
+ * Hash key field | Hash key codec (HASH only)
+ * Value field | Value codec
+ * TTL seconds | List push (list push LIST only)
+ * </pre>
+ */
+ private void updateKeyValueStructureVisibility() {
+ RedisDataStructure structure = RedisDataStructure.fromCode(wDataStructure.getText());
+ boolean hash = structure == RedisDataStructure.HASH;
+ boolean list = structure == RedisDataStructure.LIST;
+
+ wHashKeyRow.setVisible(hash);
+ FormData fdHash = (FormData) wHashKeyRow.getLayoutData();
+ if (hash) {
+ fdHash.top = new FormAttachment(wKeyRow, margin);
+ fdHash.height = SWT.DEFAULT;
+ } else {
+ fdHash.top = new FormAttachment(wKeyRow, 0);
+ fdHash.height = 0;
+ }
+ wHashKeyRow.setLayoutData(fdHash);
+
+ FormData fdValue = (FormData) wValueRow.getLayoutData();
+ fdValue.top = new FormAttachment(hash ? wHashKeyRow : wKeyRow, margin);
+ wValueRow.setLayoutData(fdValue);
+
+ wlListPushDirection.setVisible(list);
+ wListPushDirection.setVisible(list);
+ // TTL always half-width, same as field columns
+
+ wKeyValuePage.layout(true, true);
+ }
+
+ private void getFields() {
+ try {
+ IRowMeta rowMeta = pipelineMeta.getPrevTransformFields(variables, transformName);
+ BaseTransformDialog.getFieldsFromPrevious(
+ rowMeta,
+ wFields,
+ 1,
+ new int[] {1, 3},
+ new int[] {},
+ -1,
+ -1,
+ (item, valueMeta) -> {
+ item.setText(2, RedisDataStructure.STRING.name());
+ item.setText(4, RedisCodecType.STRING.name());
+ item.setText(5, "");
+ item.setText(6, "");
+ item.setText(7, RedisCodecType.STRING.name());
+ item.setText(8, "0");
+ return true;
+ });
+ input.setChanged();
+ } catch (Exception e) {
+ new ErrorDialog(shell, "Error", "Error getting fields", e);
+ }
+ }
+
+ private void getData() {
+ wConnection.setText(Const.NVL(input.getConnectionName(), ""));
+ wWriteMode.setText(
+ input.getWriteMode() == null
+ ? RedisOutputWriteMode.KEY_VALUE.name()
+ : input.getWriteMode().name());
+
+ RedisDataStructure structure =
+ input.getDataStructure() == null ? RedisDataStructure.STRING : input.getDataStructure();
+ wDataStructure.setText(structure.name());
+
+ wKeyCodec.setText(codecName(input.getKeyCodec()));
+ wHashKeyCodec.setText(codecName(input.getHashKeyCodec()));
+
+ wKeyField.setText(Const.NVL(input.getKeyField(), ""));
+ wHashKeyField.setText(Const.NVL(input.getHashKeyField(), ""));
+
+ // HASH uses hash-value meta in the shared Value field/codec widgets
+ if (structure == RedisDataStructure.HASH) {
+ wValueField.setText(Const.NVL(input.getHashValueField(), ""));
+ wValueCodec.setText(codecName(input.getHashValueCodec()));
+ } else {
+ wValueField.setText(Const.NVL(input.getValueField(), ""));
+ wValueCodec.setText(codecName(input.getValueCodec()));
+ }
+
+ wTtlSeconds.setText(Const.NVL(input.getTtlSeconds(), ""));
+ wListPushDirection.setText(
+ input.getListPushDirection() == null
+ ? RedisListPushDirection.RPUSH.name()
+ : input.getListPushDirection().name());
+
+ wFields.clearAll(false);
+ if (input.getFields() != null) {
+ for (RedisOutputField field : input.getFields()) {
+ TableItem item = new TableItem(wFields.table, SWT.NONE);
+ item.setText(1, Const.NVL(field.getStreamField(), ""));
+ item.setText(2, field.resolveDataStructure().name());
+ item.setText(3, Const.NVL(field.resolveKey(), ""));
+ item.setText(4, codecName(field.getKeyCodec()));
+ item.setText(5, Const.NVL(field.getHashKey(), ""));
+ item.setText(6, optionalCodecName(field.getHashKeyCodec()));
+ item.setText(7, codecName(field.getValueCodec()));
+ item.setText(8, Const.NVL(field.getTtlSeconds(), "0"));
+ }
+ }
+ wFields.removeEmptyRows();
+ wFields.setRowNums();
+ wFields.optWidth(true);
+ }
+
+ private static String codecName(RedisCodecType type) {
+ return type == null ? RedisCodecType.STRING.name() : type.name();
+ }
+
+ private static String optionalCodecName(RedisCodecType type) {
+ return type == null ? "" : type.name();
+ }
+
+ private void cancel() {
+ transformName = null;
+ input.setChanged(changed);
+ dispose();
+ }
+
+ private void ok() {
+ if (StringUtils.isEmpty(wTransformName.getText())) {
+ return;
+ }
+ transformName = wTransformName.getText();
+ input.setConnectionName(wConnection.getText());
+ input.setWriteMode(RedisOutputWriteMode.fromCode(wWriteMode.getText()));
+
+ RedisOutputWriteMode writeMode = RedisOutputWriteMode.fromCode(wWriteMode.getText());
+ // Always keep KEY_VALUE field slots; STREAM_FIELDS uses per-row TTL (not component TTL)
+ saveKeyValueMappings();
+ if (writeMode != RedisOutputWriteMode.STREAM_FIELDS) {
+ input.setTtlSeconds(wTtlSeconds.getText());
+ }
+
+ List<RedisOutputField> fields = new ArrayList<>();
+ for (TableItem item : wFields.getNonEmptyItems()) {
+ RedisOutputField field = new RedisOutputField();
+ field.setStreamField(item.getText(1));
+ field.setDataStructure(RedisDataStructure.fromCode(item.getText(2)));
+ field.setKey(item.getText(3));
+ field.setKeyCodec(RedisCodecType.fromCode(item.getText(4)));
+ field.setHashKey(item.getText(5));
+ String hashKeyCodec = item.getText(6);
+ field.setHashKeyCodec(
+ StringUtils.isEmpty(hashKeyCodec) ? null : RedisCodecType.fromCode(hashKeyCodec));
+ field.setValueCodec(RedisCodecType.fromCode(item.getText(7)));
+ String ttl = item.getText(8);
+ field.setTtlSeconds(StringUtils.isEmpty(ttl) ? "0" : ttl);
+ fields.add(field);
+ }
+ input.setFields(fields);
+ input.setChanged();
+ dispose();
+ }
+
+ private void saveKeyValueMappings() {
+ RedisDataStructure structure = RedisDataStructure.fromCode(wDataStructure.getText());
+ input.setDataStructure(structure);
+ input.setKeyCodec(RedisCodecType.fromCode(wKeyCodec.getText()));
+ input.setKeyField(wKeyField.getText());
+ input.setListPushDirection(RedisListPushDirection.fromCode(wListPushDirection.getText()));
+ input.setHashKeyField(wHashKeyField.getText());
+ input.setHashKeyCodec(RedisCodecType.fromCode(wHashKeyCodec.getText()));
+
+ if (structure == RedisDataStructure.HASH) {
+ input.setHashValueField(wValueField.getText());
+ input.setHashValueCodec(RedisCodecType.fromCode(wValueCodec.getText()));
+ } else {
+ input.setValueField(wValueField.getText());
+ input.setValueCodec(RedisCodecType.fromCode(wValueCodec.getText()));
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputField.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputField.java
new file mode 100644
index 0000000000..aff3d4a5d6
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputField.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+
+/**
+ * One mapping row for {@link RedisOutputWriteMode#STREAM_FIELDS}.
+ *
+ * <p>Required: stream field, key, key codec, value codec (defaults STRING). Hash key / hash key
+ * codec apply only when data structure is HASH.
+ */
+@Getter
+@Setter
+public class RedisOutputField implements Cloneable {
+
+ @HopMetadataProperty(key = "stream_field")
+ private String streamField;
+
+ @HopMetadataProperty(key = "data_structure")
+ private RedisDataStructure dataStructure = RedisDataStructure.STRING;
+
+ /** Redis key: stream field name or literal / variable text. */
+ @HopMetadataProperty(key = "key")
+ private String key;
+
+ @HopMetadataProperty(key = "key_codec")
+ private RedisCodecType keyCodec = RedisCodecType.STRING;
+
+ /** Hash field name: stream field name or literal. Ignored unless structure is HASH. */
+ @HopMetadataProperty(key = "hash_key")
+ private String hashKey;
+
+ @HopMetadataProperty(key = "hash_key_codec")
+ private RedisCodecType hashKeyCodec;
+
+ @HopMetadataProperty(key = "value_codec")
+ private RedisCodecType valueCodec = RedisCodecType.STRING;
+
+ /**
+ * Optional TTL in seconds (static or variable). Values greater than 0 set expiry; {@code 0} or
+ * empty means no expire.
+ */
+ @HopMetadataProperty(key = "ttl_seconds")
+ private String ttlSeconds = "0";
+
+ /**
+ * Legacy column from earlier STREAM_FIELDS design. Still read when {@link #key} is empty so old
+ * pipelines keep working.
+ */
+ @HopMetadataProperty(key = "redis_name")
+ private String redisName;
+
+ public RedisOutputField() {}
+
+ public RedisOutputField(RedisOutputField other) {
+ this.streamField = other.streamField;
+ this.dataStructure = other.dataStructure;
+ this.key = other.key;
+ this.keyCodec = other.keyCodec;
+ this.hashKey = other.hashKey;
+ this.hashKeyCodec = other.hashKeyCodec;
+ this.valueCodec = other.valueCodec;
+ this.ttlSeconds = other.ttlSeconds;
+ this.redisName = other.redisName;
+ }
+
+ @Override
+ public RedisOutputField clone() {
+ return new RedisOutputField(this);
+ }
+
+ public RedisDataStructure resolveDataStructure() {
+ return dataStructure == null ? RedisDataStructure.STRING : dataStructure;
+ }
+
+ /** Resolved Redis key expression (field name or literal). */
+ public String resolveKey() {
+ if (StringUtils.isNotEmpty(key)) {
+ return key;
+ }
+ if (StringUtils.isNotEmpty(redisName)) {
+ return redisName;
+ }
+ return streamField;
+ }
+
+ /** Resolved hash field name (literal / field name). Empty when not set. */
+ public String resolveHashKey() {
+ return hashKey;
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputMeta.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputMeta.java
new file mode 100644
index 0000000000..e32de47bf0
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputMeta.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.HopMetadataPropertyType;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.redis.transforms.RedisListPushDirection;
+
+@Getter
+@Setter
+@Transform(
+ id = "RedisOutput",
+ image = "redis-output.svg",
+ name = "i18n::RedisOutput.Name",
+ description = "i18n::RedisOutput.Description",
+ documentationUrl = "/pipeline/transforms/redis-output.html",
+ keywords = "i18n::RedisOutputMeta.keyword",
+ categoryDescription = "i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.Output")
+public class RedisOutputMeta extends BaseTransformMeta<RedisOutput, RedisOutputData> {
+
+ @HopMetadataProperty(
+ key = "connection",
+ hopMetadataPropertyType = HopMetadataPropertyType.REDIS_CONNECTION)
+ private String connectionName;
+
+ @HopMetadataProperty(key = "write_mode")
+ private RedisOutputWriteMode writeMode = RedisOutputWriteMode.KEY_VALUE;
+
+ @HopMetadataProperty(key = "data_structure")
+ private RedisDataStructure dataStructure = RedisDataStructure.STRING;
+
+ @HopMetadataProperty(key = "key_codec")
+ private RedisCodecType keyCodec = RedisCodecType.STRING;
+
+ @HopMetadataProperty(key = "value_codec")
+ private RedisCodecType valueCodec = RedisCodecType.STRING;
+
+ @HopMetadataProperty(key = "hash_key_codec")
+ private RedisCodecType hashKeyCodec = RedisCodecType.STRING;
+
+ @HopMetadataProperty(key = "hash_value_codec")
+ private RedisCodecType hashValueCodec = RedisCodecType.STRING;
+
+ @HopMetadataProperty(key = "key_field")
+ private String keyField;
+
+ @HopMetadataProperty(key = "value_field")
+ private String valueField;
+
+ @HopMetadataProperty(key = "hash_key_field")
+ private String hashKeyField;
+
+ @HopMetadataProperty(key = "hash_value_field")
+ private String hashValueField;
+
+ /** Optional TTL in seconds (static or variable). Empty means no expire. */
+ @HopMetadataProperty(key = "ttl_seconds")
+ private String ttlSeconds;
+
+ @HopMetadataProperty(key = "list_push_direction")
+ private RedisListPushDirection listPushDirection = RedisListPushDirection.RPUSH;
+
+ /** Fields used when {@link #writeMode} is {@link RedisOutputWriteMode#STREAM_FIELDS}. */
+ @HopMetadataProperty(key = "field", groupKey = "fields")
+ private List<RedisOutputField> fields = new ArrayList<>();
+
+ @Override
+ public void setDefault() {
+ connectionName = null;
+ writeMode = RedisOutputWriteMode.KEY_VALUE;
+ dataStructure = RedisDataStructure.STRING;
+ keyCodec = RedisCodecType.STRING;
+ valueCodec = RedisCodecType.STRING;
+ hashKeyCodec = RedisCodecType.STRING;
+ hashValueCodec = RedisCodecType.STRING;
+ keyField = "";
+ valueField = "";
+ hashKeyField = "";
+ hashValueField = "";
+ ttlSeconds = "";
+ listPushDirection = RedisListPushDirection.RPUSH;
+ fields = new ArrayList<>();
+ }
+
+ /** Null-safe setter kept explicitly so empty metadata never leaves {@code fields} null. */
+ public void setFields(List<RedisOutputField> fields) {
+ this.fields = fields == null ? new ArrayList<>() : fields;
+ }
+}
diff --git a/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputWriteMode.java b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputWriteMode.java
new file mode 100644
index 0000000000..784640403c
--- /dev/null
+++ b/plugins/tech/redis/src/main/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputWriteMode.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+/**
+ * How stream fields are mapped when writing to Redis.
+ *
+ * <ul>
+ * <li>{@link #KEY_VALUE} — pick key / value (or hash key / hash value) fields explicitly
+ * <li>{@link #STREAM_FIELDS} — per-row mapping: stream field, data structure, key, codecs, and
+ * optional hash key (Get fields fills defaults)
+ * </ul>
+ */
+public enum RedisOutputWriteMode {
+ KEY_VALUE,
+ STREAM_FIELDS;
+
+ public static String[] getNames() {
+ String[] names = new String[values().length];
+ for (int i = 0; i < names.length; i++) {
+ names[i] = values()[i].name();
+ }
+ return names;
+ }
+
+ public static RedisOutputWriteMode fromCode(String code) {
+ if (code == null || code.isBlank()) {
+ return KEY_VALUE;
+ }
+ try {
+ return RedisOutputWriteMode.valueOf(code.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return KEY_VALUE;
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/main/resources/org/apache/hop/redis/metadata/messages/messages_en_US.properties b/plugins/tech/redis/src/main/resources/org/apache/hop/redis/metadata/messages/messages_en_US.properties
new file mode 100644
index 0000000000..80acc95222
--- /dev/null
+++ b/plugins/tech/redis/src/main/resources/org/apache/hop/redis/metadata/messages/messages_en_US.properties
@@ -0,0 +1,53 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+RedisConnection.name=Redis Connection
+RedisConnection.description=A reusable Redis connection (Standalone, Sentinel or Cluster)
+RedisConnectionEditor.Name.Label=Redis Connection name
+RedisConnectionEditor.ConnectionGroup.Label=Connection config
+RedisConnectionEditor.PoolGroup.Label=Pool config
+RedisMetadata.DeploymentMode.Label=Deployment mode
+RedisMetadata.DeploymentMode.ToolTip=STANDALONE, SENTINEL or CLUSTER
+RedisMetadata.Hostname.Label=Hostname
+RedisMetadata.Hostname.ToolTip=Redis hostname for Standalone mode
+RedisMetadata.Port.Label=Port
+RedisMetadata.Port.ToolTip=Redis port for Standalone mode (default 6379)
+RedisMetadata.Database.Label=Database
+RedisMetadata.Database.ToolTip=Logical database index (Standalone/Sentinel only; Cluster always uses 0)
+RedisMetadata.MasterName.Label=Master name
+RedisMetadata.MasterName.ToolTip=Sentinel master name
+RedisMetadata.SentinelNodes.Label=Sentinel nodes
+RedisMetadata.SentinelNodes.ToolTip=Sentinel nodes as host:port, one per line (comma/semicolon also accepted)
+RedisMetadata.ClusterNodes.Label=Cluster nodes
+RedisMetadata.ClusterNodes.ToolTip=Cluster seed nodes as host:port, one per line (comma/semicolon also accepted)
+RedisMetadata.Username.Label=Username
+RedisMetadata.Username.ToolTip=Optional ACL username
+RedisMetadata.Password.Label=Password
+RedisMetadata.Password.ToolTip=Optional password
+RedisMetadata.UseSsl.Label=Use SSL/TLS
+RedisMetadata.UseSsl.ToolTip=Enable TLS for the Redis connection
+RedisMetadata.TimeoutMs.Label=Timeout (ms)
+RedisMetadata.TimeoutMs.ToolTip=Command timeout in milliseconds
+RedisMetadata.EnablePooling.Label=Enable connection pooling
+RedisMetadata.EnablePooling.ToolTip=Use a shared Lettuce connection pool (applies to Standalone, Sentinel and Cluster)
+RedisMetadata.PoolMaxTotal.Label=Max total
+RedisMetadata.PoolMaxTotal.ToolTip=Maximum number of connections in the pool
+RedisMetadata.PoolMaxIdle.Label=Max idle
+RedisMetadata.PoolMaxIdle.ToolTip=Maximum number of idle connections
+RedisMetadata.PoolMinIdle.Label=Min idle
+RedisMetadata.PoolMinIdle.ToolTip=Minimum number of idle connections
+RedisMetadata.PoolMaxWaitMs.Label=Max wait (ms)
+RedisMetadata.PoolMaxWaitMs.ToolTip=Maximum wait when borrowing a connection (-1 = forever)
diff --git a/plugins/tech/redis/src/main/resources/org/apache/hop/redis/transforms/redisinput/messages/messages_en_US.properties b/plugins/tech/redis/src/main/resources/org/apache/hop/redis/transforms/redisinput/messages/messages_en_US.properties
new file mode 100644
index 0000000000..6171589389
--- /dev/null
+++ b/plugins/tech/redis/src/main/resources/org/apache/hop/redis/transforms/redisinput/messages/messages_en_US.properties
@@ -0,0 +1,31 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+RedisInput.Name=Redis input
+RedisInput.Description=Read STRING, HASH, SET or LIST values from Redis
+RedisInputMeta.keyword=redis,input,cache,get,hash,set,list
+RedisInputDialog.Connection.Label=Redis connection
+RedisInputDialog.Connection.Tooltip=Select a Redis connection metadata object
+RedisInputDialog.Fields.Label=Fields
+RedisInputDialog.Column.RedisKey=Redis key
+RedisInputDialog.Column.RedisKeyCodec=Redis key codec
+RedisInputDialog.Column.DataStructure=Data structure
+RedisInputDialog.Column.HashField=Hash field
+RedisInputDialog.Column.HashFieldCodec=Hash field codec
+RedisInputDialog.Column.ValueField=Value field
+RedisInputDialog.Column.ValueCodec=Value codec
+RedisInputDialog.Column.ListStart=List start
+RedisInputDialog.Column.ListStop=List stop
diff --git a/plugins/tech/redis/src/main/resources/org/apache/hop/redis/transforms/redisoutput/messages/messages_en_US.properties b/plugins/tech/redis/src/main/resources/org/apache/hop/redis/transforms/redisoutput/messages/messages_en_US.properties
new file mode 100644
index 0000000000..944c47198f
--- /dev/null
+++ b/plugins/tech/redis/src/main/resources/org/apache/hop/redis/transforms/redisoutput/messages/messages_en_US.properties
@@ -0,0 +1,40 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+RedisOutput.Name=Redis output
+RedisOutput.Description=Write STRING, HASH, SET or LIST values to Redis
+RedisOutputMeta.keyword=redis,output,cache,set,hash,sadd,list
+RedisOutputDialog.Connection.Label=Redis connection
+RedisOutputDialog.Connection.Tooltip=Select a Redis connection metadata object
+RedisOutputDialog.WriteMode.Label=Write mode
+RedisOutputDialog.DataStructure.Label=Data structure
+RedisOutputDialog.KeyField.Label=Key field
+RedisOutputDialog.KeyCodec.Label=Key codec
+RedisOutputDialog.HashKeyField.Label=Hash key field
+RedisOutputDialog.HashKeyCodec.Label=Hash key codec
+RedisOutputDialog.ValueField.Label=Value field
+RedisOutputDialog.ValueCodec.Label=Value codec
+RedisOutputDialog.TtlSeconds.Label=TTL seconds
+RedisOutputDialog.ListPushDirection.Label=List push direction
+RedisOutputDialog.Fields.Label=Fields
+RedisOutputDialog.Column.StreamField=Stream field
+RedisOutputDialog.Column.DataStructure=Data structure
+RedisOutputDialog.Column.Key=Redis key
+RedisOutputDialog.Column.KeyCodec=Redis key codec
+RedisOutputDialog.Column.HashKey=Hash field
+RedisOutputDialog.Column.HashKeyCodec=Hash field codec
+RedisOutputDialog.Column.ValueCodec=Value codec
+RedisOutputDialog.Column.TtlSeconds=TTL seconds
diff --git a/plugins/tech/redis/src/main/resources/redis-input.svg b/plugins/tech/redis/src/main/resources/redis-input.svg
new file mode 100644
index 0000000000..0acd6fe07a
--- /dev/null
+++ b/plugins/tech/redis/src/main/resources/redis-input.svg
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="42px" height="42px" viewBox="0 -2.953 42 42" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
+ <g>
+ <path d="M40.354 27.717c-2.241 1.168 -13.852 5.942 -16.324 7.231s-3.845 1.276 -5.798 0.343c-1.953 -0.934 -14.309 -5.925 -16.535 -6.989C0.585 27.771 0 27.322 0 26.898v-4.245s16.086 -3.502 18.683 -4.434c2.597 -0.932 3.498 -0.965 5.708 -0.156 2.21 0.81 15.425 3.194 17.609 3.994l-0.001 4.185c0 0.42 -0.504 0.88 -1.645 1.475" fill="#912626"/>
+ <path d="M40.354 23.497c-2.241 1.168 -13.852 5.942 -16.324 7.231 -2.472 1.289 -3.845 1.276 -5.798 0.343 -1.953 -0.933 -14.309 -5.925 -16.534 -6.988 -2.226 -1.064 -2.272 -1.796 -0.086 -2.652 2.186 -0.856 14.474 -5.677 17.072 -6.609 2.597 -0.931 3.498 -0.965 5.708 -0.156 2.21 0.809 13.752 5.403 15.935 6.203 2.185 0.801 2.268 1.46 0.027 2.628" fill="#C6302B"/>
+ <path d="M40.354 20.848c-2.241 1.168 -13.852 5.942 -16.324 7.232 -2.472 1.288 -3.845 1.276 -5.798 0.342 -1.953 -0.933 -14.309 -5.925 -16.535 -6.989C0.585 20.902 0 20.454 0 20.03V15.784s16.086 -3.502 18.683 -4.433c2.597 -0.932 3.498 -0.966 5.708 -0.156C26.601 12.004 39.816 14.388 42 15.188l-0.001 4.186c0 0.42 -0.504 0.88 -1.645 1.474" fill="#912626"/>
+ <path d="M40.354 16.628c-2.241 1.168 -13.852 5.942 -16.324 7.231 -2.472 1.289 -3.845 1.276 -5.798 0.342 -1.953 -0.933 -14.309 -5.925 -16.534 -6.988 -2.226 -1.064 -2.272 -1.796 -0.086 -2.652C3.798 13.705 16.086 8.884 18.683 7.952c2.597 -0.932 3.498 -0.965 5.708 -0.156 2.21 0.809 13.752 5.403 15.935 6.203 2.185 0.801 2.268 1.46 0.027 2.628" fill="#C6302B"/>
+ <path d="M40.354 13.724c-2.241 1.168 -13.852 5.942 -16.324 7.232 -2.472 1.289 -3.845 1.276 -5.798 0.342 -1.953 -0.933 -14.309 -5.925 -16.535 -6.988C0.585 13.778 0 13.33 0 12.906v-4.246s16.086 -3.502 18.683 -4.433c2.597 -0.932 3.498 -0.965 5.708 -0.156C26.601 4.881 39.816 7.264 42 8.064l-0.001 4.186c0 0.419 -0.504 0.88 -1.645 1.474" fill="#912626"/>
+ <path d="M40.354 9.504c-2.241 1.168 -13.852 5.942 -16.324 7.231 -2.472 1.289 -3.845 1.276 -5.798 0.343C16.279 16.144 3.924 11.153 1.698 10.089S-0.574 8.293 1.612 7.437C3.798 6.581 16.086 1.76 18.683 0.829c2.597 -0.932 3.498 -0.965 5.708 -0.156s13.752 5.403 15.935 6.203c2.185 0.8 2.268 1.46 0.027 2.628" fill="#C6302B"/>
+ <path d="m26.132 5.374 -3.611 0.375 -0.808 1.945 -1.306 -2.171 -4.17 -0.375 3.111 -1.122 -0.934 -1.722 2.913 1.139 2.746 -0.899 -0.742 1.781z" fill="#FFFFFF"/>
+ <path d="M21.497 14.811 14.758 12.016l9.657 -1.482z" fill="#FFFFFF"/>
+ <path d="M12.154 6.455c2.851 0 5.161 0.896 5.161 2.001 0 1.105 -2.311 2.001 -5.161 2.001s-5.161 -0.896 -5.161 -2.001c0 -1.105 2.311 -2.001 5.161 -2.001" fill="#FFFFFF"/>
+ <path d="m30.4 5.906 5.715 2.258 -5.71 2.256z" fill="#621B1C"/>
+ <path d="m24.077 8.407 6.323 -2.501 0.005 4.515 -0.62 0.242z" fill="#9A2928"/>
+ </g>
+ <g>
+ <polygon fill="#0E3A5A"
+ points="37.2,32.7 37.2,35.8 32,30.6 30.7,31.9 35.9,37.2 32.7,37.2 32.7,39.1 39.1,39.1 39.1,32.7 "/>
+ </g>
+</svg>
diff --git a/plugins/tech/redis/src/main/resources/redis-output.svg b/plugins/tech/redis/src/main/resources/redis-output.svg
new file mode 100644
index 0000000000..29bd8af508
--- /dev/null
+++ b/plugins/tech/redis/src/main/resources/redis-output.svg
@@ -0,0 +1,27 @@
+<svg width="42px" height="42px" viewBox="-3.15 -3.15 45.15 45.15" version="1.1" xmlns="http://www.w3.org/2000/svg"
+ preserveAspectRatio="xMidYMid">
+ <g>
+ <path d="M40.354 27.717c-2.241 1.168 -13.852 5.942 -16.324 7.231s-3.845 1.276 -5.798 0.343c-1.953 -0.934 -14.309 -5.925 -16.535 -6.989C0.585 27.771 0 27.322 0 26.898v-4.245s16.086 -3.502 18.683 -4.434 3.498 -0.965 5.708 -0.156C26.601 18.873 39.816 21.257 42 22.057l-0.001 4.185c0 0.42 -0.504 0.88 -1.645 1.475"
+ fill="#912626"/>
+ <path d="M40.354 23.497c-2.241 1.168 -13.852 5.942 -16.324 7.231s-3.845 1.276 -5.798 0.343 -14.309 -5.925 -16.534 -6.988c-2.226 -1.064 -2.272 -1.796 -0.086 -2.652s14.474 -5.677 17.072 -6.609c2.597 -0.931 3.498 -0.965 5.708 -0.156s13.752 5.403 15.935 6.203c2.185 0.801 2.268 1.46 0.027 2.628"
+ fill="#C6302B"/>
+ <path d="M40.354 20.848c-2.241 1.168 -13.852 5.942 -16.324 7.232 -2.472 1.288 -3.845 1.276 -5.798 0.342S3.923 22.497 1.697 21.433C0.585 20.902 0 20.454 0 20.03v-4.246s16.086 -3.502 18.683 -4.433c2.597 -0.932 3.498 -0.966 5.708 -0.156S39.816 14.388 42 15.188l-0.001 4.186c0 0.42 -0.504 0.88 -1.645 1.474"
+ fill="#912626"/>
+ <path d="M40.354 16.628c-2.241 1.168 -13.852 5.942 -16.324 7.231s-3.845 1.276 -5.798 0.342 -14.309 -5.925 -16.534 -6.988c-2.226 -1.064 -2.272 -1.796 -0.086 -2.652S16.086 8.884 18.683 7.952s3.498 -0.965 5.708 -0.156 13.752 5.403 15.935 6.203c2.185 0.801 2.268 1.46 0.027 2.628"
+ fill="#C6302B"/>
+ <path d="M40.354 13.724c-2.241 1.168 -13.852 5.942 -16.324 7.232 -2.472 1.289 -3.845 1.276 -5.798 0.342S3.923 15.373 1.697 14.31C0.585 13.778 0 13.33 0 12.906V8.66s16.086 -3.502 18.683 -4.433c2.597 -0.932 3.498 -0.965 5.708 -0.156C26.601 4.881 39.816 7.264 42 8.064l-0.001 4.186c0 0.419 -0.504 0.88 -1.645 1.474"
+ fill="#912626"/>
+ <path d="M40.354 9.504c-2.241 1.168 -13.852 5.942 -16.324 7.231s-3.845 1.276 -5.798 0.343c-1.953 -0.934 -14.308 -5.925 -16.534 -6.989s-2.272 -1.796 -0.086 -2.652S16.086 1.76 18.683 0.829c2.597 -0.932 3.498 -0.965 5.708 -0.156s13.752 5.403 15.935 6.203c2.185 0.8 2.268 1.46 0.027 2.628"
+ fill="#C6302B"/>
+ <path d="m26.132 5.374 -3.611 0.375 -0.808 1.945L20.407 5.523l-4.17 -0.375 3.111 -1.122 -0.934 -1.722 2.913 1.139 2.746 -0.899 -0.742 1.781z"
+ fill="#FFFFFF"/>
+ <path d="m21.497 14.811 -6.739 -2.795 9.657 -1.482z" fill="#FFFFFF"/>
+ <path d="M12.154 6.455c2.851 0 5.161 0.896 5.161 2.001s-2.311 2.001 -5.161 2.001S6.993 9.561 6.993 8.456s2.311 -2.001 5.161 -2.001"
+ fill="#FFFFFF"/>
+ <path d="m30.4 5.906 5.715 2.258 -5.71 2.256z" fill="#621B1C"/>
+ <path d="m24.077 8.407 6.323 -2.501 0.005 4.515 -0.62 0.242z" fill="#9A2928"/>
+ </g>
+ <g id="arrow-top-left">
+ <path fill="#0E3A5A" d="M3.9 -0.65v3.1L-1.3 -2.75l-1.3 1.3 5.2 5.2H-0.6V5.65h6.4v-6.3Z"/>
+ </g>
+</svg>
\ No newline at end of file
diff --git a/plugins/tech/redis/src/main/resources/redis.svg b/plugins/tech/redis/src/main/resources/redis.svg
new file mode 100644
index 0000000000..5f3898f1ec
--- /dev/null
+++ b/plugins/tech/redis/src/main/resources/redis.svg
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="42px" height="42px" viewBox="0 -2.953 42 42" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
+ <g>
+ <path d="M40.354 27.717c-2.241 1.168 -13.852 5.942 -16.324 7.231s-3.845 1.276 -5.798 0.343c-1.953 -0.934 -14.309 -5.925 -16.535 -6.989C0.585 27.771 0 27.322 0 26.898v-4.245s16.086 -3.502 18.683 -4.434c2.597 -0.932 3.498 -0.965 5.708 -0.156 2.21 0.81 15.425 3.194 17.609 3.994l-0.001 4.185c0 0.42 -0.504 0.88 -1.645 1.475" fill="#912626"/>
+ <path d="M40.354 23.497c-2.241 1.168 -13.852 5.942 -16.324 7.231 -2.472 1.289 -3.845 1.276 -5.798 0.343 -1.953 -0.933 -14.309 -5.925 -16.534 -6.988 -2.226 -1.064 -2.272 -1.796 -0.086 -2.652 2.186 -0.856 14.474 -5.677 17.072 -6.609 2.597 -0.931 3.498 -0.965 5.708 -0.156 2.21 0.809 13.752 5.403 15.935 6.203 2.185 0.801 2.268 1.46 0.027 2.628" fill="#C6302B"/>
+ <path d="M40.354 20.848c-2.241 1.168 -13.852 5.942 -16.324 7.232 -2.472 1.288 -3.845 1.276 -5.798 0.342 -1.953 -0.933 -14.309 -5.925 -16.535 -6.989C0.585 20.902 0 20.454 0 20.03V15.784s16.086 -3.502 18.683 -4.433c2.597 -0.932 3.498 -0.966 5.708 -0.156C26.601 12.004 39.816 14.388 42 15.188l-0.001 4.186c0 0.42 -0.504 0.88 -1.645 1.474" fill="#912626"/>
+ <path d="M40.354 16.628c-2.241 1.168 -13.852 5.942 -16.324 7.231 -2.472 1.289 -3.845 1.276 -5.798 0.342 -1.953 -0.933 -14.309 -5.925 -16.534 -6.988 -2.226 -1.064 -2.272 -1.796 -0.086 -2.652C3.798 13.705 16.086 8.884 18.683 7.952c2.597 -0.932 3.498 -0.965 5.708 -0.156 2.21 0.809 13.752 5.403 15.935 6.203 2.185 0.801 2.268 1.46 0.027 2.628" fill="#C6302B"/>
+ <path d="M40.354 13.724c-2.241 1.168 -13.852 5.942 -16.324 7.232 -2.472 1.289 -3.845 1.276 -5.798 0.342 -1.953 -0.933 -14.309 -5.925 -16.535 -6.988C0.585 13.778 0 13.33 0 12.906v-4.246s16.086 -3.502 18.683 -4.433c2.597 -0.932 3.498 -0.965 5.708 -0.156C26.601 4.881 39.816 7.264 42 8.064l-0.001 4.186c0 0.419 -0.504 0.88 -1.645 1.474" fill="#912626"/>
+ <path d="M40.354 9.504c-2.241 1.168 -13.852 5.942 -16.324 7.231 -2.472 1.289 -3.845 1.276 -5.798 0.343C16.279 16.144 3.924 11.153 1.698 10.089S-0.574 8.293 1.612 7.437C3.798 6.581 16.086 1.76 18.683 0.829c2.597 -0.932 3.498 -0.965 5.708 -0.156s13.752 5.403 15.935 6.203c2.185 0.8 2.268 1.46 0.027 2.628" fill="#C6302B"/>
+ <path d="m26.132 5.374 -3.611 0.375 -0.808 1.945 -1.306 -2.171 -4.17 -0.375 3.111 -1.122 -0.934 -1.722 2.913 1.139 2.746 -0.899 -0.742 1.781z" fill="#FFFFFF"/>
+ <path d="M21.497 14.811 14.758 12.016l9.657 -1.482z" fill="#FFFFFF"/>
+ <path d="M12.154 6.455c2.851 0 5.161 0.896 5.161 2.001 0 1.105 -2.311 2.001 -5.161 2.001s-5.161 -0.896 -5.161 -2.001c0 -1.105 2.311 -2.001 5.161 -2.001" fill="#FFFFFF"/>
+ <path d="m30.4 5.906 5.715 2.258 -5.71 2.256z" fill="#621B1C"/>
+ <path d="m24.077 8.407 6.323 -2.501 0.005 4.515 -0.62 0.242z" fill="#9A2928"/>
+ </g>
+</svg>
diff --git a/plugins/tech/redis/src/main/resources/version.xml b/plugins/tech/redis/src/main/resources/version.xml
new file mode 100644
index 0000000000..36ab20e22e
--- /dev/null
+++ b/plugins/tech/redis/src/main/resources/version.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ ~
+ -->
+
+<version>${project.version}</version>
diff --git a/plugins/tech/redis/src/main/samples/transforms/input/redis-input-multi-structure.hpl b/plugins/tech/redis/src/main/samples/transforms/input/redis-input-multi-structure.hpl
new file mode 100644
index 0000000000..fb19fcfbaa
--- /dev/null
+++ b/plugins/tech/redis/src/main/samples/transforms/input/redis-input-multi-structure.hpl
@@ -0,0 +1,199 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>-1</pipeline_status>
+ <parameters/>
+ <name>New pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/08/01 13:01:09.358</created_date>
+ <modified_date>2026/08/01 13:01:09.358</modified_date>
+ </info>
+ <transform>
+ <type>WriteToLog</type>
+ <name>Write to log</name>
+ <displayHeader>Y</displayHeader>
+ <limitRows>N</limitRows>
+ <limitRowsNumber>0</limitRowsNumber>
+ <logmessage/>
+ <loglevel>Basic</loglevel>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>336</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>DataGrid</type>
+ <name>Data grid</name>
+ <fields>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>name</name>
+ <type>String</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>age</name>
+ <type>Integer</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>type</name>
+ <type>String</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>json</name>
+ <type>JSON</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>bob</item>
+ <item>12</item>
+ <item>age</item>
+ <item>{"name":"bob","type": "age", "age": 12}</item>
+ </line>
+ </data>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>48</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>RedisInput</type>
+ <name>Redis input</name>
+ <connection>localhost_standalone</connection>
+ <fields>
+ <field>
+ <redis_key>name_key</redis_key>
+ <redis_key_codec>STRING</redis_key_codec>
+ <data_structure>SET</data_structure>
+ <hash_field/>
+ <value_field>nameValue</value_field>
+ <value_codec>STRING</value_codec>
+ <list_start>0</list_start>
+ <list_stop>-1</list_stop>
+ </field>
+ <field>
+ <redis_key>age_key</redis_key>
+ <redis_key_codec>STRING</redis_key_codec>
+ <data_structure>LIST</data_structure>
+ <hash_field/>
+ <value_field>ageValue</value_field>
+ <value_codec>BYTE</value_codec>
+ <list_start>0</list_start>
+ <list_stop>-1</list_stop>
+ </field>
+ <field>
+ <redis_key>type_key</redis_key>
+ <redis_key_codec>STRING</redis_key_codec>
+ <data_structure>HASH</data_structure>
+ <hash_field>no_name</hash_field>
+ <hash_field_codec>STRING</hash_field_codec>
+ <value_field>typeValue</value_field>
+ <value_codec>JAVA_OBJECT</value_codec>
+ <list_start>0</list_start>
+ <list_stop>-1</list_stop>
+ </field>
+ <field>
+ <redis_key>json_key</redis_key>
+ <redis_key_codec>STRING</redis_key_codec>
+ <data_structure>STRING</data_structure>
+ <hash_field/>
+ <value_field>jsonValue</value_field>
+ <value_codec>JSON</value_codec>
+ <list_start>0</list_start>
+ <list_stop>-1</list_stop>
+ </field>
+ </fields>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>192</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <order>
+ <hop>
+ <from>Data grid</from>
+ <to>Redis input</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Redis input</from>
+ <to>Write to log</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <notepads/>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/plugins/tech/redis/src/main/samples/transforms/output/redis-output-hash.hpl b/plugins/tech/redis/src/main/samples/transforms/output/redis-output-hash.hpl
new file mode 100644
index 0000000000..6fc300ddd5
--- /dev/null
+++ b/plugins/tech/redis/src/main/samples/transforms/output/redis-output-hash.hpl
@@ -0,0 +1,157 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>-1</pipeline_status>
+ <parameters/>
+ <name>New pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/08/01 13:01:09.358</created_date>
+ <modified_date>2026/08/01 13:01:09.358</modified_date>
+ </info>
+ <transform>
+ <type>WriteToLog</type>
+ <name>Write to log</name>
+ <displayHeader>Y</displayHeader>
+ <limitRows>N</limitRows>
+ <limitRowsNumber>0</limitRowsNumber>
+ <logmessage/>
+ <loglevel>Basic</loglevel>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>336</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>DataGrid</type>
+ <name>Data grid</name>
+ <fields>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>name</name>
+ <type>String</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>age</name>
+ <type>Integer</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>type</name>
+ <type>String</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>bob</item>
+ <item>12</item>
+ <item>age</item>
+ </line>
+ </data>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>48</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>RedisOutput</type>
+ <name>Redis output</name>
+ <connection>localhost_standalone</connection>
+ <write_mode>KEY_VALUE</write_mode>
+ <data_structure>HASH</data_structure>
+ <key_codec>STRING</key_codec>
+ <value_codec>STRING</value_codec>
+ <hash_key_codec>STRING</hash_key_codec>
+ <hash_value_codec>STRING</hash_value_codec>
+ <key_field>name</key_field>
+ <value_field>age</value_field>
+ <hash_key_field>type</hash_key_field>
+ <hash_value_field>age</hash_value_field>
+ <ttl_seconds>30</ttl_seconds>
+ <list_push_direction>RPUSH</list_push_direction>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>192</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <order>
+ <hop>
+ <from>Data grid</from>
+ <to>Redis output</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Redis output</from>
+ <to>Write to log</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <notepads/>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/plugins/tech/redis/src/main/samples/transforms/output/redis-output-list.hpl b/plugins/tech/redis/src/main/samples/transforms/output/redis-output-list.hpl
new file mode 100644
index 0000000000..5fa45c7073
--- /dev/null
+++ b/plugins/tech/redis/src/main/samples/transforms/output/redis-output-list.hpl
@@ -0,0 +1,145 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>-1</pipeline_status>
+ <parameters/>
+ <name>New pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/08/01 13:01:09.358</created_date>
+ <modified_date>2026/08/01 13:01:09.358</modified_date>
+ </info>
+ <transform>
+ <type>WriteToLog</type>
+ <name>Write to log</name>
+ <displayHeader>Y</displayHeader>
+ <limitRows>N</limitRows>
+ <limitRowsNumber>0</limitRowsNumber>
+ <logmessage/>
+ <loglevel>Basic</loglevel>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>336</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>DataGrid</type>
+ <name>Data grid</name>
+ <fields>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>name</name>
+ <type>String</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>age</name>
+ <type>Integer</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>bob</item>
+ <item>12</item>
+ </line>
+ </data>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>48</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>RedisOutput</type>
+ <name>Redis output</name>
+ <connection>localhost_standalone</connection>
+ <write_mode>KEY_VALUE</write_mode>
+ <data_structure>LIST</data_structure>
+ <key_codec>STRING</key_codec>
+ <value_codec>STRING</value_codec>
+ <hash_key_codec>STRING</hash_key_codec>
+ <hash_value_codec>STRING</hash_value_codec>
+ <key_field>name</key_field>
+ <value_field>age</value_field>
+ <hash_key_field/>
+ <hash_value_field/>
+ <ttl_seconds>30</ttl_seconds>
+ <list_push_direction>RPUSH</list_push_direction>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>192</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <order>
+ <hop>
+ <from>Data grid</from>
+ <to>Redis output</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Redis output</from>
+ <to>Write to log</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <notepads/>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/plugins/tech/redis/src/main/samples/transforms/output/redis-output-set.hpl b/plugins/tech/redis/src/main/samples/transforms/output/redis-output-set.hpl
new file mode 100644
index 0000000000..87ff90ae8f
--- /dev/null
+++ b/plugins/tech/redis/src/main/samples/transforms/output/redis-output-set.hpl
@@ -0,0 +1,145 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>-1</pipeline_status>
+ <parameters/>
+ <name>New pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/08/01 13:01:09.358</created_date>
+ <modified_date>2026/08/01 13:01:09.358</modified_date>
+ </info>
+ <transform>
+ <type>WriteToLog</type>
+ <name>Write to log</name>
+ <displayHeader>Y</displayHeader>
+ <limitRows>N</limitRows>
+ <limitRowsNumber>0</limitRowsNumber>
+ <logmessage/>
+ <loglevel>Basic</loglevel>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>336</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>DataGrid</type>
+ <name>Data grid</name>
+ <fields>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>name</name>
+ <type>String</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>age</name>
+ <type>Integer</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>bob</item>
+ <item>12</item>
+ </line>
+ </data>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>48</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>RedisOutput</type>
+ <name>Redis output</name>
+ <connection>localhost_standalone</connection>
+ <write_mode>KEY_VALUE</write_mode>
+ <data_structure>SET</data_structure>
+ <key_codec>STRING</key_codec>
+ <value_codec>STRING</value_codec>
+ <hash_key_codec>STRING</hash_key_codec>
+ <hash_value_codec>STRING</hash_value_codec>
+ <key_field>name</key_field>
+ <value_field>age</value_field>
+ <hash_key_field/>
+ <hash_value_field/>
+ <ttl_seconds>10</ttl_seconds>
+ <list_push_direction>RPUSH</list_push_direction>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>192</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <order>
+ <hop>
+ <from>Data grid</from>
+ <to>Redis output</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Redis output</from>
+ <to>Write to log</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <notepads/>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/plugins/tech/redis/src/main/samples/transforms/output/redis-output-stream.hpl b/plugins/tech/redis/src/main/samples/transforms/output/redis-output-stream.hpl
new file mode 100644
index 0000000000..d8a8de9076
--- /dev/null
+++ b/plugins/tech/redis/src/main/samples/transforms/output/redis-output-stream.hpl
@@ -0,0 +1,207 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>-1</pipeline_status>
+ <parameters/>
+ <name>New pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/08/01 13:01:09.358</created_date>
+ <modified_date>2026/08/01 13:01:09.358</modified_date>
+ </info>
+ <transform>
+ <type>WriteToLog</type>
+ <name>Write to log</name>
+ <displayHeader>Y</displayHeader>
+ <limitRows>N</limitRows>
+ <limitRowsNumber>0</limitRowsNumber>
+ <logmessage/>
+ <loglevel>Basic</loglevel>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>336</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>DataGrid</type>
+ <name>Data grid</name>
+ <fields>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>name</name>
+ <type>String</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>age</name>
+ <type>Integer</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>type</name>
+ <type>String</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>json</name>
+ <type>JSON</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>bob</item>
+ <item>12</item>
+ <item>age</item>
+ <item>{"name":"bob","type": "age", "age": 12}</item>
+ </line>
+ </data>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>48</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>RedisOutput</type>
+ <name>Redis output</name>
+ <connection>localhost_standalone</connection>
+ <write_mode>STREAM_FIELDS</write_mode>
+ <data_structure>STRING</data_structure>
+ <key_codec>STRING</key_codec>
+ <value_codec>JSON</value_codec>
+ <hash_key_codec>STRING</hash_key_codec>
+ <hash_value_codec>STRING</hash_value_codec>
+ <key_field>name</key_field>
+ <value_field>json</value_field>
+ <hash_key_field/>
+ <hash_value_field/>
+ <ttl_seconds>30</ttl_seconds>
+ <list_push_direction>RPUSH</list_push_direction>
+ <fields>
+ <field>
+ <stream_field>name</stream_field>
+ <data_structure>SET</data_structure>
+ <key>name_key</key>
+ <key_codec>STRING</key_codec>
+ <hash_key/>
+ <value_codec>STRING</value_codec>
+ <ttl_seconds>0</ttl_seconds>
+ </field>
+ <field>
+ <stream_field>age</stream_field>
+ <data_structure>LIST</data_structure>
+ <key>age_key</key>
+ <key_codec>STRING</key_codec>
+ <hash_key/>
+ <value_codec>BYTE</value_codec>
+ <ttl_seconds>0</ttl_seconds>
+ </field>
+ <field>
+ <stream_field>type</stream_field>
+ <data_structure>HASH</data_structure>
+ <key>type_key</key>
+ <key_codec>STRING</key_codec>
+ <hash_key>no_name</hash_key>
+ <hash_key_codec>STRING</hash_key_codec>
+ <value_codec>JAVA_OBJECT</value_codec>
+ <ttl_seconds>0</ttl_seconds>
+ </field>
+ <field>
+ <stream_field>json</stream_field>
+ <data_structure>STRING</data_structure>
+ <key>json_key</key>
+ <key_codec>STRING</key_codec>
+ <hash_key/>
+ <value_codec>JSON</value_codec>
+ <ttl_seconds>0</ttl_seconds>
+ </field>
+ </fields>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>192</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <order>
+ <hop>
+ <from>Data grid</from>
+ <to>Redis output</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Redis output</from>
+ <to>Write to log</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <notepads/>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/plugins/tech/redis/src/main/samples/transforms/output/redis-output-string.hpl b/plugins/tech/redis/src/main/samples/transforms/output/redis-output-string.hpl
new file mode 100644
index 0000000000..5f78ed47a0
--- /dev/null
+++ b/plugins/tech/redis/src/main/samples/transforms/output/redis-output-string.hpl
@@ -0,0 +1,169 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>-1</pipeline_status>
+ <parameters/>
+ <name>New pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/08/01 13:01:09.358</created_date>
+ <modified_date>2026/08/01 13:01:09.358</modified_date>
+ </info>
+ <transform>
+ <type>WriteToLog</type>
+ <name>Write to log</name>
+ <displayHeader>Y</displayHeader>
+ <limitRows>N</limitRows>
+ <limitRowsNumber>0</limitRowsNumber>
+ <logmessage/>
+ <loglevel>Basic</loglevel>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>336</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>DataGrid</type>
+ <name>Data grid</name>
+ <fields>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>name</name>
+ <type>String</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>age</name>
+ <type>Integer</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>type</name>
+ <type>String</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>json</name>
+ <type>JSON</type>
+ <format/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>bob</item>
+ <item>12</item>
+ <item>age</item>
+ <item>{"name":"bob","type": "age", "age": 12}</item>
+ </line>
+ </data>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>48</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>RedisOutput</type>
+ <name>Redis output</name>
+ <connection>localhost_standalone</connection>
+ <write_mode>KEY_VALUE</write_mode>
+ <data_structure>STRING</data_structure>
+ <key_codec>STRING</key_codec>
+ <value_codec>JSON</value_codec>
+ <hash_key_codec>STRING</hash_key_codec>
+ <hash_value_codec>STRING</hash_value_codec>
+ <key_field>name</key_field>
+ <value_field>json</value_field>
+ <hash_key_field/>
+ <hash_value_field/>
+ <ttl_seconds>30</ttl_seconds>
+ <list_push_direction>RPUSH</list_push_direction>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>192</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <order>
+ <hop>
+ <from>Data grid</from>
+ <to>Redis output</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Redis output</from>
+ <to>Write to log</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <notepads/>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/IRedisCommandsTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/IRedisCommandsTest.java
new file mode 100644
index 0000000000..3aca5731fe
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/IRedisCommandsTest.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import io.lettuce.core.KeyValue;
+import io.lettuce.core.api.sync.RedisCommands;
+import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+class IRedisCommandsTest {
+
+ private static final byte[] KEY = "k".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] VAL = "v".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] FIELD = "f".getBytes(StandardCharsets.UTF_8);
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void standaloneDelegatesCoreCommands() {
+ RedisCommands<byte[], byte[]> lettuce = mock(RedisCommands.class);
+ when(lettuce.ping()).thenReturn("PONG");
+ when(lettuce.get(KEY)).thenReturn(VAL);
+ when(lettuce.set(KEY, VAL)).thenReturn("OK");
+ when(lettuce.hget(KEY, FIELD)).thenReturn(VAL);
+ when(lettuce.hset(KEY, FIELD, VAL)).thenReturn(true);
+ when(lettuce.hset(eq(KEY), any(Map.class))).thenReturn(1L);
+ when(lettuce.smembers(KEY)).thenReturn(Set.of(VAL));
+ when(lettuce.sismember(KEY, VAL)).thenReturn(true);
+ when(lettuce.sadd(KEY, VAL)).thenReturn(1L);
+ when(lettuce.lrange(KEY, 0, -1)).thenReturn(List.of(VAL));
+ when(lettuce.rpush(KEY, VAL)).thenReturn(1L);
+ when(lettuce.lpush(KEY, VAL)).thenReturn(2L);
+ when(lettuce.expire(KEY, 30L)).thenReturn(true);
+ when(lettuce.mget(KEY)).thenReturn(List.of(KeyValue.just(KEY, VAL), KeyValue.empty(KEY)));
+
+ IRedisCommands commands = IRedisCommands.createStandalone(lettuce);
+
+ assertEquals("PONG", commands.ping());
+ assertEquals(VAL, commands.getValue(KEY));
+ assertEquals("OK", commands.setValue(KEY, VAL));
+ assertEquals(VAL, commands.hashGet(KEY, FIELD));
+ assertTrue(commands.hashSet(KEY, FIELD, VAL));
+ assertEquals(1L, commands.hashSet(KEY, Map.of(FIELD, VAL)));
+ assertEquals(Set.of(VAL), commands.setMembers(KEY));
+ assertTrue(commands.isSetMember(KEY, VAL));
+ assertEquals(1L, commands.setAdd(KEY, VAL));
+ assertEquals(List.of(VAL), commands.listRange(KEY, 0, -1));
+ assertEquals(1L, commands.listRightPush(KEY, VAL));
+ assertEquals(2L, commands.listLeftPush(KEY, VAL));
+ assertTrue(commands.expire(KEY, 30L));
+
+ List<IRedisCommands.KeyValue<byte[], byte[]>> multi = commands.multiGet(KEY);
+ assertEquals(2, multi.size());
+ assertEquals(VAL, multi.get(0).value());
+ assertNull(multi.get(1).value());
+
+ Map<byte[], byte[]> hashAll = new HashMap<>();
+ hashAll.put(FIELD, VAL);
+ when(lettuce.hgetall(KEY)).thenReturn(hashAll);
+ assertEquals(hashAll, commands.hashGetAll(KEY));
+ verify(lettuce).hgetall(KEY);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void clusterDelegatesCoreCommands() {
+ RedisAdvancedClusterCommands<byte[], byte[]> lettuce = mock(RedisAdvancedClusterCommands.class);
+ when(lettuce.ping()).thenReturn("PONG");
+ when(lettuce.get(KEY)).thenReturn(VAL);
+ when(lettuce.set(KEY, VAL)).thenReturn("OK");
+ when(lettuce.hget(KEY, FIELD)).thenReturn(VAL);
+ when(lettuce.smembers(KEY)).thenReturn(Set.of(VAL));
+ when(lettuce.expire(KEY, 10L)).thenReturn(true);
+
+ IRedisCommands commands = IRedisCommands.createCluster(lettuce);
+ assertEquals("PONG", commands.ping());
+ assertEquals(VAL, commands.getValue(KEY));
+ assertEquals("OK", commands.setValue(KEY, VAL));
+ assertEquals(VAL, commands.hashGet(KEY, FIELD));
+ assertEquals(Set.of(VAL), commands.setMembers(KEY));
+ assertTrue(commands.expire(KEY, 10L));
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/RedisClientFactoryClusterTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/RedisClientFactoryClusterTest.java
new file mode 100644
index 0000000000..3da9f26e73
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/RedisClientFactoryClusterTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.cluster.models.partitions.Partitions;
+import io.lettuce.core.cluster.models.partitions.RedisClusterNode;
+import io.lettuce.core.cluster.models.partitions.RedisClusterNode.NodeFlag;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.stream.IntStream;
+import org.junit.jupiter.api.Test;
+
+class RedisClientFactoryClusterTest {
+
+ @Test
+ void countCoveredSlotsSumsAssignedSlots() {
+ Partitions partitions = new Partitions();
+ partitions.addPartition(nodeWithSlots(0, 5461, NodeFlag.UPSTREAM));
+ partitions.addPartition(nodeWithSlots(5461, 10923, NodeFlag.UPSTREAM));
+ partitions.addPartition(nodeWithSlots(10923, 16384, NodeFlag.UPSTREAM));
+ assertEquals(16384, RedisClientFactory.countCoveredSlots(partitions));
+ }
+
+ @Test
+ void countCoveredSlotsIgnoresFailedNodes() {
+ Partitions partitions = new Partitions();
+ partitions.addPartition(nodeWithSlots(0, 100, NodeFlag.FAIL));
+ partitions.addPartition(nodeWithSlots(0, 50, NodeFlag.UPSTREAM));
+ assertEquals(50, RedisClientFactory.countCoveredSlots(partitions));
+ }
+
+ @Test
+ void summarizePartitionsHandlesNullAndEmpty() {
+ assertEquals("(null)", RedisClientFactory.summarizePartitions(null));
+ assertEquals("(none)", RedisClientFactory.summarizePartitions(new Partitions()));
+
+ Partitions partitions = new Partitions();
+ RedisClusterNode node = nodeWithSlots(0, 10, NodeFlag.UPSTREAM);
+ node.setUri(RedisURI.create("redis://127.0.0.1:7000"));
+ partitions.addPartition(node);
+ String summary = RedisClientFactory.summarizePartitions(partitions);
+ assertTrue(summary.contains("7000"));
+ assertTrue(summary.contains("slots=10"));
+ }
+
+ @Test
+ void parseNodesSupportsSemicolonAndWhitespace() {
+ List<RedisClientFactory.HostPort> nodes = RedisClientFactory.parseNodes("a:1; b:2\nc:3", 6379);
+ assertEquals(3, nodes.size());
+ assertEquals("a", nodes.get(0).host());
+ assertEquals(1, nodes.get(0).port());
+ assertEquals("b", nodes.get(1).host());
+ assertEquals(2, nodes.get(1).port());
+ assertEquals("c", nodes.get(2).host());
+ assertEquals(3, nodes.get(2).port());
+ }
+
+ private static RedisClusterNode nodeWithSlots(int fromInclusive, int toExclusive, NodeFlag flag) {
+ RedisClusterNode node = new RedisClusterNode();
+ List<Integer> slots = new ArrayList<>();
+ IntStream.range(fromInclusive, toExclusive).forEach(slots::add);
+ node.setSlots(slots);
+ node.setFlags(EnumSet.of(flag));
+ return node;
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/RedisClientFactoryTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/RedisClientFactoryTest.java
new file mode 100644
index 0000000000..0262350fca
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/RedisClientFactoryTest.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class RedisClientFactoryTest {
+
+ @Test
+ void parseNodesSupportsCommaAndDefaultPort() {
+ List<RedisClientFactory.HostPort> nodes =
+ RedisClientFactory.parseNodes("localhost:6380, 127.0.0.1, host2:7000", 6379);
+ assertEquals(3, nodes.size());
+ assertEquals("localhost", nodes.get(0).host());
+ assertEquals(6380, nodes.get(0).port());
+ assertEquals("127.0.0.1", nodes.get(1).host());
+ assertEquals(6379, nodes.get(1).port());
+ assertEquals("host2", nodes.get(2).host());
+ assertEquals(7000, nodes.get(2).port());
+ }
+
+ @Test
+ void parseNodesEmpty() {
+ assertTrue(RedisClientFactory.parseNodes(null, 6379).isEmpty());
+ assertTrue(RedisClientFactory.parseNodes(" ", 6379).isEmpty());
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/RedisClientSessionTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/RedisClientSessionTest.java
new file mode 100644
index 0000000000..6c27cf4447
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/client/RedisClientSessionTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.client;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import io.lettuce.core.AbstractRedisClient;
+import io.lettuce.core.api.StatefulRedisConnection;
+import io.lettuce.core.codec.ByteArrayCodec;
+import org.apache.commons.pool2.impl.GenericObjectPool;
+import org.junit.jupiter.api.Test;
+
+class RedisClientSessionTest {
+
+ @Test
+ void codecReturnsByteArrayInstance() {
+ assertSame(ByteArrayCodec.INSTANCE, RedisClientSession.codec());
+ }
+
+ @Test
+ void closeWithoutPoolClosesConnectionAndShutsDownClient() {
+ AbstractRedisClient client = mock(AbstractRedisClient.class);
+ @SuppressWarnings("unchecked")
+ StatefulRedisConnection<byte[], byte[]> connection = mock(StatefulRedisConnection.class);
+ IRedisCommands commands = mock(IRedisCommands.class);
+
+ try (RedisClientSession session = new RedisClientSession(client, connection, commands)) {
+ assertSame(commands, session.getCommands());
+ }
+
+ verify(connection).close();
+ verify(client).shutdown();
+ }
+
+ @Test
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ void closeWithPoolReturnsObjectAndClosesPool() throws Exception {
+ AbstractRedisClient client = mock(AbstractRedisClient.class);
+ StatefulRedisConnection<byte[], byte[]> connection = mock(StatefulRedisConnection.class);
+ IRedisCommands commands = mock(IRedisCommands.class);
+ GenericObjectPool pool = mock(GenericObjectPool.class);
+
+ try (RedisClientSession session = new RedisClientSession(client, connection, commands, pool)) {
+ // closed by try-with-resources
+ }
+
+ verify(pool).returnObject(connection);
+ verify(pool).close();
+ verify(connection, never()).close();
+ verify(client).shutdown();
+ }
+
+ @Test
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ void closeStillShutsDownClientWhenReturnObjectFails() throws Exception {
+ AbstractRedisClient client = mock(AbstractRedisClient.class);
+ StatefulRedisConnection<byte[], byte[]> connection = mock(StatefulRedisConnection.class);
+ IRedisCommands commands = mock(IRedisCommands.class);
+ GenericObjectPool pool = mock(GenericObjectPool.class);
+ doThrow(new RuntimeException("return failed")).when(pool).returnObject(connection);
+
+ new RedisClientSession(client, connection, commands, pool).close();
+
+ verify(pool).close();
+ verify(client).shutdown();
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/codec/RedisCodecTypeTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/codec/RedisCodecTypeTest.java
new file mode 100644
index 0000000000..c1a783f978
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/codec/RedisCodecTypeTest.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.codec;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class RedisCodecTypeTest {
+
+ @Test
+ void getNamesAndFromCode() {
+ assertArrayEquals(
+ new String[] {"STRING", "JAVA_OBJECT", "JSON", "BYTE"}, RedisCodecType.getNames());
+ assertEquals(RedisCodecType.JSON, RedisCodecType.fromCode("json"));
+ assertEquals(RedisCodecType.BYTE, RedisCodecType.fromCode(" BYTE "));
+ assertEquals(RedisCodecType.STRING, RedisCodecType.fromCode(null));
+ assertEquals(RedisCodecType.STRING, RedisCodecType.fromCode(""));
+ assertEquals(RedisCodecType.STRING, RedisCodecType.fromCode("unknown"));
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/codec/RedisCodecsTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/codec/RedisCodecsTest.java
new file mode 100644
index 0000000000..047e91017b
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/codec/RedisCodecsTest.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.codec;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+import org.apache.hop.core.exception.HopException;
+import org.junit.jupiter.api.Test;
+
+class RedisCodecsTest {
+
+ @Test
+ void stringRoundTrip() throws Exception {
+ RedisValueCodec codec = RedisCodecs.create(RedisCodecType.STRING);
+ byte[] encoded = codec.encode("hello");
+ assertEquals("hello", codec.decode(encoded));
+ }
+
+ @Test
+ void jsonRoundTrip() throws Exception {
+ RedisValueCodec codec = RedisCodecs.create(RedisCodecType.JSON);
+ byte[] encoded = codec.encode(Map.of("a", 1));
+ Object decoded = codec.decode(encoded);
+ assertNotNull(decoded);
+ assertTrue(decoded.toString().contains("\"a\""));
+ assertTrue(decoded.toString().contains("1"));
+ }
+
+ @Test
+ void javaObjectRoundTrip() throws Exception {
+ RedisValueCodec codec = RedisCodecs.create(RedisCodecType.JAVA_OBJECT);
+ byte[] encoded = codec.encode("serializable-string");
+ assertEquals("serializable-string", codec.decode(encoded));
+ }
+
+ @Test
+ void byteCodecDecodesToBytes() throws Exception {
+ RedisValueCodec codec = RedisCodecs.create(RedisCodecType.BYTE);
+ byte[] raw = "abc".getBytes(StandardCharsets.UTF_8);
+ assertArrayEquals(raw, (byte[]) codec.decode(raw));
+ byte[] fromBase64 = codec.encode(Base64.getEncoder().encodeToString(raw));
+ assertArrayEquals(raw, fromBase64);
+ }
+
+ @Test
+ void byteCodecDoesNotTreatShortNumbersAsBase64() throws Exception {
+ RedisValueCodec codec = RedisCodecs.create(RedisCodecType.BYTE);
+ // "12" is technically Base64 for 0xD7 — must store as UTF-8 decimal text instead
+ assertArrayEquals("12".getBytes(StandardCharsets.UTF_8), codec.encode(12));
+ assertArrayEquals("12".getBytes(StandardCharsets.UTF_8), codec.encode(12L));
+ assertArrayEquals("12".getBytes(StandardCharsets.UTF_8), codec.encode("12"));
+ }
+
+ @Test
+ void holdersExposeFourCodecs() {
+ RedisCodecs codecs =
+ RedisCodecs.of(
+ RedisCodecType.STRING,
+ RedisCodecType.JSON,
+ RedisCodecType.BYTE,
+ RedisCodecType.JAVA_OBJECT);
+ assertInstanceOf(RedisCodecs.StringCodec.class, codecs.key());
+ assertInstanceOf(RedisCodecs.JsonCodec.class, codecs.value());
+ assertInstanceOf(RedisCodecs.ByteCodec.class, codecs.hashKey());
+ assertInstanceOf(RedisCodecs.JavaObjectCodec.class, codecs.hashValue());
+ }
+
+ @Test
+ void ofNullTypesDefaultsToStringCodecs() {
+ RedisCodecs codecs = RedisCodecs.of(null, null, null, null);
+ assertInstanceOf(RedisCodecs.StringCodec.class, codecs.key());
+ assertInstanceOf(RedisCodecs.StringCodec.class, codecs.value());
+ assertInstanceOf(RedisCodecs.StringCodec.class, codecs.hashKey());
+ assertInstanceOf(RedisCodecs.StringCodec.class, codecs.hashValue());
+ }
+
+ @Test
+ void stringCodecEncodesNullAsEmptyBytes() throws Exception {
+ RedisValueCodec codec = RedisCodecs.create(RedisCodecType.STRING);
+ assertArrayEquals(new byte[0], codec.encode(null));
+ assertEquals("", codec.decode(new byte[0]));
+ }
+
+ @Test
+ void byteCodecAcceptsValidBase64() throws Exception {
+ RedisValueCodec codec = RedisCodecs.create(RedisCodecType.BYTE);
+ byte[] raw = new byte[] {1, 2, 3};
+ String b64 = Base64.getEncoder().encodeToString(raw);
+ assertTrue(b64.length() >= 4 && b64.length() % 4 == 0);
+ assertArrayEquals(raw, codec.encode(b64));
+ }
+
+ @Test
+ void jsonDecodeInvalidBytesThrows() {
+ RedisValueCodec codec = RedisCodecs.create(RedisCodecType.JSON);
+ assertThrows(
+ HopException.class, () -> codec.decode("not-json".getBytes(StandardCharsets.UTF_8)));
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/metadata/RedisConnectionTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/metadata/RedisConnectionTest.java
new file mode 100644
index 0000000000..f8bc1c27c9
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/metadata/RedisConnectionTest.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.metadata;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class RedisConnectionTest {
+
+ @Test
+ void defaults() {
+ RedisConnection connection = new RedisConnection();
+ assertEquals(RedisDeploymentMode.STANDALONE, connection.getDeploymentMode());
+ assertEquals("localhost", connection.getHostname());
+ assertEquals("6379", connection.getPort());
+ assertEquals("0", connection.getDatabase());
+ assertEquals("10000", connection.getTimeoutMs());
+ assertFalse(connection.isUseSsl());
+ assertFalse(connection.isEnablePooling());
+ assertEquals("8", connection.getPoolMaxTotal());
+ assertEquals("8", connection.getPoolMaxIdle());
+ assertEquals("0", connection.getPoolMinIdle());
+ assertEquals("-1", connection.getPoolMaxWaitMs());
+ }
+
+ @Test
+ void copyConstructorCopiesFields() {
+ RedisConnection source = new RedisConnection();
+ source.setName("prod");
+ source.setDeploymentMode(RedisDeploymentMode.CLUSTER);
+ source.setHostname("192.168.1.1");
+ source.setPort("7000");
+ source.setClusterNodes("192.168.1.1:7000");
+ source.setPassword("secret");
+ source.setUseSsl(true);
+ source.setEnablePooling(true);
+ source.setPoolMaxTotal("16");
+
+ RedisConnection copy = new RedisConnection(source);
+ assertEquals("prod", copy.getName());
+ assertEquals(RedisDeploymentMode.CLUSTER, copy.getDeploymentMode());
+ assertEquals("192.168.1.1", copy.getHostname());
+ assertEquals("7000", copy.getPort());
+ assertEquals("192.168.1.1:7000", copy.getClusterNodes());
+ assertEquals("secret", copy.getPassword());
+ assertTrue(copy.isUseSsl());
+ assertTrue(copy.isEnablePooling());
+ assertEquals("16", copy.getPoolMaxTotal());
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/metadata/RedisDeploymentModeTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/metadata/RedisDeploymentModeTest.java
new file mode 100644
index 0000000000..db1e065a12
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/metadata/RedisDeploymentModeTest.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.metadata;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+
+class RedisDeploymentModeTest {
+
+ @Test
+ void testGetNames() {
+ String[] names = RedisDeploymentMode.getNames();
+ assertNotNull(names);
+ assertEquals(3, names.length);
+ assertEquals("STANDALONE", names[0]);
+ assertEquals("SENTINEL", names[1]);
+ assertEquals("CLUSTER", names[2]);
+ }
+
+ @Test
+ void testFromCode() {
+ assertEquals(RedisDeploymentMode.STANDALONE, RedisDeploymentMode.fromCode("STANDALONE"));
+ assertEquals(RedisDeploymentMode.SENTINEL, RedisDeploymentMode.fromCode("sentinel"));
+ assertEquals(RedisDeploymentMode.CLUSTER, RedisDeploymentMode.fromCode("CLUSTER"));
+ assertEquals(RedisDeploymentMode.STANDALONE, RedisDeploymentMode.fromCode(null));
+ assertEquals(RedisDeploymentMode.STANDALONE, RedisDeploymentMode.fromCode("nope"));
+ // blank string is not treated as null by fromCode (trim + valueOf fails → STANDALONE)
+ assertEquals(RedisDeploymentMode.STANDALONE, RedisDeploymentMode.fromCode(" "));
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/RedisDataStructureTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/RedisDataStructureTest.java
new file mode 100644
index 0000000000..c81516ee3c
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/RedisDataStructureTest.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class RedisDataStructureTest {
+
+ @Test
+ void getNamesReturnsAllValues() {
+ assertArrayEquals(
+ new String[] {"STRING", "HASH", "SET", "LIST"}, RedisDataStructure.getNames());
+ }
+
+ @Test
+ void fromCodeParsesAndFallsBack() {
+ assertEquals(RedisDataStructure.HASH, RedisDataStructure.fromCode("hash"));
+ assertEquals(RedisDataStructure.LIST, RedisDataStructure.fromCode(" LIST "));
+ assertEquals(RedisDataStructure.STRING, RedisDataStructure.fromCode(null));
+ assertEquals(RedisDataStructure.STRING, RedisDataStructure.fromCode(" "));
+ assertEquals(RedisDataStructure.STRING, RedisDataStructure.fromCode("unknown"));
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/RedisListPushDirectionTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/RedisListPushDirectionTest.java
new file mode 100644
index 0000000000..76b4044fcf
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/RedisListPushDirectionTest.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class RedisListPushDirectionTest {
+
+ @Test
+ void fromCodeParsesAndFallsBackToRpush() {
+ assertEquals(RedisListPushDirection.LPUSH, RedisListPushDirection.fromCode("lpush"));
+ assertEquals(RedisListPushDirection.RPUSH, RedisListPushDirection.fromCode("RPUSH"));
+ assertEquals(RedisListPushDirection.RPUSH, RedisListPushDirection.fromCode(null));
+ assertEquals(RedisListPushDirection.RPUSH, RedisListPushDirection.fromCode(""));
+ assertEquals(RedisListPushDirection.RPUSH, RedisListPushDirection.fromCode("nope"));
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputDataTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputDataTest.java
new file mode 100644
index 0000000000..d5002a843e
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputDataTest.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.redis.transforms.redisinput.RedisInputData.Mapping;
+import org.junit.jupiter.api.Test;
+
+class RedisInputDataTest {
+
+ @Test
+ void defaultsAndAccessors() {
+ RedisInputData data = new RedisInputData();
+ assertNull(data.getOutputRowMeta());
+ assertNull(data.getSession());
+ assertNull(data.getMappings());
+ assertEquals(0, data.getFirstValueIndex());
+
+ RowMeta rowMeta = new RowMeta();
+ Mapping mapping = new Mapping();
+ Mapping[] mappings = new Mapping[] {mapping};
+ data.setOutputRowMeta(rowMeta);
+ data.setMappings(mappings);
+ data.setFirstValueIndex(3);
+
+ assertSame(rowMeta, data.getOutputRowMeta());
+ assertSame(mappings, data.getMappings());
+ assertEquals(3, data.getFirstValueIndex());
+ }
+
+ @Test
+ void mappingDefaults() {
+ Mapping mapping = new Mapping();
+ assertEquals(-1, mapping.getKeyFieldIndex());
+ assertEquals(-1, mapping.getHashFieldIndex());
+ assertEquals(0, mapping.getListStart());
+ assertEquals(0, mapping.getListStop());
+ assertNull(mapping.getStructure());
+
+ mapping.setStructure(RedisDataStructure.LIST);
+ mapping.setListStart(1);
+ mapping.setListStop(10);
+ assertEquals(RedisDataStructure.LIST, mapping.getStructure());
+ assertEquals(1, mapping.getListStart());
+ assertEquals(10, mapping.getListStop());
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputFieldTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputFieldTest.java
new file mode 100644
index 0000000000..f0a406446b
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputFieldTest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.junit.jupiter.api.Test;
+
+class RedisInputFieldTest {
+
+ @Test
+ void resolveHelpersUseDefaults() {
+ RedisInputField field = new RedisInputField();
+ field.setDataStructure(null);
+ field.setListStart(null);
+ field.setListStop("");
+ assertEquals(RedisDataStructure.STRING, field.resolveDataStructure());
+ assertEquals("0", field.resolveListStart());
+ assertEquals("-1", field.resolveListStop());
+ }
+
+ @Test
+ void cloneCopiesFields() {
+ RedisInputField field = new RedisInputField();
+ field.setRedisKey("k");
+ field.setValueField("v");
+ field.setDataStructure(RedisDataStructure.SET);
+ field.setListStart("1");
+ field.setListStop("5");
+
+ RedisInputField copy = field.clone();
+ assertEquals("k", copy.getRedisKey());
+ assertEquals("v", copy.getValueField());
+ assertEquals(RedisDataStructure.SET, copy.getDataStructure());
+ assertEquals("1", copy.getListStart());
+ assertEquals("5", copy.getListStop());
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputMetaDefaultsTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputMetaDefaultsTest.java
new file mode 100644
index 0000000000..0b41635792
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputMetaDefaultsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class RedisInputMetaDefaultsTest {
+
+ @Test
+ void setDefaultClearsConnectionAndFields() {
+ RedisInputMeta meta = new RedisInputMeta();
+ meta.setConnectionName("redis1");
+ meta.setFields(List.of(new RedisInputField()));
+ meta.setDefault();
+ assertNull(meta.getConnectionName());
+ assertNotNull(meta.getFields());
+ assertTrue(meta.getFields().isEmpty());
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputMetaFieldsTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputMetaFieldsTest.java
new file mode 100644
index 0000000000..a28ca0a11d
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputMetaFieldsTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+import java.util.List;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaBinary;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class RedisInputMetaFieldsTest {
+
+ @BeforeAll
+ static void initEnv() throws Exception {
+ HopEnvironment.init();
+ }
+
+ @AfterAll
+ static void resetEnv() {
+ HopEnvironment.reset();
+ }
+
+ @Test
+ void getFieldsUsesBinaryForByteStringAndHash() throws Exception {
+ RedisInputMeta meta = new RedisInputMeta();
+ RedisInputField stringByte = field("k1", "v1", RedisDataStructure.STRING, RedisCodecType.BYTE);
+ RedisInputField hashByte = field("k2", "v2", RedisDataStructure.HASH, RedisCodecType.BYTE);
+ meta.setFields(List.of(stringByte, hashByte));
+
+ RowMeta rowMeta = new RowMeta();
+ meta.getFields(rowMeta, "RedisInput", null, null, new Variables(), null);
+
+ assertEquals(2, rowMeta.size());
+ assertInstanceOf(ValueMetaBinary.class, rowMeta.getValueMeta(0));
+ assertInstanceOf(ValueMetaBinary.class, rowMeta.getValueMeta(1));
+ assertEquals(IValueMeta.TYPE_BINARY, rowMeta.getValueMeta(0).getType());
+ }
+
+ @Test
+ void getFieldsKeepsStringForSetAndListEvenWithByteCodec() throws Exception {
+ RedisInputMeta meta = new RedisInputMeta();
+ meta.setFields(
+ List.of(
+ field("k1", "setOut", RedisDataStructure.SET, RedisCodecType.BYTE),
+ field("k2", "listOut", RedisDataStructure.LIST, RedisCodecType.BYTE)));
+
+ RowMeta rowMeta = new RowMeta();
+ meta.getFields(rowMeta, "RedisInput", null, null, new Variables(), null);
+
+ assertEquals(2, rowMeta.size());
+ assertInstanceOf(ValueMetaString.class, rowMeta.getValueMeta(0));
+ assertInstanceOf(ValueMetaString.class, rowMeta.getValueMeta(1));
+ }
+
+ private static RedisInputField field(
+ String key, String valueField, RedisDataStructure structure, RedisCodecType valueCodec) {
+ RedisInputField field = new RedisInputField();
+ field.setRedisKey(key);
+ field.setValueField(valueField);
+ field.setDataStructure(structure);
+ field.setValueCodec(valueCodec);
+ return field;
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputMetaTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputMetaTest.java
new file mode 100644
index 0000000000..686bc66576
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputMetaTest.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Random;
+import java.util.UUID;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.pipeline.transforms.loadsave.LoadSaveTester;
+import org.apache.hop.pipeline.transforms.loadsave.validator.IFieldLoadSaveValidator;
+import org.apache.hop.pipeline.transforms.loadsave.validator.ListLoadSaveValidator;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.junit.jupiter.api.Test;
+
+class RedisInputMetaTest {
+
+ @Test
+ void testXmlRoundTrip() throws HopException {
+ List<String> attributes = Arrays.asList("connectionName", "fields");
+
+ Map<String, String> getterMap = new HashMap<>();
+ Map<String, String> setterMap = new HashMap<>();
+
+ Map<String, IFieldLoadSaveValidator<?>> attrValidators = new HashMap<>();
+ attrValidators.put("fields", new ListLoadSaveValidator<>(new RedisInputFieldValidator(), 3));
+
+ Map<String, IFieldLoadSaveValidator<?>> typeValidators = new HashMap<>();
+
+ LoadSaveTester tester =
+ new LoadSaveTester(
+ RedisInputMeta.class, attributes, getterMap, setterMap, attrValidators, typeValidators);
+ tester.testXmlRoundTrip();
+ }
+
+ private static final class RedisInputFieldValidator
+ implements IFieldLoadSaveValidator<RedisInputField> {
+ private final Random random = new Random();
+
+ @Override
+ public RedisInputField getTestObject() {
+ RedisCodecType[] codecs = RedisCodecType.values();
+ RedisDataStructure[] structures = RedisDataStructure.values();
+ RedisInputField field = new RedisInputField();
+ field.setRedisKey("key_" + UUID.randomUUID());
+ field.setRedisKeyCodec(codecs[random.nextInt(codecs.length)]);
+ field.setDataStructure(structures[random.nextInt(structures.length)]);
+ field.setHashField(random.nextBoolean() ? "hash_" + UUID.randomUUID() : "");
+ field.setHashFieldCodec(random.nextBoolean() ? codecs[random.nextInt(codecs.length)] : null);
+ field.setValueField("value_" + UUID.randomUUID());
+ field.setValueCodec(codecs[random.nextInt(codecs.length)]);
+ field.setListStart(random.nextBoolean() ? "0" : String.valueOf(random.nextInt(10)));
+ field.setListStop(random.nextBoolean() ? "-1" : String.valueOf(random.nextInt(10)));
+ return field;
+ }
+
+ @Override
+ public boolean validateTestObject(RedisInputField testObject, Object actual) {
+ if (!(actual instanceof RedisInputField)) {
+ return false;
+ }
+ RedisInputField other = (RedisInputField) actual;
+ return Objects.equals(testObject.getRedisKey(), other.getRedisKey())
+ && testObject.getRedisKeyCodec() == other.getRedisKeyCodec()
+ && testObject.getDataStructure() == other.getDataStructure()
+ && Objects.equals(testObject.getHashField(), other.getHashField())
+ && testObject.getHashFieldCodec() == other.getHashFieldCodec()
+ && Objects.equals(testObject.getValueField(), other.getValueField())
+ && testObject.getValueCodec() == other.getValueCodec()
+ && Objects.equals(testObject.getListStart(), other.getListStart())
+ && Objects.equals(testObject.getListStop(), other.getListStop());
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputTest.java
new file mode 100644
index 0000000000..35c79c4361
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisinput/RedisInputTest.java
@@ -0,0 +1,288 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisinput;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Set;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.apache.hop.redis.client.IRedisCommands;
+import org.apache.hop.redis.client.RedisClientSession;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.codec.RedisCodecs;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.redis.transforms.redisinput.RedisInputData.Mapping;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+class RedisInputTest {
+
+ private TransformMockHelper<RedisInputMeta, RedisInputData> mockHelper;
+ private IRedisCommands commands;
+ private RedisClientSession session;
+
+ @BeforeAll
+ static void initEnv() throws Exception {
+ HopEnvironment.init();
+ }
+
+ @AfterAll
+ static void resetEnv() {
+ HopEnvironment.reset();
+ }
+
+ @BeforeEach
+ void setUp() {
+ mockHelper =
+ new TransformMockHelper<>("RedisInput", RedisInputMeta.class, RedisInputData.class);
+ when(mockHelper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+ .thenReturn(mockHelper.iLogChannel);
+ commands = mock(IRedisCommands.class);
+ session = mock(RedisClientSession.class);
+ when(session.getCommands()).thenReturn(commands);
+ }
+
+ @AfterEach
+ void tearDown() {
+ mockHelper.cleanUp();
+ }
+
+ @Test
+ void toJsonArrayElementKeepsTextAndBase64EncodesBinary() {
+ assertEquals("hello", RedisInput.toJsonArrayElement("hello"));
+ assertEquals("12", RedisInput.toJsonArrayElement("12".getBytes(StandardCharsets.UTF_8)));
+ byte[] binary = new byte[] {(byte) 0xFF, 0x00};
+ assertEquals(Base64.getEncoder().encodeToString(binary), RedisInput.toJsonArrayElement(binary));
+ }
+
+ @Test
+ void processRowReadsStringFromFieldKey() throws Exception {
+ RedisInputMeta meta = new RedisInputMeta();
+ RedisInputData data = new RedisInputData();
+ RedisInput transform = spy(createTransform(meta, data));
+
+ IRowMeta inputMeta = new RowMeta();
+ inputMeta.addValueMeta(new ValueMetaString("redisKey"));
+ IRowMeta outputMeta = inputMeta.clone();
+ outputMeta.addValueMeta(new ValueMetaString("outValue"));
+
+ Mapping mapping = stringMapping(0, 1);
+ data.setSession(session);
+ data.setMappings(new Mapping[] {mapping});
+ data.setOutputRowMeta(outputMeta);
+ transform.first = false;
+
+ byte[] keyBytes = "user:1".getBytes(StandardCharsets.UTF_8);
+ when(commands.getValue(any(byte[].class))).thenReturn("alice".getBytes(StandardCharsets.UTF_8));
+
+ doReturn(inputMeta).when(transform).getInputRowMeta();
+ doReturn(new Object[] {"user:1"}).doReturn(null).when(transform).getRow();
+ ArgumentCaptor<Object[]> outCaptor = ArgumentCaptor.forClass(Object[].class);
+ doAnswer(inv -> null).when(transform).putRow(any(), outCaptor.capture());
+
+ assertTrue(transform.processRow());
+ assertArrayEquals(keyBytes, captureKey(commands));
+ assertEquals("alice", outCaptor.getValue()[1]);
+ assertFalse(transform.processRow());
+ }
+
+ @Test
+ void processRowReadsHashWithLiteralKey() throws Exception {
+ RedisInputMeta meta = new RedisInputMeta();
+ RedisInputData data = new RedisInputData();
+ RedisInput transform = spy(createTransform(meta, data));
+
+ IRowMeta inputMeta = new RowMeta();
+ inputMeta.addValueMeta(new ValueMetaString("unused"));
+ IRowMeta outputMeta = inputMeta.clone();
+ outputMeta.addValueMeta(new ValueMetaString("email"));
+
+ Mapping mapping = new Mapping();
+ mapping.setStructure(RedisDataStructure.HASH);
+ mapping.setKeyLiteral("profile");
+ mapping.setKeyCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setHashFieldLiteral("email");
+ mapping.setHashFieldCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setValueCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setValueOutputIndex(1);
+
+ data.setSession(session);
+ data.setMappings(new Mapping[] {mapping});
+ data.setOutputRowMeta(outputMeta);
+ transform.first = false;
+
+ when(commands.hashGet(any(byte[].class), any(byte[].class)))
+ .thenReturn("a@example.com".getBytes(StandardCharsets.UTF_8));
+ doReturn(inputMeta).when(transform).getInputRowMeta();
+ doReturn("profile").when(transform).resolve("profile");
+ doReturn("email").when(transform).resolve("email");
+ doReturn(new Object[] {"x"}).when(transform).getRow();
+ ArgumentCaptor<Object[]> outCaptor = ArgumentCaptor.forClass(Object[].class);
+ doAnswer(inv -> null).when(transform).putRow(any(), outCaptor.capture());
+
+ assertTrue(transform.processRow());
+ verify(commands)
+ .hashGet(
+ eq("profile".getBytes(StandardCharsets.UTF_8)),
+ eq("email".getBytes(StandardCharsets.UTF_8)));
+ assertEquals("a@example.com", outCaptor.getValue()[1]);
+ }
+
+ @Test
+ void processRowReadsSetAsJsonArray() throws Exception {
+ RedisInputMeta meta = new RedisInputMeta();
+ RedisInputData data = new RedisInputData();
+ RedisInput transform = spy(createTransform(meta, data));
+
+ IRowMeta inputMeta = new RowMeta();
+ inputMeta.addValueMeta(new ValueMetaString("k"));
+ IRowMeta outputMeta = inputMeta.clone();
+ outputMeta.addValueMeta(new ValueMetaString("members"));
+
+ Mapping mapping = new Mapping();
+ mapping.setStructure(RedisDataStructure.SET);
+ mapping.setKeyFieldIndex(0);
+ mapping.setKeyCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setValueCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setValueOutputIndex(1);
+
+ data.setSession(session);
+ data.setMappings(new Mapping[] {mapping});
+ data.setOutputRowMeta(outputMeta);
+ transform.first = false;
+
+ when(commands.setMembers(any(byte[].class)))
+ .thenReturn(Set.of("bob".getBytes(StandardCharsets.UTF_8)));
+ doReturn(inputMeta).when(transform).getInputRowMeta();
+ doReturn(new Object[] {"team"}).when(transform).getRow();
+ ArgumentCaptor<Object[]> outCaptor = ArgumentCaptor.forClass(Object[].class);
+ doAnswer(inv -> null).when(transform).putRow(any(), outCaptor.capture());
+
+ assertTrue(transform.processRow());
+ assertEquals("[\"bob\"]", outCaptor.getValue()[1]);
+ }
+
+ @Test
+ void processRowReadsListRangeAsJsonArray() throws Exception {
+ RedisInputMeta meta = new RedisInputMeta();
+ RedisInputData data = new RedisInputData();
+ RedisInput transform = spy(createTransform(meta, data));
+
+ IRowMeta inputMeta = new RowMeta();
+ inputMeta.addValueMeta(new ValueMetaString("k"));
+ IRowMeta outputMeta = inputMeta.clone();
+ outputMeta.addValueMeta(new ValueMetaString("items"));
+
+ Mapping mapping = new Mapping();
+ mapping.setStructure(RedisDataStructure.LIST);
+ mapping.setKeyFieldIndex(0);
+ mapping.setKeyCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setValueCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setValueOutputIndex(1);
+ mapping.setListStart(0);
+ mapping.setListStop(1);
+
+ data.setSession(session);
+ data.setMappings(new Mapping[] {mapping});
+ data.setOutputRowMeta(outputMeta);
+ transform.first = false;
+
+ when(commands.listRange(any(byte[].class), eq(0L), eq(1L)))
+ .thenReturn(
+ List.of("a".getBytes(StandardCharsets.UTF_8), "b".getBytes(StandardCharsets.UTF_8)));
+ doReturn(inputMeta).when(transform).getInputRowMeta();
+ doReturn(new Object[] {"queue"}).when(transform).getRow();
+ ArgumentCaptor<Object[]> outCaptor = ArgumentCaptor.forClass(Object[].class);
+ doAnswer(inv -> null).when(transform).putRow(any(), outCaptor.capture());
+
+ assertTrue(transform.processRow());
+ verify(commands).listRange(any(byte[].class), eq(0L), eq(1L));
+ assertEquals("[\"a\",\"b\"]", outCaptor.getValue()[1]);
+ }
+
+ @Test
+ void processRowReturnsNullWhenStringMissing() throws Exception {
+ RedisInputMeta meta = new RedisInputMeta();
+ RedisInputData data = new RedisInputData();
+ RedisInput transform = spy(createTransform(meta, data));
+
+ IRowMeta inputMeta = new RowMeta();
+ inputMeta.addValueMeta(new ValueMetaString("k"));
+ IRowMeta outputMeta = inputMeta.clone();
+ outputMeta.addValueMeta(new ValueMetaString("v"));
+
+ data.setSession(session);
+ data.setMappings(new Mapping[] {stringMapping(0, 1)});
+ data.setOutputRowMeta(outputMeta);
+ transform.first = false;
+
+ when(commands.getValue(any(byte[].class))).thenReturn(null);
+ doReturn(inputMeta).when(transform).getInputRowMeta();
+ doReturn(new Object[] {"missing"}).when(transform).getRow();
+ ArgumentCaptor<Object[]> outCaptor = ArgumentCaptor.forClass(Object[].class);
+ doAnswer(inv -> null).when(transform).putRow(any(), outCaptor.capture());
+
+ assertTrue(transform.processRow());
+ assertNull(outCaptor.getValue()[1]);
+ }
+
+ private RedisInput createTransform(RedisInputMeta meta, RedisInputData data) {
+ return new RedisInput(
+ mockHelper.transformMeta, meta, data, 0, mockHelper.pipelineMeta, mockHelper.pipeline);
+ }
+
+ private static Mapping stringMapping(int keyFieldIndex, int valueOutputIndex) {
+ Mapping mapping = new Mapping();
+ mapping.setStructure(RedisDataStructure.STRING);
+ mapping.setKeyFieldIndex(keyFieldIndex);
+ mapping.setKeyCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setValueCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setValueOutputIndex(valueOutputIndex);
+ return mapping;
+ }
+
+ private static byte[] captureKey(IRedisCommands commands) {
+ ArgumentCaptor<byte[]> keyCaptor = ArgumentCaptor.forClass(byte[].class);
+ verify(commands).getValue(keyCaptor.capture());
+ return keyCaptor.getValue();
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputDataTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputDataTest.java
new file mode 100644
index 0000000000..31e5347f51
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputDataTest.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.codec.RedisCodecs;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.redis.transforms.redisoutput.RedisOutputData.StreamMapping;
+import org.junit.jupiter.api.Test;
+
+class RedisOutputDataTest {
+
+ @Test
+ void defaultsAndAccessors() {
+ RedisOutputData data = new RedisOutputData();
+ assertEquals(-1, data.getKeyFieldIndex());
+ assertEquals(-1, data.getValueFieldIndex());
+ assertEquals(-1, data.getHashKeyFieldIndex());
+ assertEquals(-1, data.getHashValueFieldIndex());
+ assertNull(data.getTtlSeconds());
+ assertNull(data.getStreamMappings());
+
+ RedisCodecs codecs =
+ RedisCodecs.of(
+ RedisCodecType.STRING,
+ RedisCodecType.STRING,
+ RedisCodecType.STRING,
+ RedisCodecType.STRING);
+ data.setCodecs(codecs);
+ data.setKeyFieldIndex(0);
+ data.setTtlSeconds(60L);
+ assertSame(codecs, data.getCodecs());
+ assertEquals(0, data.getKeyFieldIndex());
+ assertEquals(60L, data.getTtlSeconds());
+ }
+
+ @Test
+ void streamMappingDefaults() {
+ StreamMapping mapping = new StreamMapping();
+ assertEquals(-1, mapping.getKeyFieldIndex());
+ assertEquals(-1, mapping.getHashKeyFieldIndex());
+ assertNull(mapping.getTtlSeconds());
+
+ mapping.setStructure(RedisDataStructure.HASH);
+ mapping.setStreamFieldIndex(2);
+ mapping.setTtlSeconds(15L);
+ assertEquals(RedisDataStructure.HASH, mapping.getStructure());
+ assertEquals(2, mapping.getStreamFieldIndex());
+ assertEquals(15L, mapping.getTtlSeconds());
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputFieldTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputFieldTest.java
new file mode 100644
index 0000000000..d87411fad2
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputFieldTest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.junit.jupiter.api.Test;
+
+class RedisOutputFieldTest {
+
+ @Test
+ void resolveKeyPrefersKeyThenRedisNameThenStreamField() {
+ RedisOutputField field = new RedisOutputField();
+ field.setStreamField("stream");
+ assertEquals("stream", field.resolveKey());
+
+ field.setRedisName("legacy");
+ assertEquals("legacy", field.resolveKey());
+
+ field.setKey("explicit");
+ assertEquals("explicit", field.resolveKey());
+ }
+
+ @Test
+ void resolveDataStructureDefaultsToString() {
+ RedisOutputField field = new RedisOutputField();
+ field.setDataStructure(null);
+ assertEquals(RedisDataStructure.STRING, field.resolveDataStructure());
+ }
+
+ @Test
+ void cloneCopiesFields() {
+ RedisOutputField field = new RedisOutputField();
+ field.setStreamField("s");
+ field.setKey("k");
+ field.setHashKey("h");
+ field.setTtlSeconds("9");
+ field.setDataStructure(RedisDataStructure.HASH);
+
+ RedisOutputField copy = field.clone();
+ assertEquals("s", copy.getStreamField());
+ assertEquals("k", copy.getKey());
+ assertEquals("h", copy.getHashKey());
+ assertEquals("9", copy.getTtlSeconds());
+ assertEquals(RedisDataStructure.HASH, copy.getDataStructure());
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputMetaDefaultsTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputMetaDefaultsTest.java
new file mode 100644
index 0000000000..d4147afdd8
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputMetaDefaultsTest.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.redis.transforms.RedisListPushDirection;
+import org.junit.jupiter.api.Test;
+
+class RedisOutputMetaDefaultsTest {
+
+ @Test
+ void setDefaultResetsConfiguration() {
+ RedisOutputMeta meta = new RedisOutputMeta();
+ meta.setConnectionName("c1");
+ meta.setWriteMode(RedisOutputWriteMode.STREAM_FIELDS);
+ meta.setFields(List.of(new RedisOutputField()));
+ meta.setDefault();
+
+ assertNull(meta.getConnectionName());
+ assertEquals(RedisOutputWriteMode.KEY_VALUE, meta.getWriteMode());
+ assertEquals(RedisDataStructure.STRING, meta.getDataStructure());
+ assertEquals(RedisCodecType.STRING, meta.getKeyCodec());
+ assertEquals(RedisCodecType.STRING, meta.getValueCodec());
+ assertEquals("", meta.getKeyField());
+ assertEquals("", meta.getValueField());
+ assertEquals(RedisListPushDirection.RPUSH, meta.getListPushDirection());
+ assertNotNull(meta.getFields());
+ assertTrue(meta.getFields().isEmpty());
+ }
+
+ @Test
+ void setFieldsNullBecomesEmptyList() {
+ RedisOutputMeta meta = new RedisOutputMeta();
+ meta.setFields(null);
+ assertNotNull(meta.getFields());
+ assertTrue(meta.getFields().isEmpty());
+
+ List<RedisOutputField> fields = new ArrayList<>();
+ fields.add(new RedisOutputField());
+ meta.setFields(fields);
+ assertEquals(1, meta.getFields().size());
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputMetaTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputMetaTest.java
new file mode 100644
index 0000000000..c9f580f530
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputMetaTest.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Random;
+import java.util.UUID;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.pipeline.transforms.loadsave.LoadSaveTester;
+import org.apache.hop.pipeline.transforms.loadsave.validator.EnumLoadSaveValidator;
+import org.apache.hop.pipeline.transforms.loadsave.validator.IFieldLoadSaveValidator;
+import org.apache.hop.pipeline.transforms.loadsave.validator.ListLoadSaveValidator;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.redis.transforms.RedisListPushDirection;
+import org.junit.jupiter.api.Test;
+
+class RedisOutputMetaTest {
+
+ @Test
+ void testXmlRoundTrip() throws HopException {
+ List<String> attributes =
+ Arrays.asList(
+ "connectionName",
+ "writeMode",
+ "dataStructure",
+ "keyCodec",
+ "valueCodec",
+ "hashKeyCodec",
+ "hashValueCodec",
+ "keyField",
+ "valueField",
+ "hashKeyField",
+ "hashValueField",
+ "ttlSeconds",
+ "listPushDirection",
+ "fields");
+
+ Map<String, String> getterMap = new HashMap<>();
+ Map<String, String> setterMap = new HashMap<>();
+
+ Map<String, IFieldLoadSaveValidator<?>> attrValidators = new HashMap<>();
+ attrValidators.put("writeMode", new EnumLoadSaveValidator<>(RedisOutputWriteMode.class));
+ attrValidators.put("dataStructure", new EnumLoadSaveValidator<>(RedisDataStructure.class));
+ attrValidators.put("keyCodec", new EnumLoadSaveValidator<>(RedisCodecType.class));
+ attrValidators.put("valueCodec", new EnumLoadSaveValidator<>(RedisCodecType.class));
+ attrValidators.put("hashKeyCodec", new EnumLoadSaveValidator<>(RedisCodecType.class));
+ attrValidators.put("hashValueCodec", new EnumLoadSaveValidator<>(RedisCodecType.class));
+ attrValidators.put(
+ "listPushDirection", new EnumLoadSaveValidator<>(RedisListPushDirection.class));
+ attrValidators.put("fields", new ListLoadSaveValidator<>(new RedisOutputFieldValidator(), 3));
+
+ Map<String, IFieldLoadSaveValidator<?>> typeValidators = new HashMap<>();
+
+ LoadSaveTester tester =
+ new LoadSaveTester(
+ RedisOutputMeta.class,
+ attributes,
+ getterMap,
+ setterMap,
+ attrValidators,
+ typeValidators);
+ tester.testXmlRoundTrip();
+ }
+
+ private static final class RedisOutputFieldValidator
+ implements IFieldLoadSaveValidator<RedisOutputField> {
+ private final Random random = new Random();
+
+ @Override
+ public RedisOutputField getTestObject() {
+ RedisCodecType[] codecs = RedisCodecType.values();
+ RedisDataStructure[] structures = RedisDataStructure.values();
+ RedisOutputField field = new RedisOutputField();
+ field.setStreamField("stream_" + UUID.randomUUID());
+ field.setDataStructure(structures[random.nextInt(structures.length)]);
+ field.setKey("key_" + UUID.randomUUID());
+ field.setKeyCodec(codecs[random.nextInt(codecs.length)]);
+ field.setHashKey(random.nextBoolean() ? "hash_" + UUID.randomUUID() : "");
+ field.setHashKeyCodec(random.nextBoolean() ? codecs[random.nextInt(codecs.length)] : null);
+ field.setValueCodec(codecs[random.nextInt(codecs.length)]);
+ field.setTtlSeconds(random.nextBoolean() ? "0" : String.valueOf(random.nextInt(3600) + 1));
+ if (random.nextBoolean()) {
+ field.setRedisName("legacy_" + UUID.randomUUID());
+ }
+ return field;
+ }
+
+ @Override
+ public boolean validateTestObject(RedisOutputField testObject, Object actual) {
+ if (!(actual instanceof RedisOutputField)) {
+ return false;
+ }
+ RedisOutputField other = (RedisOutputField) actual;
+ return Objects.equals(testObject.getStreamField(), other.getStreamField())
+ && testObject.getDataStructure() == other.getDataStructure()
+ && Objects.equals(testObject.getKey(), other.getKey())
+ && testObject.getKeyCodec() == other.getKeyCodec()
+ && Objects.equals(testObject.getHashKey(), other.getHashKey())
+ && testObject.getHashKeyCodec() == other.getHashKeyCodec()
+ && testObject.getValueCodec() == other.getValueCodec()
+ && Objects.equals(testObject.getTtlSeconds(), other.getTtlSeconds())
+ && Objects.equals(testObject.getRedisName(), other.getRedisName());
+ }
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputTest.java
new file mode 100644
index 0000000000..6b96e739ea
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputTest.java
@@ -0,0 +1,327 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.apache.hop.redis.client.IRedisCommands;
+import org.apache.hop.redis.client.RedisClientSession;
+import org.apache.hop.redis.codec.RedisCodecType;
+import org.apache.hop.redis.codec.RedisCodecs;
+import org.apache.hop.redis.transforms.RedisDataStructure;
+import org.apache.hop.redis.transforms.RedisListPushDirection;
+import org.apache.hop.redis.transforms.redisoutput.RedisOutputData.StreamMapping;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+class RedisOutputTest {
+
+ private TransformMockHelper<RedisOutputMeta, RedisOutputData> mockHelper;
+ private IRedisCommands commands;
+ private RedisClientSession session;
+
+ @BeforeAll
+ static void initEnv() throws Exception {
+ HopEnvironment.init();
+ }
+
+ @AfterAll
+ static void resetEnv() {
+ HopEnvironment.reset();
+ }
+
+ @BeforeEach
+ void setUp() {
+ mockHelper =
+ new TransformMockHelper<>("RedisOutput", RedisOutputMeta.class, RedisOutputData.class);
+ when(mockHelper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+ .thenReturn(mockHelper.iLogChannel);
+ commands = mock(IRedisCommands.class);
+ session = mock(RedisClientSession.class);
+ when(session.getCommands()).thenReturn(commands);
+ }
+
+ @AfterEach
+ void tearDown() {
+ mockHelper.cleanUp();
+ }
+
+ @Test
+ void keyValueWritesStringAndExpires() throws Exception {
+ RedisOutputMeta meta = new RedisOutputMeta();
+ meta.setWriteMode(RedisOutputWriteMode.KEY_VALUE);
+ meta.setDataStructure(RedisDataStructure.STRING);
+ meta.setKeyField("key");
+ meta.setValueField("value");
+
+ RedisOutputData data = preparedKeyValueData(0, 1, -1, -1, 30L);
+ RedisOutput transform = spy(createTransform(meta, data));
+ stubRow(transform, data, new Object[] {"user:1", "alice"});
+
+ assertTrue(transform.processRow());
+ verify(commands)
+ .setValue(
+ eq("user:1".getBytes(StandardCharsets.UTF_8)),
+ eq("alice".getBytes(StandardCharsets.UTF_8)));
+ verify(commands).expire(eq("user:1".getBytes(StandardCharsets.UTF_8)), eq(30L));
+ }
+
+ @Test
+ void keyValueSkipsExpireWhenTtlMissing() throws Exception {
+ RedisOutputMeta meta = new RedisOutputMeta();
+ meta.setWriteMode(RedisOutputWriteMode.KEY_VALUE);
+ meta.setDataStructure(RedisDataStructure.STRING);
+ meta.setKeyField("key");
+ meta.setValueField("value");
+
+ RedisOutputData data = preparedKeyValueData(0, 1, -1, -1, null);
+ RedisOutput transform = spy(createTransform(meta, data));
+ stubRow(transform, data, new Object[] {"user:1", "alice"});
+
+ assertTrue(transform.processRow());
+ verify(commands).setValue(any(byte[].class), any(byte[].class));
+ verify(commands, never()).expire(any(byte[].class), anyLong());
+ }
+
+ @Test
+ void keyValueWritesHash() throws Exception {
+ RedisOutputMeta hashMeta = new RedisOutputMeta();
+ hashMeta.setWriteMode(RedisOutputWriteMode.KEY_VALUE);
+ hashMeta.setDataStructure(RedisDataStructure.HASH);
+ hashMeta.setKeyField("key");
+ hashMeta.setHashKeyField("hk");
+ hashMeta.setHashValueField("hv");
+ RedisOutputData hashData = preparedKeyValueData(0, -1, 1, 2, null);
+ RedisOutput hashTransform = spy(createTransform(hashMeta, hashData));
+ stubRow(hashTransform, hashData, new Object[] {"hkey", "field", "val"});
+ assertTrue(hashTransform.processRow());
+ verify(commands)
+ .hashSet(
+ eq("hkey".getBytes(StandardCharsets.UTF_8)),
+ eq("field".getBytes(StandardCharsets.UTF_8)),
+ eq("val".getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ void keyValueWritesSet() throws Exception {
+ RedisOutputMeta setMeta = new RedisOutputMeta();
+ setMeta.setWriteMode(RedisOutputWriteMode.KEY_VALUE);
+ setMeta.setDataStructure(RedisDataStructure.SET);
+ setMeta.setKeyField("key");
+ setMeta.setValueField("value");
+ RedisOutputData setData = preparedKeyValueData(0, 1, -1, -1, null);
+ RedisOutput setTransform = spy(createTransform(setMeta, setData));
+ stubRow(setTransform, setData, new Object[] {"skey", "member"});
+ assertTrue(setTransform.processRow());
+ verify(commands)
+ .setAdd(
+ eq("skey".getBytes(StandardCharsets.UTF_8)),
+ eq("member".getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ void keyValueListUsesLpushWhenConfigured() throws Exception {
+ RedisOutputMeta listMeta = new RedisOutputMeta();
+ listMeta.setWriteMode(RedisOutputWriteMode.KEY_VALUE);
+ listMeta.setDataStructure(RedisDataStructure.LIST);
+ listMeta.setKeyField("key");
+ listMeta.setValueField("value");
+ listMeta.setListPushDirection(RedisListPushDirection.LPUSH);
+ RedisOutputData listData = preparedKeyValueData(0, 1, -1, -1, null);
+ RedisOutput listTransform = spy(createTransform(listMeta, listData));
+ stubRow(listTransform, listData, new Object[] {"lkey", "elem"});
+ assertTrue(listTransform.processRow());
+ verify(commands)
+ .listLeftPush(
+ eq("lkey".getBytes(StandardCharsets.UTF_8)),
+ eq("elem".getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ void streamFieldsBatchesHashWritesForSameKey() throws Exception {
+ RedisOutputMeta meta = new RedisOutputMeta();
+ meta.setWriteMode(RedisOutputWriteMode.STREAM_FIELDS);
+
+ RedisOutputData data = new RedisOutputData();
+ data.setSession(session);
+ data.setOutputRowMeta(rowMeta("name", "age"));
+ data.setCodecs(
+ RedisCodecs.of(
+ RedisCodecType.STRING,
+ RedisCodecType.STRING,
+ RedisCodecType.STRING,
+ RedisCodecType.STRING));
+
+ StreamMapping nameMapping = hashStreamMapping(0, "user", "name", 10L);
+ StreamMapping ageMapping = hashStreamMapping(1, "user", "age", 5L);
+ data.setStreamMappings(new StreamMapping[] {nameMapping, ageMapping});
+
+ RedisOutput transform = spy(createTransform(meta, data));
+ transform.first = false;
+ doReturn(data.getOutputRowMeta()).when(transform).getInputRowMeta();
+ doReturn(new Object[] {"alice", "30"}).when(transform).getRow();
+ doAnswer(inv -> null).when(transform).putRow(any(), any());
+ doReturn("user").when(transform).resolve("user");
+ doReturn("name").when(transform).resolve("name");
+ doReturn("age").when(transform).resolve("age");
+
+ assertTrue(transform.processRow());
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<Map<byte[], byte[]>> mapCaptor = ArgumentCaptor.forClass(Map.class);
+ verify(commands).hashSet(eq("user".getBytes(StandardCharsets.UTF_8)), mapCaptor.capture());
+ assertEquals(2, mapCaptor.getValue().size());
+ // TTL uses max(10, 5)
+ verify(commands).expire(eq("user".getBytes(StandardCharsets.UTF_8)), eq(10L));
+ }
+
+ @Test
+ void streamFieldsWritesStringSetAndDefaultListRpush() throws Exception {
+ RedisOutputMeta meta = new RedisOutputMeta();
+ meta.setWriteMode(RedisOutputWriteMode.STREAM_FIELDS);
+
+ RedisOutputData data = new RedisOutputData();
+ data.setSession(session);
+ data.setOutputRowMeta(rowMeta("v"));
+ data.setCodecs(
+ RedisCodecs.of(
+ RedisCodecType.STRING,
+ RedisCodecType.STRING,
+ RedisCodecType.STRING,
+ RedisCodecType.STRING));
+
+ StreamMapping stringMapping = new StreamMapping();
+ stringMapping.setStructure(RedisDataStructure.STRING);
+ stringMapping.setStreamFieldIndex(0);
+ stringMapping.setKeyLiteral("s");
+ stringMapping.setKeyCodec(RedisCodecs.create(RedisCodecType.STRING));
+ stringMapping.setValueCodec(RedisCodecs.create(RedisCodecType.STRING));
+
+ StreamMapping setMapping = new StreamMapping();
+ setMapping.setStructure(RedisDataStructure.SET);
+ setMapping.setStreamFieldIndex(0);
+ setMapping.setKeyLiteral("set");
+ setMapping.setKeyCodec(RedisCodecs.create(RedisCodecType.STRING));
+ setMapping.setValueCodec(RedisCodecs.create(RedisCodecType.STRING));
+
+ StreamMapping listMapping = new StreamMapping();
+ listMapping.setStructure(RedisDataStructure.LIST);
+ listMapping.setStreamFieldIndex(0);
+ listMapping.setKeyLiteral("list");
+ listMapping.setKeyCodec(RedisCodecs.create(RedisCodecType.STRING));
+ listMapping.setValueCodec(RedisCodecs.create(RedisCodecType.STRING));
+
+ data.setStreamMappings(new StreamMapping[] {stringMapping, setMapping, listMapping});
+
+ RedisOutput transform = spy(createTransform(meta, data));
+ transform.first = false;
+ doReturn(data.getOutputRowMeta()).when(transform).getInputRowMeta();
+ doReturn(new Object[] {"x"}).when(transform).getRow();
+ doAnswer(inv -> null).when(transform).putRow(any(), any());
+ doReturn("s").when(transform).resolve("s");
+ doReturn("set").when(transform).resolve("set");
+ doReturn("list").when(transform).resolve("list");
+
+ assertTrue(transform.processRow());
+ verify(commands)
+ .setValue(
+ eq("s".getBytes(StandardCharsets.UTF_8)), eq("x".getBytes(StandardCharsets.UTF_8)));
+ verify(commands)
+ .setAdd(
+ eq("set".getBytes(StandardCharsets.UTF_8)), eq("x".getBytes(StandardCharsets.UTF_8)));
+ verify(commands)
+ .listRightPush(
+ eq("list".getBytes(StandardCharsets.UTF_8)), eq("x".getBytes(StandardCharsets.UTF_8)));
+ }
+
+ private RedisOutput createTransform(RedisOutputMeta meta, RedisOutputData data) {
+ return new RedisOutput(
+ mockHelper.transformMeta, meta, data, 0, mockHelper.pipelineMeta, mockHelper.pipeline);
+ }
+
+ private RedisOutputData preparedKeyValueData(
+ int keyIdx, int valueIdx, int hashKeyIdx, int hashValueIdx, Long ttl) {
+ RedisOutputData data = new RedisOutputData();
+ data.setSession(session);
+ data.setCodecs(
+ RedisCodecs.of(
+ RedisCodecType.STRING,
+ RedisCodecType.STRING,
+ RedisCodecType.STRING,
+ RedisCodecType.STRING));
+ data.setKeyFieldIndex(keyIdx);
+ data.setValueFieldIndex(valueIdx);
+ data.setHashKeyFieldIndex(hashKeyIdx);
+ data.setHashValueFieldIndex(hashValueIdx);
+ data.setTtlSeconds(ttl);
+ data.setOutputRowMeta(rowMeta("key", "value", "hk", "hv"));
+ return data;
+ }
+
+ private void stubRow(RedisOutput transform, RedisOutputData data, Object[] row) throws Exception {
+ transform.first = false;
+ doReturn(data.getOutputRowMeta()).when(transform).getInputRowMeta();
+ doReturn(row).when(transform).getRow();
+ doAnswer(inv -> null).when(transform).putRow(any(), any());
+ }
+
+ private static IRowMeta rowMeta(String... names) {
+ RowMeta meta = new RowMeta();
+ for (String name : names) {
+ meta.addValueMeta(new ValueMetaString(name));
+ }
+ return meta;
+ }
+
+ private static StreamMapping hashStreamMapping(
+ int streamIndex, String keyLiteral, String hashKeyLiteral, Long ttl) {
+ StreamMapping mapping = new StreamMapping();
+ mapping.setStructure(RedisDataStructure.HASH);
+ mapping.setStreamFieldIndex(streamIndex);
+ mapping.setKeyLiteral(keyLiteral);
+ mapping.setKeyCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setHashKeyLiteral(hashKeyLiteral);
+ mapping.setHashKeyCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setValueCodec(RedisCodecs.create(RedisCodecType.STRING));
+ mapping.setTtlSeconds(ttl);
+ return mapping;
+ }
+}
diff --git a/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputWriteModeTest.java b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputWriteModeTest.java
new file mode 100644
index 0000000000..12042e7988
--- /dev/null
+++ b/plugins/tech/redis/src/test/java/org/apache/hop/redis/transforms/redisoutput/RedisOutputWriteModeTest.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.redis.transforms.redisoutput;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class RedisOutputWriteModeTest {
+
+ @Test
+ void getNamesAndFromCode() {
+ assertArrayEquals(new String[] {"KEY_VALUE", "STREAM_FIELDS"}, RedisOutputWriteMode.getNames());
+ assertEquals(
+ RedisOutputWriteMode.STREAM_FIELDS, RedisOutputWriteMode.fromCode("stream_fields"));
+ assertEquals(RedisOutputWriteMode.KEY_VALUE, RedisOutputWriteMode.fromCode(null));
+ assertEquals(RedisOutputWriteMode.KEY_VALUE, RedisOutputWriteMode.fromCode(" "));
+ assertEquals(RedisOutputWriteMode.KEY_VALUE, RedisOutputWriteMode.fromCode("other"));
+ }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java
index fc78001783..0ed1440345 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java
@@ -993,7 +993,11 @@ public class GuiCompositeWidgets {
// Old layout: control on right side, next to label
fdControl.left = new FormAttachment(props.getMiddlePct(), 0);
if (rightControl == null) {
- fdControl.right = new FormAttachment(100, 0);
+ // Do not stretch checkboxes across the remaining width — on some platforms a
+ // full-width SWT.CHECK looks like an empty push button next to the label.
+ if (!checkBox) {
+ fdControl.right = new FormAttachment(100, 0);
+ }
} else {
fdControl.right = new FormAttachment(rightControl, -5);
}