[map] Support inline map (#17300)

Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
jimtng 2024-08-20 20:15:02 +10:00 committed by Ciprian Pascu
parent badbc7707c
commit 64331da0ad
3 changed files with 109 additions and 29 deletions

View File

@ -1,15 +1,30 @@
# Map Transformation Service
Transforms the input by mapping it to another string. It expects the mappings to be read from a file which is stored under the `transform` folder.
The file name must have the `.map` extension.
Transforms the input by mapping it to another string.
This file should be in property syntax, i.e. simple lines with "key=value" pairs.
The file format is documented [here](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Properties.html#load(java.io.Reader)).
To organize the various transformations one might use subfolders.
## Map Syntax
A default value can be provided if no matching entry is found by using "=value" syntax.
The mapping is performed based on "key=value" pairs.
When the input matches a `key` in the mapping table, the corresponding `value` is given as the output of the transformation.
The format of the mapping table is documented [here](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Properties.html#load(java.io.Reader)).
A default value can be provided if no matching entry is found by using "=value" syntax.
Defining this default value using `_source_` would then return the non transformed input string.
## File-based Map
The mapping table can be stored in a file under the `transform` folder.
The file name must have the `.map` extension.
To organize the various transformations one might use subfolders.
## Inline Map
Instead of providing the file name from which to load, the mapping table can be specified inline by prefixing it with the `|` character.
The "key=value" pairs are separated with a semicolon (`;`) or a newline character.
For example, the following map function translates open/closed to ON/OFF: `|open=ON; closed=OFF`
## Example
@ -33,7 +48,6 @@ white\ space=using escape
| `white space` | `using escape` |
| `anything` | `default` |
## Usage as a Profile
The functionality of this `TransformationService` can be used in a `Profile` on an `ItemChannelLink` too.

View File

@ -16,11 +16,15 @@ import java.io.IOException;
import java.io.StringReader;
import java.net.URI;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
@ -56,10 +60,12 @@ public class MapTransformationService
private static final String PROFILE_CONFIG_URI = "profile:transform:MAP";
private static final String CONFIG_PARAM_FUNCTION = "function";
private static final Set<String> SUPPORTED_CONFIGURATION_TYPES = Set.of("map");
private static final Pattern INLINE_MAP_CONFIG_PATTERN = Pattern.compile("\\s*\\|(?<map>.+)", Pattern.DOTALL);
private final Logger logger = LoggerFactory.getLogger(MapTransformationService.class);
private final TransformationRegistry transformationRegistry;
private final Map<String, Properties> cachedTransformations = new ConcurrentHashMap<>();
private final Map<String, Properties> cachedInlineMap = new LRUMap<>(1000);
@Activate
public MapTransformationService(@Reference TransformationRegistry transformationRegistry) {
@ -74,30 +80,51 @@ public class MapTransformationService
@Override
public @Nullable String transform(String function, String source) throws TransformationException {
// always get a configuration from the registry to account for changed system locale
Transformation transformation = transformationRegistry.get(function, null);
Properties properties = null;
if (transformation != null) {
if (!cachedTransformations.containsKey(transformation.getUID())) {
importConfiguration(transformation);
}
Properties properties = cachedTransformations.get(transformation.getUID());
if (properties != null) {
String target = properties.getProperty(source);
if (target == null) {
target = properties.getProperty("");
if (target == null) {
throw new TransformationException("Target value not found in map for '" + source + "'");
} else if (SOURCE_VALUE.equals(target)) {
target = source;
}
Matcher matcher = INLINE_MAP_CONFIG_PATTERN.matcher(function);
if (matcher.matches()) {
properties = cachedInlineMap.computeIfAbsent(function, f -> {
Properties props = new Properties();
String map = matcher.group("map").trim();
if (!map.contains("\n")) {
map = map.replace(";", "\n");
}
try {
props.load(new StringReader(map));
logger.trace("Parsed inline map configuration '{}'", props);
} catch (IOException e) {
logger.warn("Failed to parse inline map configuration '{}': {}", map, e.getMessage());
return null;
}
return props;
});
} else {
// always get a configuration from the registry to account for changed system locale
Transformation transformation = transformationRegistry.get(function, null);
if (transformation != null) {
properties = cachedTransformations.get(transformation.getUID());
if (properties == null) {
properties = importConfiguration(transformation);
}
logger.debug("Transformation resulted in '{}'", target);
return target;
}
}
if (properties != null) {
String target = properties.getProperty(source);
if (target == null) {
target = properties.getProperty("");
if (target == null) {
throw new TransformationException("Target value not found in map for '" + source + "'");
} else if (SOURCE_VALUE.equals(target)) {
target = source;
}
}
logger.debug("Transformation resulted in '{}'", target);
return target;
}
throw new TransformationException("Could not find configuration '" + function + "' or failed to parse it.");
}
@ -131,19 +158,34 @@ public class MapTransformationService
}
}
private void importConfiguration(@Nullable Transformation transformation) {
private @Nullable Properties importConfiguration(@Nullable Transformation transformation) {
if (transformation != null) {
try {
Properties properties = new Properties();
String function = transformation.getConfiguration().get(Transformation.FUNCTION);
if (function == null || function.isBlank()) {
logger.warn("Function not defined for transformation '{}'", transformation.getUID());
return;
return null;
}
properties.load(new StringReader(function));
cachedTransformations.put(transformation.getUID(), properties);
return properties;
} catch (IOException ignored) {
}
}
return null;
}
class LRUMap<K, V> extends LinkedHashMap<K, V> {
private final int maxEntries;
public LRUMap(int maxEntries) {
super(10, 0.75f, true);
this.maxEntries = maxEntries;
}
protected boolean removeEldestEntry(@Nullable Entry<K, V> eldest) {
return size() > maxEntries;
}
}
}

View File

@ -152,4 +152,28 @@ public class MapTransformationServiceTest extends JavaTest {
// ensure modified configuration is applied
assertEquals("fermé", processor.transform(NON_DEFAULTED_TRANSFORMATION_DE, SOURCE_CLOSED));
}
@Test
public void oneLineInlineMapTest() throws TransformationException {
String transformation = "|key1=semicolons_are_the_separators ; key2 = value2";
assertEquals("value2", processor.transform(transformation, "key2"));
}
@Test
public void multiLineInlineMapTest() throws TransformationException {
String transformation = "|key1=semicolons_arent_separators;1 \n key2 = value;2";
assertEquals("value;2", processor.transform(transformation, "key2"));
}
@Test
public void defaultInlineTest() throws TransformationException {
String transformation = "|key1=value1;key2=value;=default";
assertEquals("default", processor.transform(transformation, "nonexistent"));
}
@Test
public void defaultSourceInlineTest() throws TransformationException {
String transformation = "|key1=value1;key2=value;=_source_";
assertEquals("nonexistent", processor.transform(transformation, "nonexistent"));
}
}