[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 # 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. Transforms the input by mapping it to another string.
The file name must have the `.map` extension.
This file should be in property syntax, i.e. simple lines with "key=value" pairs. ## Map Syntax
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.
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. 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 ## Example
@ -33,7 +48,6 @@ white\ space=using escape
| `white space` | `using escape` | | `white space` | `using escape` |
| `anything` | `default` | | `anything` | `default` |
## Usage as a Profile ## Usage as a Profile
The functionality of this `TransformationService` can be used in a `Profile` on an `ItemChannelLink` too. 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.io.StringReader;
import java.net.URI; import java.net.URI;
import java.util.Collection; import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties; import java.util.Properties;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault; 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 PROFILE_CONFIG_URI = "profile:transform:MAP";
private static final String CONFIG_PARAM_FUNCTION = "function"; private static final String CONFIG_PARAM_FUNCTION = "function";
private static final Set<String> SUPPORTED_CONFIGURATION_TYPES = Set.of("map"); 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 Logger logger = LoggerFactory.getLogger(MapTransformationService.class);
private final TransformationRegistry transformationRegistry; private final TransformationRegistry transformationRegistry;
private final Map<String, Properties> cachedTransformations = new ConcurrentHashMap<>(); private final Map<String, Properties> cachedTransformations = new ConcurrentHashMap<>();
private final Map<String, Properties> cachedInlineMap = new LRUMap<>(1000);
@Activate @Activate
public MapTransformationService(@Reference TransformationRegistry transformationRegistry) { public MapTransformationService(@Reference TransformationRegistry transformationRegistry) {
@ -74,30 +80,51 @@ public class MapTransformationService
@Override @Override
public @Nullable String transform(String function, String source) throws TransformationException { public @Nullable String transform(String function, String source) throws TransformationException {
// always get a configuration from the registry to account for changed system locale Properties properties = null;
Transformation transformation = transformationRegistry.get(function, null);
if (transformation != null) { Matcher matcher = INLINE_MAP_CONFIG_PATTERN.matcher(function);
if (!cachedTransformations.containsKey(transformation.getUID())) { if (matcher.matches()) {
importConfiguration(transformation); properties = cachedInlineMap.computeIfAbsent(function, f -> {
} Properties props = new Properties();
Properties properties = cachedTransformations.get(transformation.getUID()); String map = matcher.group("map").trim();
if (properties != null) { if (!map.contains("\n")) {
String target = properties.getProperty(source); map = map.replace(";", "\n");
}
if (target == null) { try {
target = properties.getProperty(""); props.load(new StringReader(map));
if (target == null) { logger.trace("Parsed inline map configuration '{}'", props);
throw new TransformationException("Target value not found in map for '" + source + "'"); } catch (IOException e) {
} else if (SOURCE_VALUE.equals(target)) { logger.warn("Failed to parse inline map configuration '{}': {}", map, e.getMessage());
target = source; 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."); 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) { if (transformation != null) {
try { try {
Properties properties = new Properties(); Properties properties = new Properties();
String function = transformation.getConfiguration().get(Transformation.FUNCTION); String function = transformation.getConfiguration().get(Transformation.FUNCTION);
if (function == null || function.isBlank()) { if (function == null || function.isBlank()) {
logger.warn("Function not defined for transformation '{}'", transformation.getUID()); logger.warn("Function not defined for transformation '{}'", transformation.getUID());
return; return null;
} }
properties.load(new StringReader(function)); properties.load(new StringReader(function));
cachedTransformations.put(transformation.getUID(), properties); cachedTransformations.put(transformation.getUID(), properties);
return properties;
} catch (IOException ignored) { } 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 // ensure modified configuration is applied
assertEquals("fermé", processor.transform(NON_DEFAULTED_TRANSFORMATION_DE, SOURCE_CLOSED)); 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"));
}
} }