001/** 002 * Copyright (C) 2006-2025 Talend Inc. - www.talend.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.talend.sdk.component.runtime.manager.reflect.visibility; 017 018import static java.util.Locale.ROOT; 019import static java.util.Optional.ofNullable; 020import static java.util.stream.Collectors.joining; 021import static java.util.stream.Collectors.toList; 022import static java.util.stream.Collectors.toMap; 023import static lombok.AccessLevel.PRIVATE; 024 025import java.lang.reflect.Array; 026import java.util.Collection; 027import java.util.Map; 028import java.util.function.Function; 029import java.util.stream.IntStream; 030import java.util.stream.Stream; 031 032import javax.json.JsonNumber; 033import javax.json.JsonObject; 034import javax.json.JsonPointer; 035import javax.json.JsonString; 036import javax.json.JsonValue; 037import javax.json.spi.JsonProvider; 038 039import org.talend.sdk.component.runtime.manager.ParameterMeta; 040 041import lombok.NoArgsConstructor; 042import lombok.RequiredArgsConstructor; 043 044@RequiredArgsConstructor 045public class VisibilityService { 046 047 private final AbsolutePathResolver pathResolver = new AbsolutePathResolver(); 048 049 private final JsonProvider jsonProvider; 050 051 public ConditionGroup build(final ParameterMeta param) { 052 final boolean and = 053 "AND".equalsIgnoreCase(param.getMetadata().getOrDefault("tcomp::condition::ifs::operator", "AND")); 054 return new ConditionGroup(param 055 .getMetadata() 056 .entrySet() 057 .stream() 058 .filter(meta -> meta.getKey().startsWith("tcomp::condition::if::target")) 059 .map(meta -> { 060 final String[] split = meta.getKey().split("::"); 061 final String index = split.length == 5 ? "::" + split[split.length - 1] : ""; 062 final String valueKey = "tcomp::condition::if::value" + index; 063 final String negateKey = "tcomp::condition::if::negate" + index; 064 final String evaluationStrategyKey = "tcomp::condition::if::evaluationStrategy" + index; 065 final String absoluteTargetPath = pathResolver.resolveProperty(param.getPath(), meta.getValue()); 066 return new Condition(toPointer(absoluteTargetPath), 067 Boolean.parseBoolean(param.getMetadata().getOrDefault(negateKey, "false")), 068 param.getMetadata().getOrDefault(evaluationStrategyKey, "DEFAULT").toUpperCase(ROOT), 069 param.getMetadata().getOrDefault(valueKey, "true").split(",")); 070 }) 071 .collect(toList()), and ? stream -> stream.allMatch(i -> i) : stream -> stream.anyMatch(i -> i)); 072 } 073 074 private JsonPointer toPointer(final String absoluteTargetPath) { 075 return jsonProvider.createPointer('/' + absoluteTargetPath.replace('.', '/')); 076 } 077 078 // check org.talend.sdk.component.form.internal.converter.impl.widget.path.AbsolutePathResolver, 079 // we don't want component-form-core dependency here, we can move it to another module later if relevant 080 @NoArgsConstructor(access = PRIVATE) 081 private static class AbsolutePathResolver { 082 083 private String resolveProperty(final String propPath, final String paramRef) { 084 return doResolveProperty(propPath, normalizeParamRef(paramRef)); 085 } 086 087 private String normalizeParamRef(final String paramRef) { 088 return (!paramRef.contains(".") ? "../" : "") + paramRef; 089 } 090 091 private String doResolveProperty(final String propPath, final String paramRef) { 092 if (".".equals(paramRef)) { 093 return propPath; 094 } 095 if (paramRef.startsWith("..")) { 096 String current = propPath; 097 String ref = paramRef; 098 while (ref.startsWith("..")) { 099 int lastDot = current.lastIndexOf('.'); 100 if (lastDot < 0) { 101 lastDot = 0; 102 } 103 current = current.substring(0, lastDot); 104 ref = ref.substring("..".length(), ref.length()); 105 if (ref.startsWith("/")) { 106 ref = ref.substring(1); 107 } 108 if (current.isEmpty()) { 109 break; 110 } 111 } 112 return Stream.of(current, ref.replace('/', '.')).filter(it -> !it.isEmpty()).collect(joining(".")); 113 } 114 if (paramRef.startsWith(".") || paramRef.startsWith("./")) { 115 return propPath + '.' + paramRef.replaceFirst("\\./?", "").replace('/', '.'); 116 } 117 return paramRef; 118 } 119 } 120 121 @RequiredArgsConstructor(access = PRIVATE) 122 public static class Condition { 123 124 private static final Function<Object, String> TO_STRING = v -> v == null ? null : String.valueOf(v); 125 126 private static final Function<Object, String> TO_LOWERCASE = 127 v -> v == null ? null : String.valueOf(v).toLowerCase(ROOT); 128 129 private final JsonPointer pointer; 130 131 private final boolean negation; 132 133 private final String evaluationStrategy; 134 135 private final String[] values; 136 137 boolean evaluateCondition(final JsonObject payload) { 138 return negation != Stream.of(values).anyMatch(val -> evaluate(val, payload)); 139 } 140 141 private boolean evaluate(final String expected, final JsonObject payload) { 142 final Object actual = extractValue(payload); 143 switch (evaluationStrategy) { 144 case "DEFAULT": 145 return expected.equals(TO_STRING.apply(actual)); 146 case "LENGTH": 147 if (actual == null) { 148 return "0".equals(expected); 149 } 150 final int expectedSize = Integer.parseInt(expected); 151 if (Collection.class.isInstance(actual)) { 152 return expectedSize == Collection.class.cast(actual).size(); 153 } 154 if (actual.getClass().isArray()) { 155 return expectedSize == Array.getLength(actual); 156 } 157 if (String.class.isInstance(actual)) { 158 return expectedSize == String.class.cast(actual).length(); 159 } 160 return false; 161 default: 162 Function<Object, String> preprocessor = TO_STRING; 163 if (evaluationStrategy.startsWith("CONTAINS")) { 164 final int start = evaluationStrategy.indexOf('('); 165 if (start >= 0) { 166 final int end = evaluationStrategy.indexOf(')', start); 167 if (end >= 0) { 168 final Map<String, String> configuration = Stream 169 .of(evaluationStrategy.substring(start + 1, end).split(",")) 170 .map(String::trim) 171 .filter(it -> !it.isEmpty()) 172 .map(it -> { 173 final int sep = it.indexOf('='); 174 if (sep > 0) { 175 return new String[] { it.substring(0, sep), it.substring(sep + 1) }; 176 } 177 return new String[] { "value", it }; 178 }) 179 .collect(toMap(a -> a[0], a -> a[1])); 180 if (Boolean.parseBoolean(configuration.getOrDefault("lowercase", "false"))) { 181 preprocessor = TO_LOWERCASE; 182 } 183 } 184 } 185 if (actual == null) { 186 return false; 187 } 188 if (CharSequence.class.isInstance(actual)) { 189 return ofNullable(preprocessor.apply(TO_STRING.apply(actual))) 190 .map(it -> it.contains(expected)) 191 .orElse(false); 192 } 193 if (Collection.class.isInstance(actual)) { 194 final Collection<?> collection = Collection.class.cast(actual); 195 return collection.stream().map(preprocessor).anyMatch(it -> it.contains(expected)); 196 } 197 if (actual.getClass().isArray()) { 198 return IntStream 199 .range(0, Array.getLength(actual)) 200 .mapToObj(i -> Array.get(actual, i)) 201 .map(preprocessor) 202 .anyMatch(it -> it.contains(expected)); 203 } 204 return false; 205 } 206 throw new IllegalArgumentException("Not supported operation '" + evaluationStrategy + "'"); 207 } 208 } 209 210 private Object extractValue(final JsonObject payload) { 211 if (!pointer.containsValue(payload)) { 212 return null; 213 } 214 return ofNullable(pointer.getValue(payload)).map(this::mapValue).orElse(null); 215 216 } 217 218 private Object mapValue(final JsonValue value) { 219 switch (value.getValueType()) { 220 case ARRAY: 221 return value.asJsonArray().stream().map(this::mapValue).collect(toList()); 222 case STRING: 223 return JsonString.class.cast(value).getString(); 224 case TRUE: 225 return true; 226 case FALSE: 227 return false; 228 case NUMBER: 229 return JsonNumber.class.cast(value).doubleValue(); 230 case NULL: 231 return null; 232 case OBJECT: 233 default: 234 return value; 235 } 236 } 237 } 238 239 @RequiredArgsConstructor(access = PRIVATE) 240 public static class ConditionGroup { 241 242 private final Collection<Condition> conditions; 243 244 private final Function<Stream<Boolean>, Boolean> aggregator; 245 246 public boolean isVisible(final JsonObject payload) { 247 return conditions 248 .stream() 249 .allMatch(group -> aggregator.apply(conditions.stream().map(c -> c.evaluateCondition(payload)))); 250 } 251 } 252 253}