From 877ae965900cc5e88d3d3d5beab3b22c5a3263fd Mon Sep 17 00:00:00 2001 From: Hynek Mlnarik Date: Mon, 25 Oct 2021 16:56:37 +0200 Subject: [PATCH] KEYCLOAK-18854 Introduce storage-independent ModelCriteriaBuilder --- .../criteria/DefaultModelCriteria.java | 99 ++++++++++++ .../storage/criteria/ModelCriteriaNode.java | 141 ++++++++++++++++++ .../map/storage/tree/DefaultTreeNode.java | 41 +++-- .../criteria/DefaultModelCriteriaTest.java | 119 +++++++++++++++ 4 files changed, 389 insertions(+), 11 deletions(-) create mode 100644 model/map/src/main/java/org/keycloak/models/map/storage/criteria/DefaultModelCriteria.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/storage/criteria/ModelCriteriaNode.java create mode 100644 model/map/src/test/java/org/keycloak/models/map/storage/criteria/DefaultModelCriteriaTest.java diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/criteria/DefaultModelCriteria.java b/model/map/src/main/java/org/keycloak/models/map/storage/criteria/DefaultModelCriteria.java new file mode 100644 index 000000000000..105c38672dfa --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/storage/criteria/DefaultModelCriteria.java @@ -0,0 +1,99 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.models.map.storage.criteria; + +import org.keycloak.models.map.storage.ModelCriteriaBuilder; +import org.keycloak.models.map.storage.criteria.ModelCriteriaNode.ExtOperator; +import org.keycloak.storage.SearchableModelField; + +/** + * Descriptive model criteria implementation which in other words represents a Boolean formula on searchable fields. + * @author hmlnarik + */ +public class DefaultModelCriteria implements ModelCriteriaBuilder { + + private final ModelCriteriaNode node; + + public DefaultModelCriteria() { + this.node = null; + } + + private DefaultModelCriteria(ModelCriteriaNode node) { + this.node = node; + } + + @Override + public DefaultModelCriteria compare(SearchableModelField modelField, Operator op, Object... value) { + final ModelCriteriaNode targetNode; + if (isEmpty()) { + targetNode = new ModelCriteriaNode<>(modelField, op, value); + } else if (node.getNodeOperator() == ExtOperator.AND) { + targetNode = node.cloneTree(); + targetNode.addChild(new ModelCriteriaNode<>(modelField, op, value)); + } else { + targetNode = new ModelCriteriaNode<>(ExtOperator.AND); + targetNode.addChild(node.cloneTree()); + targetNode.addChild(new ModelCriteriaNode<>(modelField, op, value)); + } + return new DefaultModelCriteria<>(targetNode); + } + + @Override + public DefaultModelCriteria and(ModelCriteriaBuilder... mcbs) { + final ModelCriteriaNode targetNode = new ModelCriteriaNode<>(ExtOperator.AND); + for (ModelCriteriaBuilder mcb : mcbs) { + targetNode.addChild(((DefaultModelCriteria) mcb.unwrap(DefaultModelCriteria.class)).node); + } + return new DefaultModelCriteria<>(targetNode); + } + + @Override + public DefaultModelCriteria or(ModelCriteriaBuilder... mcbs) { + final ModelCriteriaNode targetNode = new ModelCriteriaNode<>(ExtOperator.OR); + for (ModelCriteriaBuilder mcb : mcbs) { + targetNode.addChild(((DefaultModelCriteria) mcb.unwrap(DefaultModelCriteria.class)).node); + } + return new DefaultModelCriteria<>(targetNode); + } + + @Override + public DefaultModelCriteria not(ModelCriteriaBuilder mcb) { + final ModelCriteriaNode targetNode = new ModelCriteriaNode<>(ExtOperator.NOT); + targetNode.addChild(((DefaultModelCriteria) mcb.unwrap(DefaultModelCriteria.class)).node); + return new DefaultModelCriteria<>(targetNode); + } + + /** + * Copies contents of this {@code ModelCriteriaBuilder} into + * another {@code ModelCriteriaBuilder}. + * @param mcb {@code ModelCriteriaBuilder} to copy the contents onto + * @return Updated {@code ModelCriteriaBuilder} + */ + public ModelCriteriaBuilder flashToModelCriteriaBuilder(ModelCriteriaBuilder mcb) { + return mcb == null ? null : node.flashToModelCriteriaBuilder(mcb); + } + + public boolean isEmpty() { + return node == null; + } + + @Override + public String toString() { + return isEmpty() ? "" : node.toString(); + } + +} diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/criteria/ModelCriteriaNode.java b/model/map/src/main/java/org/keycloak/models/map/storage/criteria/ModelCriteriaNode.java new file mode 100644 index 000000000000..0c634db39f51 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/storage/criteria/ModelCriteriaNode.java @@ -0,0 +1,141 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.models.map.storage.criteria; + +import org.keycloak.models.map.storage.ModelCriteriaBuilder; +import org.keycloak.models.map.storage.ModelCriteriaBuilder.Operator; +import org.keycloak.models.map.storage.tree.DefaultTreeNode; +import org.keycloak.storage.SearchableModelField; +import java.util.Arrays; +import java.util.Collections; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * + * @author hmlnarik + */ +public class ModelCriteriaNode extends DefaultTreeNode> { + + public static enum ExtOperator { + AND { + @Override public ModelCriteriaBuilder apply(ModelCriteriaBuilder mcb, ModelCriteriaNode node) { + if (node.getChildren().isEmpty()) { + return null; + } + final ModelCriteriaBuilder[] operands = node.getChildren().stream() + .map(n -> n.flashToModelCriteriaBuilder(mcb)) + .filter(Objects::nonNull) + .toArray(ModelCriteriaBuilder[]::new); + return operands.length == 0 ? null : mcb.and(operands); + } + @Override public String toString(ModelCriteriaNode node) { + return "(" + node.getChildren().stream().map(ModelCriteriaNode::toString).collect(Collectors.joining(" && ")) + ")"; + } + }, + OR { + @Override public ModelCriteriaBuilder apply(ModelCriteriaBuilder mcb, ModelCriteriaNode node) { + if (node.getChildren().isEmpty()) { + return null; + } + final ModelCriteriaBuilder[] operands = node.getChildren().stream() + .map(n -> n.flashToModelCriteriaBuilder(mcb)) + .filter(Objects::nonNull) + .toArray(ModelCriteriaBuilder[]::new); + return operands.length == 0 ? null : mcb.or(operands); + } + @Override public String toString(ModelCriteriaNode node) { + return "(" + node.getChildren().stream().map(ModelCriteriaNode::toString).collect(Collectors.joining(" || ")) + ")"; + } + }, + NOT { + @Override public ModelCriteriaBuilder apply(ModelCriteriaBuilder mcb, ModelCriteriaNode node) { + return mcb.not(node.getChildren().iterator().next().flashToModelCriteriaBuilder(mcb)); + } + @Override public String toString(ModelCriteriaNode node) { + return "! " + node.getChildren().iterator().next().toString(); + } + }, + SIMPLE_OPERATOR { + @Override public ModelCriteriaBuilder apply(ModelCriteriaBuilder mcb, ModelCriteriaNode node) { + return mcb.compare( + node.field, + node.simpleOperator, + node.simpleOperatorArguments + ); + } + @Override public String toString(ModelCriteriaNode node) { + return node.field.getName() + " " + node.simpleOperator + " " + Arrays.deepToString(node.simpleOperatorArguments); + } + }, + ; + + public abstract ModelCriteriaBuilder apply(ModelCriteriaBuilder mcbCreator, ModelCriteriaNode node); + public abstract String toString(ModelCriteriaNode node); + } + + private final ExtOperator nodeOperator; + + private final Operator simpleOperator; + + private final SearchableModelField field; + + private final Object[] simpleOperatorArguments; + + public ModelCriteriaNode(SearchableModelField field, Operator simpleOperator, Object... simpleOperatorArguments) { + super(Collections.emptyMap()); + this.simpleOperator = simpleOperator; + this.field = field; + this.simpleOperatorArguments = simpleOperatorArguments; + this.nodeOperator = ExtOperator.SIMPLE_OPERATOR; + } + + public ModelCriteriaNode(ExtOperator nodeOperator) { + super(Collections.emptyMap()); + this.nodeOperator = nodeOperator; + this.simpleOperator = null; + this.field = null; + this.simpleOperatorArguments = null; + } + + private ModelCriteriaNode(ExtOperator nodeOperator, Operator simpleOperator, SearchableModelField field, Object[] simpleOperatorArguments) { + super(Collections.emptyMap()); + this.nodeOperator = nodeOperator; + this.simpleOperator = simpleOperator; + this.field = field; + this.simpleOperatorArguments = simpleOperatorArguments; + } + + public ExtOperator getNodeOperator() { + return nodeOperator; + } + + public ModelCriteriaNode cloneTree() { + return cloneTree(n -> new ModelCriteriaNode<>(n.nodeOperator, n.simpleOperator, n.field, n.simpleOperatorArguments)); + } + + public ModelCriteriaBuilder flashToModelCriteriaBuilder(ModelCriteriaBuilder mcb) { + final ModelCriteriaBuilder res = nodeOperator.apply(mcb, this); + return res == null ? mcb : res; + } + + @Override + public String toString() { + return nodeOperator.toString(this); + } + +} diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/tree/DefaultTreeNode.java b/model/map/src/main/java/org/keycloak/models/map/storage/tree/DefaultTreeNode.java index cbf7e1bdd4fb..23075a1693a3 100644 --- a/model/map/src/main/java/org/keycloak/models/map/storage/tree/DefaultTreeNode.java +++ b/model/map/src/main/java/org/keycloak/models/map/storage/tree/DefaultTreeNode.java @@ -26,8 +26,8 @@ import java.util.Map; import java.util.Optional; import java.util.Queue; -import java.util.Stack; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Predicate; /** @@ -39,8 +39,8 @@ */ public class DefaultTreeNode> implements TreeNode { - private final Map edgeProperties = new HashMap<>(); - private final Map nodeProperties = new HashMap<>(); + private final Map nodeProperties; + private final Map edgeProperties; private final Map treeProperties; private final LinkedList children = new LinkedList<>(); private String id; @@ -51,6 +51,14 @@ public class DefaultTreeNode> implements Tree */ protected DefaultTreeNode(Map treeProperties) { this.treeProperties = treeProperties; + this.edgeProperties = new HashMap<>(); + this.nodeProperties = new HashMap<>(); + } + + public DefaultTreeNode(Map nodeProperties, Map edgeProperties, Map treeProperties) { + this.nodeProperties = nodeProperties; + this.edgeProperties = edgeProperties; + this.treeProperties = treeProperties; } @Override @@ -106,7 +114,7 @@ public void setId(String id) { @Override public Optional findFirstDfs(Predicate visitor) { Deque stack = new LinkedList<>(); - stack.add((Self) this); + stack.add(getThis()); while (! stack.isEmpty()) { Self node = stack.pop(); if (visitor.test(node)) { @@ -124,7 +132,7 @@ public Optional findFirstDfs(Predicate visitor) { @Override public Optional findFirstBottommostDfs(Predicate visitor) { Deque stack = new LinkedList<>(); - stack.add((Self) this); + stack.add(getThis()); while (! stack.isEmpty()) { Self node = stack.pop(); if (visitor.test(node)) { @@ -149,7 +157,7 @@ public Optional findFirstBottommostDfs(Predicate visitor) { @Override public Optional findFirstBfs(Predicate visitor) { Queue queue = new LinkedList<>(); - queue.add((Self) this); + queue.add(getThis()); while (! queue.isEmpty()) { Self node = queue.poll(); if (visitor.test(node)) { @@ -165,7 +173,7 @@ public Optional findFirstBfs(Predicate visitor) { public List getPathToRoot(PathOrientation orientation) { LinkedList res = new LinkedList<>(); Consumer addFunc = orientation == PathOrientation.BOTTOM_FIRST ? res::addLast : res::addFirst; - Optional p = Optional.of((Self) this); + Optional p = Optional.of(getThis()); while (p.isPresent()) { addFunc.accept(p.get()); p = p.get().getParent(); @@ -186,7 +194,7 @@ public void addChild(Self node) { if (! this.children.contains(node)) { this.children.add(node); } - node.setParent((Self) this); + node.setParent(getThis()); // Prevent setting a parent of this node as a child of this node. In such a case, remove the parent of this node for (Optional p = getParent(); p.isPresent(); p = p.get().getParent()) { @@ -205,7 +213,7 @@ public void addChild(int index, Self node) { if (! this.children.contains(node)) { this.children.add(index, node); } - node.setParent((Self) this); + node.setParent(getThis()); // Prevent setting a parent of this node as a child of this node. In such a case, remove the parent of this node for (Optional p = getParent(); p.isPresent(); p = p.get().getParent()) { @@ -276,12 +284,23 @@ public void setParent(Self parent) { if (this.parent != null) { Self previousParent = this.parent; this.parent = null; - previousParent.removeChild((Self) this); + previousParent.removeChild(getThis()); } if (parent != null) { this.parent = parent; - parent.addChild((Self) this); + parent.addChild(getThis()); } } + + public > RNode cloneTree(Function instantiateFunc) { + final RNode res = instantiateFunc.apply(getThis()); + this.getChildren().forEach(c -> res.addChild(c.cloneTree(instantiateFunc))); + return res; + } + + @SuppressWarnings("unchecked") + private Self getThis() { + return (Self) this; + } } diff --git a/model/map/src/test/java/org/keycloak/models/map/storage/criteria/DefaultModelCriteriaTest.java b/model/map/src/test/java/org/keycloak/models/map/storage/criteria/DefaultModelCriteriaTest.java new file mode 100644 index 000000000000..fb3b4fa52908 --- /dev/null +++ b/model/map/src/test/java/org/keycloak/models/map/storage/criteria/DefaultModelCriteriaTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.models.map.storage.criteria; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.map.storage.ModelCriteriaBuilder.Operator; +import static org.hamcrest.MatcherAssert.assertThat; +import org.junit.Test; +import static org.hamcrest.Matchers.hasToString; +import static org.keycloak.models.ClientModel.SearchableFields.*; + +/** + * + * @author hmlnarik + */ +public class DefaultModelCriteriaTest { + + @Test + public void testSimpleCompare() { + DefaultModelCriteria v = new DefaultModelCriteria<>(); + assertThat(v.compare(CLIENT_ID, Operator.EQ, 3), hasToString("clientId EQ [3]")); + assertThat(v.compare(CLIENT_ID, Operator.EQ, 4).compare(ID, Operator.EQ, 5), hasToString("(clientId EQ [4] && id EQ [5])")); + } + + @Test + public void testSimpleCompareAnd() { + DefaultModelCriteria v = new DefaultModelCriteria<>(); + assertThat(v.and(v.compare(CLIENT_ID, Operator.EQ, 3)), hasToString("(clientId EQ [3])")); + assertThat(v.and(v.compare(CLIENT_ID, Operator.EQ, 4).compare(ID, Operator.EQ, 5)), hasToString("((clientId EQ [4] && id EQ [5]))")); + assertThat(v.and(v.compare(CLIENT_ID, Operator.EQ, 4), v.compare(ID, Operator.EQ, 5)), hasToString("(clientId EQ [4] && id EQ [5])")); + } + + @Test + public void testSimpleCompareOr() { + DefaultModelCriteria v = new DefaultModelCriteria<>(); + assertThat(v.or(v.compare(CLIENT_ID, Operator.EQ, 3)), hasToString("(clientId EQ [3])")); + assertThat(v.or(v.compare(CLIENT_ID, Operator.EQ, 4).compare(ID, Operator.EQ, 5)), hasToString("((clientId EQ [4] && id EQ [5]))")); + assertThat(v.or(v.compare(CLIENT_ID, Operator.EQ, 4), v.compare(ID, Operator.EQ, 5)), hasToString("(clientId EQ [4] || id EQ [5])")); + } + + @Test + public void testSimpleCompareAndOr() { + DefaultModelCriteria v = new DefaultModelCriteria<>(); + assertThat(v.or( + v.and( + v.compare(CLIENT_ID, Operator.EQ, 4), + v.compare(ID, Operator.EQ, 5) + ), + v.compare(ATTRIBUTE, Operator.EQ, "city", "Ankh-Morpork") + ), hasToString("((clientId EQ [4] && id EQ [5]) || attribute EQ [city, Ankh-Morpork])")); + } + + @Test + public void testComplexCompareAndOr() { + DefaultModelCriteria v = new DefaultModelCriteria<>(); + assertThat(v.or( + v.and( + v.compare(CLIENT_ID, Operator.EQ, 4), + v.compare(REALM_ID, Operator.EQ, "aa") + ), + v.not(v.compare(ID, Operator.EQ, 5)) + ), + hasToString("((clientId EQ [4] && realmId EQ [aa]) || ! id EQ [5])") + ); + + assertThat(v.or( + v.and( + v.compare(CLIENT_ID, Operator.EQ, 4), + v.compare(REALM_ID, Operator.EQ, "aa") + ), + v.not( + v.compare(ID, Operator.EQ, 5) + ).compare(ENABLED, Operator.EQ, "true") + ), + hasToString("((clientId EQ [4] && realmId EQ [aa]) || (! id EQ [5] && enabled EQ [true]))") + ); + } + + @Test + public void testFlashingToAnotherMCB() { + DefaultModelCriteria v = new DefaultModelCriteria<>(); + assertThat(v.or( + v.and( + v.compare(CLIENT_ID, Operator.EQ, 4), + v.compare(REALM_ID, Operator.EQ, "aa") + ), + v.not(v.compare(ID, Operator.EQ, 5)) + ).flashToModelCriteriaBuilder(new DefaultModelCriteria<>()), + hasToString("((clientId EQ [4] && realmId EQ [aa]) || ! id EQ [5])") + ); + + assertThat(v.or( + v.and( + v.compare(CLIENT_ID, Operator.EQ, 4), + v.compare(REALM_ID, Operator.EQ, "aa") + ), + v.not( + v.compare(ID, Operator.EQ, 5) + ).compare(ENABLED, Operator.EQ, "true") + ).flashToModelCriteriaBuilder(new DefaultModelCriteria<>()), + hasToString("((clientId EQ [4] && realmId EQ [aa]) || (! id EQ [5] && enabled EQ [true]))") + ); + } + +}