/*
* Copyright 2012 Robert Philipp
*
* 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.freezedry.persistence.keyvalue.renderers;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.freezedry.persistence.annotations.PersistMap;
import org.freezedry.persistence.containers.Pair;
import org.freezedry.persistence.keyvalue.KeyValueBuilder;
import org.freezedry.persistence.keyvalue.renderers.decorators.StringDecorator;
import org.freezedry.persistence.tree.InfoNode;
import org.freezedry.persistence.utils.Constants;
import org.freezedry.persistence.utils.ReflectionUtils;
/**
* Renders the subtree of the semantic model that represents a {@link Map}. {@link Map}s are rendered
* as the map's persistence name followed by the decorated key. For example, a {@code Map< String, Double >}
* would be persisted in the following format when using the default decorator and settings:
* <code><pre>
* Person.friends{"Polly"} = "bird"
* Person.friends{"Sparky"} = "dog"
* </pre></code>
* or for a more complicated map such as {@code Map< String, Map< String, String > >}:
* <code><pre>
* Person.groups{"numbers"}{"one"} = "ONE"
* Person.groups{"numbers"}{"two"} = "TWO"
* Person.groups{"numbers"}{"three"} = "THREE"
* Person.groups{"letters"}{"a"} = "AY"
* Person.groups{"letters"}{"b"} = "BEE"
* </pre></code>
*
* @author Robert Philipp
*/
public class MapRenderer extends AbstractPersistenceRenderer {
private static final Logger LOGGER = LoggerFactory.getLogger( MapRenderer.class );
public static final String OPEN = "{";
public static final String CLOSE = "}";
private String mapEntryName = PersistMap.ENTRY_PERSIST_NAME;
private String mapKeyName = PersistMap.KEY_PERSIST_NAME;
private String mapValueName = PersistMap.VALUE_PERSIST_NAME;
private final StringDecorator keyDecorator;
private final String decorationRegex;
private final Pattern decorationPattern;
private final String validationRegex;
private final Pattern validationPattern;
/**
* Constructs a key-value {@link MapRenderer} for rendering the key-values for a {@link Map}
* @param builder The {@link KeyValueBuilder} that makes calls to this renderer. Recall that this
* is part of a recursive algorithm.
* @param openKey The decorator for the key that represents the beginning of the key. For example, if
* the key is surrounded by "<code>{</code>" and "<code>}</code>", then the {@code openKey}
* would be "<code>{</code>".
* @param closeKey The decorator for the key that represents the end of the key. For example, if
* the key is surrounded by "<code>{</code>" and "<code>}</code>", then the {@code openKey}
* would be "<code>}</code>".
*/
public MapRenderer( final KeyValueBuilder builder, final String openKey, final String closeKey )
{
super( builder );
// create the decorator for the key
keyDecorator = new StringDecorator( openKey, closeKey );
// create the regular expression that determines if a string is renderered by this class
// people[0] is a collection, but people{"test"}[0] is a map< string, list< integer > >
// so we want to check that only word characters precede the "["
final String open = Pattern.quote( openKey );
final String close = Pattern.quote( closeKey );
// create and compile the regex pattern for the decoration
decorationRegex = open + "[\\p{Graph}\\p{Space}]+" + close;
decorationPattern = Pattern.compile( decorationRegex );
// create and compile the regex pattern for validating the complete key
validationRegex = "\\w+" + decorationRegex + "|^" + decorationRegex;
validationPattern = Pattern.compile( validationRegex );
}
/**
* Constructs a key-value {@link MapRenderer} for renderering the key-values for a {@link Map}.
* Decorates the key with the default decorations.
* @param builder The {@link KeyValueBuilder} that makes calls to this renderer. Recall that this
* is part of a recursive algorithm.
*/
public MapRenderer( final KeyValueBuilder builder )
{
this( builder, OPEN, CLOSE );
}
/**
* Copy constructor
* @param renderer The {@link MapRenderer} to copy
*/
public MapRenderer( final MapRenderer renderer )
{
super( renderer );
this.keyDecorator = renderer.keyDecorator.getCopy();
this.decorationRegex = renderer.decorationRegex;
this.decorationPattern = renderer.decorationPattern;
this.validationRegex = renderer.validationRegex;
this.validationPattern = renderer.validationPattern;
}
/**
* @param name the persistence name in the {@link InfoNode}s representing {@link Map.Entry}
* The default value is {@link PersistMap#ENTRY_PERSIST_NAME}
*/
public void setMapEntryName( final String name )
{
this.mapEntryName = name;
}
/**
* @return the persistence name in the {@link InfoNode}s representing {@link Map.Entry}
*/
public String getMapEntryName()
{
return mapEntryName;
}
/**
* @param name the persistence name in the {@link InfoNode}s representing {@link Map} keys
* The default value is {@link PersistMap#KEY_PERSIST_NAME}
*/
public void setMapKeyName( final String name )
{
this.mapKeyName = name;
}
/**
* @return the persistence name in the {@link InfoNode}s representing {@link Map} keys
*/
public String getMapKeyName()
{
return mapKeyName;
}
/**
* @param name the persistence name in the {@link InfoNode}s representing {@link Map} values
* The default value is {@link PersistMap#VALUE_PERSIST_NAME}
*/
public void setMapValueName( final String name )
{
this.mapValueName = name;
}
/**
* @return the persistence name in the {@link InfoNode}s representing {@link Map} values
*/
public String getMapValueName()
{
return mapValueName;
}
/**
* Builds a key-value pair and adds it to the list of key-value pairs. If the
* {@link InfoNode} is compound, then the {@link PersistenceRenderer} may refer back to
* the {@link KeyValueBuilder} to build out the compound node.
* @param infoNode The current {@link InfoNode} in the semantic model.
* @param key The current key. The key is constructed by appending persistence names, which
* may or may not be decorated, to the current key. In this way it represents a flattening
* of the semantic model.
* @param keyValues The current list of key-value pairs
* @param isWithholdPersistName true if the renderer implementation should not append the
* {@link InfoNode}'s persistence name to the key.
*/
@Override
public void buildKeyValuePair( final InfoNode infoNode,
final String key,
final List< Pair< String, Object > > keyValues,
final boolean isWithholdPersistName )
{
// [Division:months{January}[0], 1]
// [Division:months{January}[1], 2]
// [Division:systems{ALM}, "Investments and Capital Markets Division"]
// [Division:systems{SAP}, "Single Family Division"]
// [Division.people[0].Person.groups{"numbers"}{"one"} = "ONE"]
// [Division.personMap{"funny"}.Person.givenName = "Pryor"]
for( InfoNode node : infoNode.getChildren() )
{
// the node should be a MapEntry class, if not, then we've got problems, which
// we will not hesitate to report to the proper authorities.
if( ReflectionUtils.isClassOrSuperclass( Map.Entry.class, node.getClazz() ) )
{
// there should be two nodes hanging off the MapEntry: the key and the value.
// each of these may have their own subnodes.
final List< InfoNode > entryNodes = node.getChildren();
if( entryNodes.size() != 2 )
{
final StringBuilder message = new StringBuilder();
message.append( "The MapRenderer expects MapEntry nodes to have exactly 2 subnodes." ).append( Constants.NEW_LINE );
message.append( " Number of subnodes: " ).append( entryNodes.size() ).append( Constants.NEW_LINE );
message.append( " Persist Name: " ).append( node.getPersistName() );
LOGGER.error( message.toString() );
throw new IllegalStateException( message.toString() );
}
// find the info node that holds the key, and the info node that holds the value
InfoNode keyNode;
InfoNode valueNode;
final String name1 = entryNodes.get( 0 ).getPersistName();
final String name2 = entryNodes.get( 1 ).getPersistName();
if( name1.equals( mapKeyName ) && name2.equals( mapValueName ) )
{
keyNode = entryNodes.get( 0 );
valueNode = entryNodes.get( 1 );
}
else if( name2.equals( mapKeyName ) && name1.equals( mapValueName ) )
{
keyNode = entryNodes.get( 1 );
valueNode = entryNodes.get( 0 );
}
else
{
final String message = new StringBuilder()
.append( "The MapRenderer expects MapEntry nodes to have a key subnode and a value subnode." ).append( Constants.NEW_LINE )
.append( " Required Key Sub-node Persist Name: " ).append( mapKeyName ).append( Constants.NEW_LINE )
.append( " Required Value Sub-node Persist Name: " ).append( mapValueName ).append( Constants.NEW_LINE )
.append( " First Node's Persist Name: " ).append( name1 ).append( Constants.NEW_LINE )
.append( " Second Node's Persist Name: " ).append( name2 )
.toString();
LOGGER.error( message );
throw new IllegalStateException( message );
}
// no we can continue to parse the nodes.
String newKey = createNodeKey( key, infoNode, isWithholdPersistName );
if( keyNode.isLeafNode() )
{
final Object object = keyNode.getValue();
final Class< ? > clazz = object.getClass();
String value;
if( containsDecorator( clazz ) )
{
value = getDecorator( clazz ).decorate( object );
}
else
{
value = object.toString();
}
newKey += keyDecorator.decorate( value );
// create the key-value pair and return it. we know that we have value node, and that
// the persistence name of the value is "Value" or something else set by the user. we
// don't want to write that out, so we simply remove the value from the node.
getPersistenceBuilder().createKeyValuePairs( valueNode, newKey, keyValues, true );
}
else
{
// TODO currently we have a slight problem here for compound keys.
final StringBuilder message = new StringBuilder();
message.append( "The MapRenderer doesn't allow compound (composite) keys at this point." ).append( Constants.NEW_LINE );
message.append( " Current Key: " ).append( newKey ).append( Constants.NEW_LINE );
LOGGER.error( message.toString() );
throw new IllegalStateException( message.toString() );
}
// mark the node as processed so that it doesn't get processed again
node.setIsProcessed( true );
}
else
{
final StringBuilder message = new StringBuilder();
message.append( "The MapRenderer expects the root node of the map to have only sub-nodes of type MapEntry." ).append( Constants.NEW_LINE );
message.append( " InfoNode Type: " ).append( (node.getClazz() == null ? "[null]" : node.getClazz().getName()) ).append( Constants.NEW_LINE );
message.append( " Persist Name: " ).append( node.getPersistName() );
LOGGER.error( message.toString() );
throw new IllegalStateException( message.toString() );
}
}
}
/**
* Creates a new key based on the specified key and the persistence name found in the info node
* @param key The current key
* @param node The {@link InfoNode}
* @param isHidePersistName set to {@code true} if the persistence name should be hidded; {@code false} to show it
* @return a new key based on the specified key and the persistence name found in the info node
*/
private String createNodeKey( final String key, final InfoNode node, final boolean isHidePersistName )
{
String newKey = key;
if( node.getPersistName() != null && !node.getPersistName().isEmpty() && !isHidePersistName )
{
newKey += getPersistenceBuilder().getSeparator() + node.getPersistName();
}
return newKey;
}
/*
* (non-Javadoc)
* @see org.freezedry.persistence.keyvalue.renderers.PersistenceRenderer#buildInfoNode(org.freezedry.persistence.tree.InfoNode, java.util.List)
*/
@Override
public void buildInfoNode( final InfoNode parentNode, final List< Pair< String, String > > keyValues )
{
// nothing to do
if( keyValues == null || keyValues.isEmpty() )
{
return;
}
// grab the group name for the map, and create the compound node
// that holds the map entries of the map as child nodes, and add it
// to the parent node
final String group = getGroupName( keyValues.get( 0 ).getFirst() );
final InfoNode mapNode = InfoNode.createCompoundNode( null, group, null );
parentNode.addChild( mapNode );
// construct the patterns to determine if the node should be a compound node,
// in which case we recurse back to the builder, or a leaf node, in which case
// we simply create it here and add it to the collection node
final String compoundRegex = "^" + group + decorationRegex;
final Pattern compoundPattern = Pattern.compile( compoundRegex );
final String leafRegex = compoundRegex + "$";
final Pattern leafPattern = Pattern.compile( leafRegex );
// we want to have groups by the map key. the map key is the map key in the key-value pair that.
// for example, in friends{"Polly"}, the map key is "Polly" (including the quotes). then we
// can parse each group into its proper node.
final Map< String, List< Pair< String, String > > > mapKeyGroups = getMapKeyGroups( keyValues );
for( Map.Entry< String, List< Pair< String, String > > > entry : mapKeyGroups.entrySet() )
{
// for each group, i.e. each key, we need a map entry node attached to the map node.
final InfoNode mapEntryNode = InfoNode.createCompoundNode( null, mapEntryName, null );
mapNode.addChild( mapEntryNode );
// grab the map key and the list of key-values associated with that map key
final String mapKey = entry.getKey();
// run through the list of key-values creating the child nodes for the map-entry node
final List< Pair< String, String > > keyValueGroup = entry.getValue();
final List< Pair< String, String > > copiedKeyValues = new ArrayList<>( keyValueGroup );
for( Pair< String, String > keyValue : keyValueGroup )
{
// check to see if any items have been removed from the list. this could happen
// when there is a compound node that we have combined, and removed all the entries
// from this list
if( !copiedKeyValues.contains( keyValue ) )
{
continue;
}
// grab the key
final String key = keyValue.getFirst();
// we must figure out whether this is a compound node or a leaf node
final Matcher compoundMatcher = compoundPattern.matcher( key );
final Matcher leafMatcher = leafPattern.matcher( key );
final String trimmedKey = key.replaceAll( " ", "" );
if( leafMatcher.find() && !trimmedKey.contains( "\"}{\"" ) )
{
// its a leaf, create the key node
final String rawMapKey = getDecorator( mapKey ).undecorate( mapKey );
final InfoNode keyNode = InfoNode.createLeafNode( null, rawMapKey, mapKeyName, null );
mapEntryNode.addChild( keyNode );
// so now we need to figure out what the value is. we know that
// it must be a number (integer, double) or a string.
final String value = keyValue.getSecond();
final String rawValue = getDecorator( value ).undecorate( value );
// create the leaf info node and add it to the collection node
final InfoNode valueNode = InfoNode.createLeafNode( null, rawValue, mapValueName, null );
mapEntryNode.addChild( valueNode );
}
else if( compoundMatcher.find() )
{
// its a compound node, create the key node
final String rawMapKey = getDecorator( mapKey ).undecorate( mapKey );
final InfoNode keyNode = InfoNode.createLeafNode( null, rawMapKey, mapKeyName, null );
mapEntryNode.addChild( keyNode );
// in this case, we'll have several entries that have the same index, so
// we'll need to pull those out and put them into a new key-value list
final String separator = getPersistenceBuilder().getSeparator();
final List< Pair< String, String > > mapValueKeyValues = new ArrayList<>();
for( Pair< String, String > copiedKeyValue : keyValues )
{
final String copiedKey = copiedKeyValue.getFirst();
final String keyFirstElement = extractMapKeyPart( key.split( Pattern.quote( separator ) )[ 0 ] );
if( copiedKey.startsWith( keyFirstElement ) )
{
// strip the first element off the key. this could mean one of three things:
// 1. for something like months{"April"}[1] we remove all but the [1]
// 2. for something like months{"April"}.Date we remove all but the Date
// 3. for something like months{"April"}[1].Mood we remove all but [1].Mood
// 4. for something like months{"April"}{"happiness"} we need to remove all but {"happiness"}
// 5. for something like months{"April"}.Person.givenName we need to remove all but Person.giveName
StringBuilder strippedKey = new StringBuilder( mapValueName );
final String keyRemainder = stripFirstElement( copiedKey, separator );
if( !keyRemainder.startsWith( "{" ) && !keyRemainder.startsWith( "[" ) )
{
strippedKey.append( separator );
}
strippedKey.append( keyRemainder );
// add the key to the list of keys that belong to the compound node
mapValueKeyValues.add( new Pair<>( strippedKey.toString(), copiedKeyValue.getSecond() ) );
// and remove the element from the list of key values
copiedKeyValues.remove( copiedKeyValue );
}
}
// call the builder (which called this method) to build the compound node
getPersistenceBuilder().createInfoNode( mapEntryNode, mapValueName, mapValueKeyValues );
}
else
{
// error
final StringBuilder message = new StringBuilder();
message.append( "The key neither represents a leaf node nor a compound node. This is a real problem!" ).append( Constants.NEW_LINE );
message.append( " Parent Node Persistence Name: " ).append( parentNode.getPersistName() ).append( Constants.NEW_LINE );
message.append( " Key: " ).append( key ).append( Constants.NEW_LINE );
LOGGER.error( message.toString() );
throw new IllegalArgumentException( message.toString() );
}
}
}
}
/**
* Extracts the map keys and creates a {@link Map} that has as its keys these map keys. The values are the
* key-value pairs associated with each of these map keys. For leaf nodes, the list of key-value pairs will
* be of size one. For compound nodes, the list will have a size greater than or equal to one.
* @param keyValues The list of key values for the group.
* @return a {@link Map} whose keys are the map keys in the list of key-value pairs
*/
private Map< String, List< Pair< String, String > > > getMapKeyGroups( final List< Pair< String, String > > keyValues )
{
final Map< String, List< Pair< String, String > > > keyGroups = new LinkedHashMap<>();
for( Pair< String, String > keyValue : keyValues )
{
final String mapKey = extractGroupMapKey( keyValue.getFirst() );
if( keyGroups.containsKey( mapKey ) )
{
keyGroups.get( mapKey ).add( keyValue );
}
else
{
final List< Pair< String, String > > keyValueList = new ArrayList<>();
keyValueList.add( keyValue );
keyGroups.put( mapKey, keyValueList );
}
}
return keyGroups;
}
/**
* Extracts the map key from the key. For example, if the key is <code>months{"January"}[0]</code>, then this method
* will return the {@link String} <code>"January"</code> (including the quotes). Or, for example, if the key is <code>id{234}</code>
* this method will return the {@link String} <code>234</code>.
* @param key The key from which to extract the map key
* @return the map key as a {@link String}.
*/
private String extractGroupMapKey( final String key )
{
String mapKey = null;
final Matcher matcher = decorationPattern.matcher( key );
if( matcher.find() )
{
int end = matcher.end();
if( key.replaceAll( " ", "" ).contains( "\"}{\"" ) )
{
end = key.indexOf( "}" ) + 1;
}
mapKey = keyDecorator.undecorate( key.substring( matcher.start(), end ) );
}
return mapKey;
}
/**
* Extracts the map key part from the key. For example, if the key is <code>months{"January"}[0]</code>
* this method will return <code>months{"January"}</code>.
* @param key The key from which to extract the map-key part
* @return the map key part from the key
*/
private String extractMapKeyPart( final String key )
{
String mapKey = null;
final Matcher matcher = decorationPattern.matcher( key );
if( matcher.find() )
{
int end = matcher.end();
if( key.replaceAll( " ", "" ).contains( "\"}{\"" ) )
{
end = key.indexOf( "}" ) + 1;
}
mapKey = key.substring( 0, end );
}
return mapKey;
}
/**
* Removes the map key part from the key. For example, if the key is <code>months{"January"}[0]</code>
* this method will return <code>[0]</code>.
* @param key The key from which to strip the map-key part
* @return the key, stripped of the map key part
*/
private String removeMapKeyPart( final String key )
{
String keyRemainder = null;
final Matcher matcher = decorationPattern.matcher( key );
if( matcher.find() )
{
int end = matcher.end();
if( key.replaceAll( " ", "" ).contains( "\"}{\"" ) )
{
end = key.indexOf( "}" ) + 1;
}
keyRemainder = key.substring( end );
}
return keyRemainder;
}
/**
* Strips the first element from a compound element. For example, if the key is {@code groups\{"numbers"\}\{"one"\}}
* then strips off and returns {@code groups\{"numbers"\}}
* @param key The key to process
* @param separator The decorator (i.e. "{")
* @return The first element
*/
private String stripFirstElement( final String key, final String separator )
{
String remainder = removeMapKeyPart( key );
if( remainder.startsWith( separator ) )
{
remainder = remainder.replaceAll( "^" + Pattern.quote( separator ), "" );
}
return remainder;
}
/*
* (non-Javadoc)
* @see org.freezedry.persistence.keyvalue.renderers.PersistenceRenderer#isRenderer(java.lang.String)
*/
@Override
public boolean isRenderer( String keyElement )
{
return validationPattern.matcher( keyElement ).find();
}
/*
* (non-Javadoc)
* @see org.freezedry.persistence.keyvalue.renderers.PersistenceRenderer#getGroupName(java.lang.String)
*/
@Override
public String getGroupName( final String key )
{
final Matcher matcher = decorationPattern.matcher( key );
String group = null;
if( matcher.find() )
{
group = key.substring( 0, matcher.start() );
}
return group;
}
/*
* (non-Javadoc)
* @see org.freezedry.persistence.copyable.Copyable#getCopy()
*/
@Override
public MapRenderer getCopy()
{
return new MapRenderer( this );
}
}