This is an automated email from the ASF dual-hosted git repository.
hansva 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 c4a0099389 Issue #8539 : Get Avro Decode fields from JSON (#8567)
c4a0099389 is described below
commit c4a0099389932fe4d5ff56fd3c0c4d1876d124f2
Author: Matt Casters <[email protected]>
AuthorDate: Thu Sep 24 14:37:46 2026 +0200
Issue #8539 : Get Avro Decode fields from JSON (#8567)
---
.../pages/pipeline/transforms/avro-decode.adoc | 7 +-
.../transforms/avrodecode/AvroDecodeDialog.java | 277 +++++++++++----------
.../avrodecode/AvroDecodeFieldFinder.java | 178 +++++++++++++
.../avrodecode/messages/messages_en_US.properties | 6 +
.../avrodecode/AvroDecodeFieldFinderTest.java | 182 ++++++++++++++
5 files changed, 518 insertions(+), 132 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/avro-decode.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/avro-decode.adoc
index e7f5f91d25..abf505d4cb 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/avro-decode.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/avro-decode.adoc
@@ -47,7 +47,10 @@ Please note that complex data types like Map and Record are
converted into JSON
Note: The "Avro type" column is informational only.
It's not used at runtime.
-|Get fields button
-|You can use the "Get fields" button to retrieve the fields from the schema
present in the metadata of the specified Avro Record source field. If there's
no metadata in this field, in case of sourcing from a Kafka consumer for
example, you can opt to read from an Avro file.
+|Get fields from file
+|Retrieves fields from the schema on the selected Avro Record source field,
when that field already has one (for example after Avro Encode). If there is no
schema, pick an Avro file. The fields are taken from the first record. Rows are
added to the table.
+
+|Get fields from JSON
+|Paste JSON instead of reading a file. Three forms are accepted: a Hop Avro
value with `schema` and `data`, an Avro record schema, or one sample record
(the JSON shown when previewing an Avro value, including from Kafka). A sample
record's types are a best effort: JSON null becomes String, and only the
top-level fields are added. Rows are added to the table.
|===
diff --git
a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrodecode/AvroDecodeDialog.java
b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrodecode/AvroDecodeDialog.java
index a0930555a5..5da133e834 100644
---
a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrodecode/AvroDecodeDialog.java
+++
b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrodecode/AvroDecodeDialog.java
@@ -17,23 +17,21 @@
package org.apache.hop.avro.transforms.avrodecode;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.HashMap;
import java.util.List;
-import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
import org.apache.avro.Schema;
import org.apache.avro.generic.GenericRecord;
import org.apache.commons.lang3.StringUtils;
+import
org.apache.hop.avro.transforms.avrodecode.AvroDecodeFieldFinder.FieldRow;
import org.apache.hop.avro.transforms.avroinput.AvroFileInputMeta;
import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.exception.HopTransformException;
import org.apache.hop.core.row.IRowMeta;
import org.apache.hop.core.row.IValueMeta;
import org.apache.hop.core.row.RowMetaBuilder;
import org.apache.hop.core.row.value.ValueMetaAvroRecord;
import org.apache.hop.core.row.value.ValueMetaFactory;
-import org.apache.hop.core.util.StringUtil;
import org.apache.hop.core.util.Utils;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.i18n.BaseMessages;
@@ -48,6 +46,7 @@ import
org.apache.hop.pipeline.transforms.injector.InjectorField;
import org.apache.hop.pipeline.transforms.injector.InjectorMeta;
import org.apache.hop.ui.core.PropsUi;
import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.EnterTextDialog;
import org.apache.hop.ui.core.dialog.ErrorDialog;
import org.apache.hop.ui.core.widget.ColumnInfo;
import org.apache.hop.ui.core.widget.NamingSchemeTypes;
@@ -83,7 +82,16 @@ public class AvroDecodeDialog extends BaseTransformDialog {
public String open() {
createShell(BaseMessages.getString(PKG, "AvroDecodeDialog.Shell.Title"));
- buildButtonBar().ok(e -> ok()).get(e -> getFields()).cancel(e ->
cancel()).build();
+ buildButtonBar()
+ .ok(e -> ok())
+ .custom(
+ BaseMessages.getString(PKG,
"AvroDecodeDialog.GetFieldsFromFile.Button"),
+ e -> getFieldsFromFile())
+ .custom(
+ BaseMessages.getString(PKG,
"AvroDecodeDialog.GetFieldsFromJson.Button"),
+ e -> getFieldsFromJson())
+ .cancel(e -> cancel())
+ .build();
Label wlSourceField = new Label(shell, SWT.RIGHT);
wlSourceField.setText(BaseMessages.getString(PKG,
"AvroDecodeDialog.SourceField.Label"));
@@ -244,137 +252,146 @@ public class AvroDecodeDialog extends
BaseTransformDialog {
dispose();
}
- private void getFields() {
+ private void getFieldsFromFile() {
try {
-
- Map<String, Schema.Field> fieldsMap = new HashMap<>();
-
- // If we have a source field name we can see if it's an Avro Record type
with a schema...
- //
- String fieldName = wSourceField.getText();
- if (StringUtils.isNotEmpty(fieldName)) {
- IRowMeta fields = pipelineMeta.getPrevTransformFields(variables,
transformName);
- IValueMeta valueMeta = fields.searchValueMeta(fieldName);
- if (valueMeta != null && valueMeta.getType() == IValueMeta.TYPE_AVRO) {
- Schema schema = ((ValueMetaAvroRecord) valueMeta).getSchema();
- if (schema != null) {
- for (Schema.Field field : schema.getFields()) {
- fieldsMap.put(field.name(), field);
- }
- }
- }
- }
-
- // If there's no metadata in the fields map, ask for an Avro field to
get the schema from
- //
- if (fieldsMap.isEmpty()) {
- String filename =
- BaseDialog.presentFileDialog(
- shell,
- new String[] {"*.avro", "*.*"},
- new String[] {"Avro files", "All files"},
- true);
- if (filename != null) {
- // Read the file
- // Grab the schema
- // Add all the fields to wFields
- //
- PipelineMeta pipelineMeta = new PipelineMeta();
- pipelineMeta.setName("Get Avro file details");
-
- // We'll inject the filename to minimize dependencies
- //
- InjectorMeta injector = new InjectorMeta();
- injector
- .getInjectorFields()
- .add(new InjectorField(CONST_FILENAME, "String", "500", "-1"));
- TransformMeta injectorMeta = new TransformMeta("Filename", injector);
- injectorMeta.setLocation(50, 50);
- pipelineMeta.addTransform(injectorMeta);
-
- // The Avro File Input transform
- //
- AvroFileInputMeta fileInput = new AvroFileInputMeta();
- fileInput.setDataFilenameField(CONST_FILENAME);
- fileInput.setOutputFieldName("avro");
- fileInput.setRowsLimit("1");
- TransformMeta fileInputMeta = new TransformMeta("Avro", fileInput);
- fileInputMeta.setLocation(250, 50);
- pipelineMeta.addTransform(fileInputMeta);
- pipelineMeta.addPipelineHop(new PipelineHopMeta(injectorMeta,
fileInputMeta));
-
- LocalPipelineEngine pipeline =
- new LocalPipelineEngine(pipelineMeta, variables, loggingObject);
- pipeline.setMetadataProvider(metadataProvider);
- pipeline.prepareExecution();
- pipeline.setPreview(true);
-
- RowProducer rowProducer = pipeline.addRowProducer("Filename", 0);
-
- IEngineComponent avroComponent = pipeline.findComponent("Avro", 0);
-
- avroComponent.addRowListener(
- new RowAdapter() {
- private boolean first = true;
-
- @Override
- public void rowWrittenEvent(IRowMeta rowMeta, Object[] row)
- throws HopTransformException {
- if (first) {
- first = false;
-
- int index = rowMeta.indexOfValue("avro");
- ValueMetaAvroRecord avroMeta =
- (ValueMetaAvroRecord) rowMeta.getValueMeta(index);
- Object avroValue = row[index];
-
- try {
- GenericRecord genericRecord =
avroMeta.getGenericRecord(avroValue);
- Schema schema = genericRecord.getSchema();
- List<Schema.Field> fields = schema.getFields();
- for (Schema.Field field : fields) {
- fieldsMap.put(field.name(), field);
- }
- } catch (Exception e) {
- throw new HopTransformException(e);
- }
- }
- }
- });
-
- pipeline.startThreads();
- rowProducer.putRow(
- new RowMetaBuilder().addString(CONST_FILENAME).build(),
- new Object[] {variables.resolve(filename)});
- rowProducer.finished();
-
- pipeline.waitUntilFinished();
- }
+ Schema schema = schemaFromSelectedSourceField();
+ if (schema == null) {
+ schema = readSchemaFromAvroFile();
}
+ addFieldsFromSchema(schema);
+ } catch (Exception e) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG,
"AvroDecodeDialog.GetFields.Error.Title"),
+ BaseMessages.getString(PKG,
"AvroDecodeDialog.GetFields.Error.Message"),
+ e);
+ }
+ }
- if (fieldsMap.isEmpty()) {
- // Sorry, we can't do anything...
+ private void getFieldsFromJson() {
+ try {
+ EnterTextDialog dialog =
+ new EnterTextDialog(
+ shell,
+ BaseMessages.getString(PKG,
"AvroDecodeDialog.GetFieldsFromJson.Title"),
+ BaseMessages.getString(PKG,
"AvroDecodeDialog.GetFieldsFromJson.Message"),
+ "",
+ true);
+ String json = dialog.open();
+ if (StringUtils.isBlank(json)) {
return;
}
+ addFieldsFromSchema(AvroDecodeFieldFinder.schemaFromJson(json));
+ } catch (Exception e) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG,
"AvroDecodeDialog.GetFields.Error.Title"),
+ BaseMessages.getString(PKG,
"AvroDecodeDialog.GetFields.Error.Message"),
+ e);
+ }
+ }
- List<String> names = new ArrayList<>(fieldsMap.keySet());
- names.sort(Comparator.comparing(String::toLowerCase));
- for (String name : names) {
- Schema.Field field = fieldsMap.get(name);
- String typeDesc =
StringUtil.initCap(field.schema().getType().name().toLowerCase());
- int hopType = AvroDecode.getStandardHopType(field);
- String hopTypeDesc = ValueMetaFactory.getValueMetaName(hopType);
-
- TableItem item = new TableItem(wFields.table, SWT.NONE);
- item.setText(1, Const.NVL(field.name(), ""));
- item.setText(2, typeDesc);
- item.setText(3, Const.NVL(field.name(), ""));
- item.setText(4, hopTypeDesc);
- }
- wFields.optimizeTableView();
+ /**
+ * Schema already attached to the selected source field, when that field is
an Avro record. Avro
+ * Encode does this. Avro File Input and Kafka do not.
+ */
+ private Schema schemaFromSelectedSourceField() throws HopException {
+ String fieldName = wSourceField.getText();
+ if (StringUtils.isEmpty(fieldName)) {
+ return null;
+ }
+ IRowMeta fields = pipelineMeta.getPrevTransformFields(variables,
transformName);
+ IValueMeta valueMeta = fields.searchValueMeta(fieldName);
+ if (!(valueMeta instanceof ValueMetaAvroRecord avroValueMeta)) {
+ return null;
+ }
+ Schema schema = avroValueMeta.getSchema();
+ if (schema == null || schema.getType() != Schema.Type.RECORD ||
schema.getFields().isEmpty()) {
+ return null;
+ }
+ return schema;
+ }
- } catch (Exception e) {
- new ErrorDialog(shell, "Error", "Error getting fields", e);
+ private Schema readSchemaFromAvroFile() throws HopException {
+ String filename =
+ BaseDialog.presentFileDialog(
+ shell, new String[] {"*.avro", "*.*"}, new String[] {"Avro files",
"All files"}, true);
+ if (filename == null) {
+ return null;
+ }
+
+ PipelineMeta previewPipeline = new PipelineMeta();
+ previewPipeline.setName("Get Avro file details");
+
+ InjectorMeta injector = new InjectorMeta();
+ injector.getInjectorFields().add(new InjectorField(CONST_FILENAME,
"String", "500", "-1"));
+ TransformMeta injectorMeta = new TransformMeta("Filename", injector);
+ injectorMeta.setLocation(50, 50);
+ previewPipeline.addTransform(injectorMeta);
+
+ AvroFileInputMeta fileInput = new AvroFileInputMeta();
+ fileInput.setDataFilenameField(CONST_FILENAME);
+ fileInput.setOutputFieldName("avro");
+ fileInput.setRowsLimit("1");
+ TransformMeta fileInputMeta = new TransformMeta("Avro", fileInput);
+ fileInputMeta.setLocation(250, 50);
+ previewPipeline.addTransform(fileInputMeta);
+ previewPipeline.addPipelineHop(new PipelineHopMeta(injectorMeta,
fileInputMeta));
+
+ LocalPipelineEngine pipeline =
+ new LocalPipelineEngine(previewPipeline, variables, loggingObject);
+ pipeline.setMetadataProvider(metadataProvider);
+ pipeline.prepareExecution();
+ pipeline.setPreview(true);
+
+ RowProducer rowProducer = pipeline.addRowProducer("Filename", 0);
+ IEngineComponent avroComponent = pipeline.findComponent("Avro", 0);
+ AtomicReference<Schema> schemaRef = new AtomicReference<>();
+ avroComponent.addRowListener(
+ new RowAdapter() {
+ private boolean first = true;
+
+ @Override
+ public void rowWrittenEvent(IRowMeta rowMeta, Object[] row) throws
HopTransformException {
+ if (!first) {
+ return;
+ }
+ first = false;
+ int index = rowMeta.indexOfValue("avro");
+ ValueMetaAvroRecord avroMeta = (ValueMetaAvroRecord)
rowMeta.getValueMeta(index);
+ try {
+ GenericRecord genericRecord =
avroMeta.getGenericRecord(row[index]);
+ schemaRef.set(genericRecord.getSchema());
+ } catch (Exception e) {
+ throw new HopTransformException(e);
+ }
+ }
+ });
+
+ pipeline.startThreads();
+ rowProducer.putRow(
+ new RowMetaBuilder().addString(CONST_FILENAME).build(),
+ new Object[] {variables.resolve(filename)});
+ rowProducer.finished();
+ pipeline.waitUntilFinished();
+ return schemaRef.get();
+ }
+
+ private void addFieldsFromSchema(Schema schema) throws HopException {
+ if (schema == null) {
+ return;
+ }
+ List<FieldRow> rows = AvroDecodeFieldFinder.rowsForSchema(schema);
+ if (rows.isEmpty()) {
+ return;
+ }
+ for (FieldRow row : rows) {
+ TableItem item = new TableItem(wFields.table, SWT.NONE);
+ item.setText(1, Const.NVL(row.sourceField(), ""));
+ item.setText(2, Const.NVL(row.sourceAvroType(), ""));
+ item.setText(3, Const.NVL(row.targetFieldName(), ""));
+ item.setText(4, Const.NVL(row.targetType(), ""));
}
+ wFields.optimizeTableView();
}
}
diff --git
a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrodecode/AvroDecodeFieldFinder.java
b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrodecode/AvroDecodeFieldFinder.java
new file mode 100644
index 0000000000..f364bfc26d
--- /dev/null
+++
b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrodecode/AvroDecodeFieldFinder.java
@@ -0,0 +1,178 @@
+/*
+ * 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.avro.transforms.avrodecode;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import org.apache.avro.AvroRuntimeException;
+import org.apache.avro.Schema;
+import org.apache.avro.SchemaBuilder;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.value.ValueMetaFactory;
+import org.apache.hop.core.util.StringUtil;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.json.simple.parser.JSONParser;
+import org.json.simple.parser.ParseException;
+
+/**
+ * Turns pasted JSON into Avro Decode field rows.
+ *
+ * <p>Accepts a Hop Avro value ({@code schema} and {@code data}), a bare Avro
record schema, or one
+ * sample record. A sample record only contributes its top-level fields, and a
JSON null is typed as
+ * String.
+ */
+final class AvroDecodeFieldFinder {
+ private static final String CONST_SCHEMA = "schema";
+ private static final String CONST_DATA = "data";
+ private static final Set<String> UNION_WRAPPERS =
+ Set.of("boolean", "int", "long", "float", "double", "bytes", "string");
+
+ private AvroDecodeFieldFinder() {}
+
+ record FieldRow(
+ String sourceField, String sourceAvroType, String targetFieldName,
String targetType) {}
+
+ /**
+ * @return the record schema described by {@code json}, or {@code null} when
{@code json} is blank
+ */
+ static Schema schemaFromJson(String json) throws HopException {
+ if (StringUtils.isBlank(json)) {
+ return null;
+ }
+ String text = json.trim();
+ Object parsed;
+ try {
+ parsed = new JSONParser().parse(text);
+ } catch (ParseException e) {
+ throw new HopException("Unable to parse Avro JSON", e);
+ }
+ if (!(parsed instanceof JSONObject object)) {
+ throw new HopException("Avro JSON must be an object");
+ }
+ // The data half is not decoded. Nullable unions in
GenericRecord.toString() are not always in
+ // the shape JsonDecoder expects, and only the schema is needed to list
fields.
+ if (object.get(CONST_SCHEMA) instanceof JSONObject schemaObject
+ && object.get(CONST_DATA) instanceof JSONObject) {
+ try {
+ return recordSchema(new
Schema.Parser().parse(schemaObject.toJSONString()));
+ } catch (RuntimeException e) {
+ throw new HopException("Unable to read the Avro schema and data JSON",
e);
+ }
+ }
+ try {
+ Schema schema = new Schema.Parser().parse(text);
+ if (schema.getType() == Schema.Type.RECORD) {
+ return schema;
+ }
+ } catch (RuntimeException e) {
+ // Not an Avro schema. Fall through and read it as one sample record.
+ }
+ return schemaFromSample(object);
+ }
+
+ static List<FieldRow> rowsForSchema(Schema schema) throws HopException {
+ Schema recordSchema = recordSchema(schema);
+ List<Schema.Field> fields = new ArrayList<>(recordSchema.getFields());
+ fields.sort(Comparator.comparing(field -> field.name().toLowerCase()));
+ List<FieldRow> rows = new ArrayList<>();
+ for (Schema.Field field : fields) {
+ String typeDesc =
StringUtil.initCap(field.schema().getType().name().toLowerCase());
+ int hopType = AvroDecode.getStandardHopType(field);
+ rows.add(
+ new FieldRow(
+ field.name(), typeDesc, field.name(),
ValueMetaFactory.getValueMetaName(hopType)));
+ }
+ return rows;
+ }
+
+ private static Schema recordSchema(Schema schema) throws HopException {
+ if (schema == null || schema.getType() != Schema.Type.RECORD) {
+ throw new HopException("Avro JSON must describe a record");
+ }
+ return schema;
+ }
+
+ private static Schema schemaFromSample(JSONObject object) throws
HopException {
+ SchemaBuilder.FieldAssembler<Schema> fields =
SchemaBuilder.record("sample").fields();
+ int recordName = 0;
+ for (Object keyObject : object.keySet()) {
+ String name = String.valueOf(keyObject);
+ Object value = object.get(name);
+ try {
+ Schema fieldSchema = schemaForValue(value, recordName);
+ if (value instanceof JSONObject jsonObject &&
wrapperSchema(jsonObject) == null) {
+ recordName++;
+ }
+ fields = fields.name(name).type(fieldSchema).noDefault();
+ } catch (AvroRuntimeException e) {
+ throw new HopException("Field '" + name + "' cannot be read from the
sample JSON", e);
+ }
+ }
+ return fields.endRecord();
+ }
+
+ private static Schema schemaForValue(Object value, int recordName) {
+ if (value instanceof JSONObject jsonObject) {
+ Schema wrapped = wrapperSchema(jsonObject);
+ if (wrapped != null) {
+ return wrapped;
+ }
+ Schema nested = Schema.createRecord("sample_record_" + recordName, null,
"hop.sample", false);
+ nested.setFields(List.of());
+ return nested;
+ }
+ if (value instanceof JSONArray) {
+ return Schema.createArray(Schema.create(Schema.Type.STRING));
+ }
+ if (value instanceof Boolean) {
+ return Schema.create(Schema.Type.BOOLEAN);
+ }
+ if (value instanceof Number number) {
+ if (number instanceof Double || number instanceof Float) {
+ return Schema.create(Schema.Type.DOUBLE);
+ }
+ return Schema.create(Schema.Type.LONG);
+ }
+ return Schema.create(Schema.Type.STRING);
+ }
+
+ // Avro JSON encodes a non-null union value as a one-key object such as
{"long": 10}.
+ private static Schema wrapperSchema(JSONObject object) {
+ if (object.size() != 1) {
+ return null;
+ }
+ Object key = object.keySet().iterator().next();
+ if (!(key instanceof String name) || !UNION_WRAPPERS.contains(name)) {
+ return null;
+ }
+ return switch (name) {
+ case "boolean" -> Schema.create(Schema.Type.BOOLEAN);
+ case "int" -> Schema.create(Schema.Type.INT);
+ case "long" -> Schema.create(Schema.Type.LONG);
+ case "float" -> Schema.create(Schema.Type.FLOAT);
+ case "double" -> Schema.create(Schema.Type.DOUBLE);
+ case "bytes" -> Schema.create(Schema.Type.BYTES);
+ case "string" -> Schema.create(Schema.Type.STRING);
+ default -> null;
+ };
+ }
+}
diff --git
a/plugins/tech/avro/src/main/resources/org/apache/hop/avro/transforms/avrodecode/messages/messages_en_US.properties
b/plugins/tech/avro/src/main/resources/org/apache/hop/avro/transforms/avrodecode/messages/messages_en_US.properties
index b918890c48..eab961ffc4 100644
---
a/plugins/tech/avro/src/main/resources/org/apache/hop/avro/transforms/avrodecode/messages/messages_en_US.properties
+++
b/plugins/tech/avro/src/main/resources/org/apache/hop/avro/transforms/avrodecode/messages/messages_en_US.properties
@@ -23,6 +23,12 @@ AvroDecodeDialog.Fields.Column.TargetLength=Length
AvroDecodeDialog.Fields.Column.TargetPrecision=Precision
AvroDecodeDialog.Fields.Column.TargetType=Hop type
AvroDecodeDialog.Fields.Label=Source fields
+AvroDecodeDialog.GetFields.Error.Message=Error getting fields
+AvroDecodeDialog.GetFields.Error.Title=Error
+AvroDecodeDialog.GetFieldsFromFile.Button=Get fields from file
+AvroDecodeDialog.GetFieldsFromJson.Button=Get fields from JSON
+AvroDecodeDialog.GetFieldsFromJson.Message=Paste a Hop Avro value (schema and
data), an Avro record schema, or one sample record. Types in a sample record
are a best effort, and null becomes String.
+AvroDecodeDialog.GetFieldsFromJson.Title=Get fields from JSON
AvroDecodeDialog.Shell.Title=Avro decode
AvroDecodeDialog.SourceField.Label=Source field
AvroDecodeDialog.TransformName.Label=Transform name
diff --git
a/plugins/tech/avro/src/test/java/org/apache/hop/avro/transforms/avrodecode/AvroDecodeFieldFinderTest.java
b/plugins/tech/avro/src/test/java/org/apache/hop/avro/transforms/avrodecode/AvroDecodeFieldFinderTest.java
new file mode 100644
index 0000000000..bbc243a2d4
--- /dev/null
+++
b/plugins/tech/avro/src/test/java/org/apache/hop/avro/transforms/avrodecode/AvroDecodeFieldFinderTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.avro.transforms.avrodecode;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.avro.Schema;
+import org.apache.avro.SchemaBuilder;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import
org.apache.hop.avro.transforms.avrodecode.AvroDecodeFieldFinder.FieldRow;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.value.ValueMetaAvroRecord;
+import org.apache.hop.core.util.TestUtil;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class AvroDecodeFieldFinderTest {
+
+ @BeforeAll
+ static void setUp() throws HopException {
+ TestUtil.registerTestPluginTypes();
+ }
+
+ @Test
+ void hopValueAndBareSchemaUseTheRecordSchema() throws Exception {
+ Schema schema =
+ SchemaBuilder.record("person")
+ .fields()
+ .name("name")
+ .type()
+ .nullable()
+ .stringType()
+ .noDefault()
+ .name("age")
+ .type()
+ .longType()
+ .noDefault()
+ .endRecord();
+ GenericRecord record = new GenericData.Record(schema);
+ record.put("name", "alice");
+ record.put("age", 10L);
+
+ List<FieldRow> fromHopValue =
+ AvroDecodeFieldFinder.rowsForSchema(
+ AvroDecodeFieldFinder.schemaFromJson(
+ ValueMetaAvroRecord.convertGenericRecordToString(record)));
+ List<FieldRow> fromSchema =
+ AvroDecodeFieldFinder.rowsForSchema(
+ AvroDecodeFieldFinder.schemaFromJson(schema.toString()));
+
+ assertEquals(fromSchema, fromHopValue);
+ assertEquals(List.of("age", "name"), names(fromSchema));
+ assertField(fromSchema, "name", "Union", "String");
+ assertField(fromSchema, "age", "Long", "Integer");
+ }
+
+ @Test
+ void sampleRecordInfersTopLevelTypes() throws Exception {
+ String json =
+ """
+ {
+ "label": "alice",
+ "count": 10,
+ "ratio": 1.5,
+ "ok": true,
+ "tags": ["a"],
+ "child": {"left": 1, "right": 2},
+ "missing": null,
+ "asString": {"string": "alice"},
+ "asLong": {"long": 10},
+ "asInt": {"int": 3},
+ "asFloat": {"float": 1.25},
+ "asDouble": {"double": 2.5},
+ "asBytes": {"bytes": "YQ=="},
+ "asBoolean": {"boolean": true},
+ "status": {"com.example.Status": "OPEN"}
+ }
+ """;
+
+ List<FieldRow> rows =
+
AvroDecodeFieldFinder.rowsForSchema(AvroDecodeFieldFinder.schemaFromJson(json));
+
+ assertEquals(
+ List.of(
+ "asBoolean",
+ "asBytes",
+ "asDouble",
+ "asFloat",
+ "asInt",
+ "asLong",
+ "asString",
+ "child",
+ "count",
+ "label",
+ "missing",
+ "ok",
+ "ratio",
+ "status",
+ "tags"),
+ names(rows));
+ assertField(rows, "label", "String", "String");
+ assertField(rows, "count", "Long", "Integer");
+ assertField(rows, "ratio", "Double", "Number");
+ assertField(rows, "ok", "Boolean", "Boolean");
+ assertField(rows, "tags", "Array", "String");
+ assertField(rows, "child", "Record", "String");
+ assertField(rows, "missing", "String", "String");
+ assertField(rows, "asString", "String", "String");
+ assertField(rows, "asLong", "Long", "Integer");
+ assertField(rows, "asInt", "Int", "Integer");
+ assertField(rows, "asFloat", "Float", "Number");
+ assertField(rows, "asDouble", "Double", "Number");
+ assertField(rows, "asBytes", "Bytes", "Binary");
+ assertField(rows, "asBoolean", "Boolean", "Boolean");
+ assertField(rows, "status", "Record", "String");
+ assertTrue(names(rows).stream().noneMatch(name -> name.equals("left") ||
name.equals("right")));
+ }
+
+ @Test
+ void fieldNamesAreSortedCaseInsensitively() throws Exception {
+ List<FieldRow> rows =
+ AvroDecodeFieldFinder.rowsForSchema(
+ AvroDecodeFieldFinder.schemaFromJson("{\"b\": 1, \"A\": 2}"));
+
+ assertEquals(List.of("A", "b"), names(rows));
+ }
+
+ @Test
+ void blankJsonHasNoSchema() throws Exception {
+ assertNull(AvroDecodeFieldFinder.schemaFromJson(null));
+ assertNull(AvroDecodeFieldFinder.schemaFromJson(""));
+ assertNull(AvroDecodeFieldFinder.schemaFromJson(" "));
+ }
+
+ @Test
+ void malformedJsonIsRejected() {
+ assertThrows(HopException.class, () ->
AvroDecodeFieldFinder.schemaFromJson("{"));
+ assertThrows(HopException.class, () ->
AvroDecodeFieldFinder.schemaFromJson("[1, 2]"));
+ assertThrows(HopException.class, () ->
AvroDecodeFieldFinder.schemaFromJson("\"hello\""));
+ }
+
+ @Test
+ void illegalSampleFieldNameIsRejected() {
+ assertThrows(HopException.class, () ->
AvroDecodeFieldFinder.schemaFromJson("{\"a-b\": 1}"));
+ }
+
+ private static List<String> names(List<FieldRow> rows) {
+ return rows.stream().map(FieldRow::sourceField).toList();
+ }
+
+ private static void assertField(
+ List<FieldRow> rows, String name, String avroType, String hopType) {
+ FieldRow row =
+ rows.stream()
+ .filter(candidate -> candidate.sourceField().equals(name))
+ .findFirst()
+ .orElseThrow();
+ assertEquals(name, row.targetFieldName());
+ assertEquals(avroType, row.sourceAvroType());
+ assertEquals(hopType, row.targetType());
+ }
+}