// Copyright 2016 Google Inc. All Rights Reserved. // // 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 com.google.api.ads.adwords.axis.utils.v201607.shopping; import com.google.api.ads.adwords.axis.v201607.cm.AdGroupCriterion; import com.google.api.ads.adwords.axis.v201607.cm.ProductDimension; import com.google.api.ads.adwords.axis.v201607.cm.ProductPartition; import com.google.common.base.Joiner; import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import org.apache.commons.beanutils.BeanUtils; import org.apache.commons.lang.SerializationUtils; import org.apache.commons.lang.SystemUtils; import org.apache.commons.lang.builder.ToStringBuilder; import org.apache.commons.lang.builder.ToStringStyle; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.SortedMap; import javax.annotation.Nullable; /** * A node in a tree of {@link ProductPartition}s. Used to construct {@link AdGroupCriterion} objects * for shopping campaigns. */ public class ProductPartitionNode { @Nullable private final ProductPartitionNode parentNode; @Nullable private final ProductDimension dimension; @Nullable private Long partitionId; private NodeState nodeState; /** * A map from ProductDimension to child ProductPartitionNode. * * <p>NOTE: This map uses a custom comparator that is <em>not</em> consistent with equals. * Therefore, this map does <em>not</em> obey the general contract of the {@link Map} interface. */ private final SortedMap<ProductDimension, ProductPartitionNode> children; /** * Union of relevant attributes from all subclasses of ProductDimension. Used by * {@link #toString(ProductDimension)} to construct a String representation of a ProductDimension * instance. */ private static final ImmutableList<String> DIMENSION_PROPERTY_NAMES = ImmutableList.of("type", "condition", "value", "channel", "channelExclusivity"); /** * Constructor that sets the parent node, dimension, and partitionId. This constructor does * <em>not</em> call {@code parentNode.addChild(this)}. */ ProductPartitionNode(@Nullable ProductPartitionNode parentNode, @Nullable ProductDimension dimension, @Nullable Long partitionId, Comparator<? super ProductDimension> comparator) { this.parentNode = parentNode; this.dimension = dimension; this.children = Maps.newTreeMap(comparator); this.partitionId = partitionId; this.nodeState = new BiddableUnitState(); } /** * Performs a <em>shallow</em> copy of properties from {@code fromNode} to {@code toNode}. Does * <em>not</em> change the parent node of {@code toNode}. * * @param fromNode the node to copy from. * @param toNode the node to copy to. * @return {@code toNode}, with its properties updated. */ static ProductPartitionNode copyProperties(ProductPartitionNode fromNode, ProductPartitionNode toNode) { switch (fromNode.nodeState.getNodeType()) { case BIDDABLE_UNIT: toNode = toNode.asBiddableUnit(); toNode.setBid(fromNode.getBid()); break; case EXCLUDED_UNIT: toNode = toNode.asExcludedUnit(); break; case SUBDIVISION: toNode = toNode.asSubdivision(); break; default: throw new IllegalStateException( "Unrecognized node state: " + fromNode.nodeState.getNodeType()); } return toNode.setProductPartitionId(fromNode.getProductPartitionId()); } /** * Returns a <em>copy</em> of this node's {@link ProductDimension}. */ public ProductDimension getDimension() { return (ProductDimension) SerializationUtils.clone(dimension); } /** * Returns the product partition ID of this node. */ @Nullable public Long getProductPartitionId() { return partitionId; } /** * Sets the product partition ID of this node. * * @param partitionId required - the ID to set * @return this node */ ProductPartitionNode setProductPartitionId(Long partitionId) { this.partitionId = partitionId; return this; } /** * Returns true if this node's partition type is SUBDIVISION. */ public boolean isSubdivision() { return nodeState.getNodeType() == NodeType.SUBDIVISION; } /** * Returns true if this node's partition type is UNIT. */ public boolean isUnit() { return nodeState.getNodeType() == NodeType.BIDDABLE_UNIT || nodeState.getNodeType() == NodeType.EXCLUDED_UNIT; } /** * Modifies this node to be a SUBDIVISION node. * * @return this node, updated to a subdivision node */ public ProductPartitionNode asSubdivision() { nodeState = nodeState.transitionTo(NodeType.SUBDIVISION); return this; } /** * Returns an Iterable of a shallow copy of all children of this node. */ public Iterable<ProductPartitionNode> getChildren() { return ImmutableList.<ProductPartitionNode>copyOf(children.values()); } /** * Returns the child node with the specified ProductDimension. * * @throws IllegalArgumentException if no such direct child node exists. */ public ProductPartitionNode getChild(ProductDimension dimension) { Preconditions.checkArgument(hasChild(dimension), "No child exists with dimension: %s", toString(dimension)); return children.get(dimension); } /** * Returns true if this node has a child with the specified dimension. * * @param dimension required - the child dimension */ public boolean hasChild(ProductDimension dimension) { return children.containsKey(dimension); } private boolean hasChildren() { return !children.isEmpty(); } /** * Returns the bid (in micros) for this node, or null if {@link #isExcludedUnit()}. */ @Nullable public Long getBid() { return nodeState.getBidInMicros(); } /** * Returns true if this node's partition type is UNIT and is biddable (not excluded). */ public boolean isBiddableUnit() { return nodeState.getNodeType() == NodeType.BIDDABLE_UNIT; } /** * Returns true if this node's partition type is UNIT and is excluded (not biddable). */ public boolean isExcludedUnit() { return nodeState.getNodeType() == NodeType.EXCLUDED_UNIT; } /** * Returns a simple String representation of this node. Does <em>not</em> include details from * this node's children. */ @Override public String toString() { Long parentPartitionId = getParent() != null ? getParent().getProductPartitionId() : null; return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) .append("dimension", toString(dimension)) .append("partitionId", partitionId) .append("parentPartitionId", parentPartitionId) .append("nodeType", nodeState.getNodeType()) .append("bidMicros", nodeState.getBidInMicros()) .append("hasChildren", hasChildren()) .toString(); } /** * Returns a String representation of this node and all of its children. */ public String toDetailedString() { StringBuilder stringBuilder = new StringBuilder(); appendDetailedString(0, stringBuilder); return stringBuilder.toString(); } /** * Appends to {@code stringBuidler} a String representation of this node and all of its children. * * @param level the level of this node * @param stringBuilder the StringBuilder to append to */ private void appendDetailedString(int level, StringBuilder stringBuilder) { String pad = Strings.repeat("--", level); stringBuilder.append(pad).append(this).append(SystemUtils.LINE_SEPARATOR); for (ProductPartitionNode childNode : getChildren()) { childNode.appendDetailedString(level + 1, stringBuilder); } } /** * Convenience method for producing a meaningful String representation of a * {@link ProductDimension}. */ public static String toString(ProductDimension dimension) { if (dimension == null) { return String.valueOf(dimension); } List<String> attributeToStrings = Lists.newArrayList(); try { @SuppressWarnings("unchecked") Map<String, String> propertiesMap = BeanUtils.describe(dimension); for (String propertyName : DIMENSION_PROPERTY_NAMES) { if (propertiesMap.containsKey(propertyName)) { attributeToStrings.add( String.format("%s=%s", propertyName, BeanUtils.getProperty(dimension, propertyName))); } } } catch (Exception e) { attributeToStrings.add("--UNKNOWN--"); } return String.format("%s[%s]", dimension.getClass().getSimpleName(), Joiner.on(',').join(attributeToStrings)); } /** * Returns the parent node of this node. The returned node will be {@code null} if this is the * root node. */ public ProductPartitionNode getParent() { return parentNode; } /** * Adds a NEW child for {@code childDimension} under this node. * * @param childDimension required - the {@link ProductDimension} for the new child * @return the newly created child node */ public ProductPartitionNode addChild(ProductDimension childDimension) { ProductPartitionNode newChild = new ProductPartitionNode(this, childDimension, null, children.comparator()); Preconditions.checkArgument(isSubdivision(), "Parent node is not a SUBDIVISION. Call asSubdivision before adding children to a node."); if (children.containsKey(childDimension)) { throw new IllegalArgumentException( String.format("A child with dimension %s already exists", toString(childDimension))); } children.put(childDimension, newChild); return newChild; } /** * Removes the child with the specified dimension. * * @param childDimension required - the child dimension * @return this node * @throws IllegalArgumentException if no such child exists */ public ProductPartitionNode removeChild(ProductDimension childDimension) { if (!children.containsKey(childDimension)) { throw new IllegalArgumentException(String.format( "Attempted to remove child %s but no such child exists", toString(childDimension))); } children.remove(childDimension); return this; } /** * Removes all children of this node. * * @return this node */ public ProductPartitionNode removeAllChildren() { children.clear(); return this; } /** * Removes all children from this node and modifies this node to be a UNIT node excluded from * bidding. * * @return this node, updated to an excluded node * @throws IllegalStateException if this node is the root node */ public ProductPartitionNode asExcludedUnit() { if (getParent() == null) { throw new IllegalStateException("The root node cannot be an excluded unit"); } nodeState = nodeState.transitionTo(NodeType.EXCLUDED_UNIT); removeAllChildren(); return this; } /** * Removes all children from this node and modifies this node to be a UNIT node that is biddable. * * @return this node, updated to a biddable node */ public ProductPartitionNode asBiddableUnit() { nodeState = nodeState.transitionTo(NodeType.BIDDABLE_UNIT); removeAllChildren(); return this; } /** * Sets the bid for this node. * * @param bidInMicros a null or positive long * @throws IllegalArgumentException if {@code bidInMicros < 0L} * @throws IllegalStateException if this node is not a biddable UNIT node */ public ProductPartitionNode setBid(@Nullable Long bidInMicros) { this.nodeState.setBidInMicros(bidInMicros); return this; } /** * Enumeration of valid node types. */ private enum NodeType { BIDDABLE_UNIT, EXCLUDED_UNIT, SUBDIVISION; } /** * The state of a node. This encapsulates the node type and behavior for setting/getting bids, as * well as transitions from one node type to another. */ private abstract static class NodeState { /** * Returns the NodeType for this state. */ abstract NodeType getNodeType(); /** * Returns the bid in micros for this state. */ @Nullable Long getBidInMicros() { return null; } /** * Sets the bid for this state. * * @param bidInMicros the new bid for the state * @throws IllegalStateException by default. Biddable subclasses should override this behavior. */ void setBidInMicros(@Nullable Long bidInMicros) { throw new IllegalStateException(String.format("Cannot set bid on a %s node", getNodeType())); } /** * Transitions this NodeState to a NodeState for the specified NodeType. * * @param nodeType required * @return a NodeState for the specified NodeType. Will be {@code this} if the NodeType matches * this state's NodeType. */ NodeState transitionTo(NodeType nodeType) { Preconditions.checkNotNull(nodeType, "Null node type"); if (nodeType == getNodeType()) { return this; } switch (nodeType) { case BIDDABLE_UNIT: return new BiddableUnitState(); case EXCLUDED_UNIT: return new ExcludedUnitState(); case SUBDIVISION: return new SubdivisionState(); default: throw new IllegalArgumentException("Unrecognized node type: " + nodeType); } } } /** * NodeState implementation for {@link NodeType#SUBDIVISION}. */ private static class SubdivisionState extends NodeState { @Override NodeType getNodeType() { return NodeType.SUBDIVISION; } } /** * NodeState implementation for {@link NodeType#EXCLUDED_UNIT}. */ private static class ExcludedUnitState extends NodeState { @Override NodeType getNodeType() { return NodeType.EXCLUDED_UNIT; } } /** * NodeState implementation for {@link NodeType#BIDDABLE_UNIT}. */ private static class BiddableUnitState extends NodeState { private Long bidInMicros; @Override Long getBidInMicros() { return bidInMicros; } @Override void setBidInMicros(Long bidInMicros) { Preconditions.checkArgument(bidInMicros == null || bidInMicros > 0L, "Invalid bid: %s. Bid must be null or > 0.", bidInMicros); this.bidInMicros = bidInMicros; } @Override NodeType getNodeType() { return NodeType.BIDDABLE_UNIT; } } }