mlTextEntry : mlTextValue.entrySet())
                {
                    Locale mlTextLocale = mlTextEntry.getKey();
                    String mlTextStr = mlTextEntry.getValue();
                    // Get the Locale ID for the text
                    Long mlTextLocaleId = localeDAO.getOrCreateLocalePair(mlTextLocale).getFirst();
                    // This is persisted against the current locale, but as a d:text instance
                    // This is persisted against the current locale, but as a d:text instance
                    NodePropertyValue npValue = new NodePropertyValue(DataTypeDefinition.TEXT, mlTextStr);
                    NodePropertyKey npKey = new NodePropertyKey();
                    npKey.setListIndex(collectionIndex);
                    npKey.setQnameId(propertyQNameId);
                    npKey.setLocaleId(mlTextLocaleId);
                    // Add it to the map
                    propertyMap.put(npKey, npValue);
                }
            }
            else
            {
                NodePropertyValue npValue = makeNodePropertyValue(propertyDef, value);
                NodePropertyKey npKey = new NodePropertyKey();
                npKey.setListIndex(collectionIndex);
                npKey.setQnameId(propertyQNameId);
                npKey.setLocaleId(propertyLocaleId);
                // Add it to the map
                propertyMap.put(npKey, npValue);
            }
        }
    }
    /**
     * Helper method to convert the Serializable value into a full, persistable {@link NodePropertyValue}.
     * 
     * Where the property definition is null, the value will take on the {@link DataTypeDefinition#ANY generic ANY}
     * value.
     * 
     * Collections are NOT supported. These must be split up by the calling code before calling this method. Map
     * instances are supported as plain serializable instances.
     * 
     * @param propertyDef the property dictionary definition, may be null
     * @param value the value, which will be converted according to the definition - may be null
     * @return Returns the persistable property value
     */
    public NodePropertyValue makeNodePropertyValue(PropertyDefinition propertyDef, Serializable value)
    {
        // get property attributes
        final QName propertyTypeQName;
        if (propertyDef == null) // property not recognised
        {
            // allow it for now - persisting excess properties can be useful sometimes
            propertyTypeQName = DataTypeDefinition.ANY;
        }
        else
        {
            propertyTypeQName = propertyDef.getDataType().getName();
        }
        try
        {
            NodePropertyValue propertyValue = null;
            propertyValue = new NodePropertyValue(propertyTypeQName, value);
            // done
            return propertyValue;
        }
        catch (TypeConversionException e)
        {
            throw new TypeConversionException(
                    "The property value is not compatible with the type defined for the property: \n" +
                    "   property: " + (propertyDef == null ? "unknown" : propertyDef) + "\n" +
                    "   value: " + value + "\n" +
                    "   value type: " + value.getClass(),
                    e);
        }
    }
    public Serializable getPublicProperty(
            Map propertyValues,
            QName propertyQName)
    {
        // Get the qname ID
        Pair qnamePair = qnameDAO.getQName(propertyQName);
        if (qnamePair == null)
        {
            // There is no persisted property with that QName, so we can't match anything
            return null;
        }
        Long qnameId = qnamePair.getFirst();
        // Now loop over the properties and extract those with the given qname ID
        SortedMap scratch = new TreeMap();
        for (Map.Entry entry : propertyValues.entrySet())
        {
            NodePropertyKey propertyKey = entry.getKey();
            if (propertyKey.getQnameId().equals(qnameId))
            {
                scratch.put(propertyKey, entry.getValue());
            }
        }
        // If we found anything, then collapse the properties to a Serializable
        if (scratch.size() > 0)
        {
            PropertyDefinition propertyDef = dictionaryService.getProperty(propertyQName);
            Serializable collapsedValue = collapsePropertiesWithSameQName(propertyDef, scratch);
            return collapsedValue;
        }
        else
        {
            return null;
        }
    }
    public Map convertToPublicProperties(Map propertyValues)
    {
        Map propertyMap = new HashMap(propertyValues.size(), 1.0F);
        // Shortcut
        if (propertyValues.size() == 0)
        {
            return propertyMap;
        }
        // We need to process the properties in order
        SortedMap sortedPropertyValues = new TreeMap(
                propertyValues);
        // A working map. Ordering is important.
        SortedMap scratch = new TreeMap();
        // Iterate (sorted) over the map entries and extract values with the same qname
        Long currentQNameId = Long.MIN_VALUE;
        Iterator> iterator = sortedPropertyValues.entrySet().iterator();
        while (true)
        {
            Long nextQNameId = null;
            NodePropertyKey nextPropertyKey = null;
            NodePropertyValue nextPropertyValue = null;
            // Record the next entry's values
            if (iterator.hasNext())
            {
                Map.Entry entry = iterator.next();
                nextPropertyKey = entry.getKey();
                nextPropertyValue = entry.getValue();
                nextQNameId = nextPropertyKey.getQnameId();
            }
            // If the QName is going to change, and we have some entries to process, then process them.
            if (scratch.size() > 0 && (nextQNameId == null || !nextQNameId.equals(currentQNameId)))
            {
                QName currentQName = qnameDAO.getQName(currentQNameId).getSecond();
                PropertyDefinition currentPropertyDef = dictionaryService.getProperty(currentQName);
                // We have added something to the scratch properties but the qname has just changed
                Serializable collapsedValue = null;
                // We can shortcut if there is only one value
                if (scratch.size() == 1)
                {
                    // There is no need to collapse list indexes
                    collapsedValue = collapsePropertiesWithSameQNameAndListIndex(currentPropertyDef, scratch);
                }
                else
                {
                    // There is more than one value so the list indexes need to be collapsed
                    collapsedValue = collapsePropertiesWithSameQName(currentPropertyDef, scratch);
                }
                boolean forceCollection = false;
                // If the property is multi-valued then the output property must be a collection
                if (currentPropertyDef != null && currentPropertyDef.isMultiValued())
                {
                    forceCollection = true;
                }
                else if (scratch.size() == 1 && scratch.firstKey().getListIndex().intValue() > -1)
                {
                    // This is to handle cases of collections where the property is d:any but not
                    // declared as multiple.
                    forceCollection = true;
                }
                if (forceCollection && collapsedValue != null && !(collapsedValue instanceof Collection>))
                {
                    // Can't use Collections.singletonList: ETHREEOH-1172
                    ArrayList collection = new ArrayList(1);
                    collection.add(collapsedValue);
                    collapsedValue = collection;
                }
                
                // Store the value
                propertyMap.put(currentQName, collapsedValue);
                // Reset
                scratch.clear();
            }
            if (nextQNameId != null)
            {
                // Add to the current entries
                scratch.put(nextPropertyKey, nextPropertyValue);
                currentQNameId = nextQNameId;
            }
            else
            {
                // There is no next value to process
                break;
            }
        }
        // Done
        return propertyMap;
    }
    private Serializable collapsePropertiesWithSameQName(
            PropertyDefinition propertyDef,
            SortedMap sortedPropertyValues)
    {
        Serializable result = null;
        Collection collectionResult = null;
        // A working map. Ordering is not important for this map.
        Map scratch = new HashMap(3);
        // Iterate (sorted) over the map entries and extract values with the same list index
        Integer currentListIndex = Integer.MIN_VALUE;
        Iterator> iterator = sortedPropertyValues.entrySet().iterator();
        while (true)
        {
            Integer nextListIndex = null;
            NodePropertyKey nextPropertyKey = null;
            NodePropertyValue nextPropertyValue = null;
            // Record the next entry's values
            if (iterator.hasNext())
            {
                Map.Entry entry = iterator.next();
                nextPropertyKey = entry.getKey();
                nextPropertyValue = entry.getValue();
                nextListIndex = nextPropertyKey.getListIndex();
            }
            // If the list index is going to change, and we have some entries to process, then process them.
            if (scratch.size() > 0 && (nextListIndex == null || !nextListIndex.equals(currentListIndex)))
            {
                // We have added something to the scratch properties but the index has just changed
                Serializable collapsedValue = collapsePropertiesWithSameQNameAndListIndex(propertyDef, scratch);
                // Store. If there is a value already, then we must build a collection.
                if (result == null)
                {
                    result = collapsedValue;
                }
                else if (collectionResult != null)
                {
                    // We have started a collection, so just add the value to it.
                    collectionResult.add(collapsedValue);
                }
                else
                {
                    // We already had a result, and now have another. A collection has not been
                    // started. We start a collection and explicitly keep track of it so that
                    // we don't get mixed up with collections of collections (ETHREEOH-2064).
                    collectionResult = new ArrayList(20);
                    collectionResult.add(result); // Add the first result
                    collectionResult.add(collapsedValue); // Add the new value
                    result = (Serializable) collectionResult;
                }
                // Reset
                scratch.clear();
            }
            if (nextListIndex != null)
            {
                // Add to the current entries
                scratch.put(nextPropertyKey, nextPropertyValue);
                currentListIndex = nextListIndex;
            }
            else
            {
                // There is no next value to process
                break;
            }
        }
        // Make sure that multi-valued properties are returned as a collection
        if (propertyDef != null && propertyDef.isMultiValued() && result != null && !(result instanceof Collection>))
        {
            // Can't use Collections.singletonList: ETHREEOH-1172
            ArrayList collection = new ArrayList(1);
            collection.add(result);
            result = collection;
        }
        // Done
        return result;
    }
    /**
     * At this level, the properties have the same qname and list index. They can only be separated by locale.
     * Typically, MLText will fall into this category as only.
     * 
     * If there are multiple values then they can only be separated by locale. If they are separated by locale, then
     * they have to be text-based. This means that the only way to store them is via MLText. Any other multi-locale
     * properties cannot be deserialized.
     */
    private Serializable collapsePropertiesWithSameQNameAndListIndex(
            PropertyDefinition propertyDef,
            Map propertyValues)
    {
        int propertyValuesSize = propertyValues.size();
        Serializable value = null;
        if (propertyValuesSize == 0)
        {
            // Nothing to do
            return value;
        }
        
        // Do we definitely have MLText?
        boolean isMLText = (propertyDef != null && propertyDef.getDataType().getName().equals(DataTypeDefinition.MLTEXT));
        
        // Determine the default locale ID.  The chance of it being null is vanishingly small, but ...
        Pair defaultLocalePair = localeDAO.getDefaultLocalePair();
        Long defaultLocaleId = (defaultLocalePair == null) ? null : defaultLocalePair.getFirst();
        
        Integer listIndex = null;
        for (Map.Entry entry : propertyValues.entrySet())
        {
            NodePropertyKey propertyKey = entry.getKey();
            NodePropertyValue propertyValue = entry.getValue();
            // Check that the client code has gathered the values together correctly
            if (listIndex == null)
            {
                listIndex = propertyKey.getListIndex();
            }
            else if (!listIndex.equals(propertyKey.getListIndex()))
            {
                throw new IllegalStateException("Expecting to collapse properties with same list index: " + propertyValues);
            }
            
            // Get the locale of the current value
            Long localeId = propertyKey.getLocaleId();
            boolean isDefaultLocale = EqualsHelper.nullSafeEquals(defaultLocaleId, localeId);
            
            // Get the local entry value
            Serializable entryValue = makeSerializableValue(propertyDef, propertyValue);
            
            // A default locale indicates a simple value i.e. the entry represents the whole value,
            // unless the dictionary specifically declares it to be d:mltext
            if (isDefaultLocale && !isMLText)
            {
                // Check and warn if there are other values
                if (propertyValuesSize > 1)
                {
                    logger.warn(
                            "Found localized properties along with a 'null' value in the default locale. \n" +
                            "   The localized values will be ignored; 'null' will be returned: \n" +
                            "   Default locale ID: " + defaultLocaleId + "\n" +
                            "   Property:          " + propertyDef + "\n" +
                            "   Values:            " + propertyValues);
                }
                // The entry could be null or whatever value came out
                value = entryValue;
                break;
            }
            else
            {
                // Non-default locales indicate MLText ONLY.
                Locale locale = localeDAO.getLocalePair(localeId).getSecond();
                // Note that we force a non-null value here as a null MLText object is persisted
                // just like any other null i.e. with the default locale.
                if (value == null)
                {
                    value = new MLText();
                }       // We break for other entry values, so no need to check the non-null case
                // Put the current value into the MLText object
                if (entryValue == null || entryValue instanceof String)
                {
                    // Can put in nulls and Strings
                    ((MLText)value).put(locale, (String)entryValue);    // We've checked the casts
                }
                else
                {
                    // It's a non-null non-String ... can't be added to MLText!
                    logger.warn(
                            "Found localized non-String properties. \n" +
                            "   The non-String values will be ignored: \n" +
                            "   Default locale ID: " + defaultLocaleId + "\n" +
                            "   Property:          " + propertyDef + "\n" +
                            "   Values:            " + propertyValues);
                }
            }
        }
        // Done
        return value;
    }
    /**
     * Extracts the externally-visible property from the persistable value.
     * 
     * @param propertyDef       the model property definition - may be null
     * @param propertyValue     the persisted property
     * @return                  Returns the value of the property in the format dictated by the property definition,
     *                          or null if the property value is null
     */
    public Serializable makeSerializableValue(PropertyDefinition propertyDef, NodePropertyValue propertyValue)
    {
        if (propertyValue == null)
        {
            return null;
        }
        // get property attributes
        final QName propertyTypeQName;
        if (propertyDef == null)
        {
            // allow this for now
            propertyTypeQName = DataTypeDefinition.ANY;
        }
        else
        {
            propertyTypeQName = propertyDef.getDataType().getName();
        }
        try
        {
            Serializable value = propertyValue.getValue(propertyTypeQName);
            // Handle conversions to and from ContentData
            if (value instanceof ContentDataId)
            {
                // ContentData used to be persisted as a String and then as a Long.
                // Now it has a special type to denote the ID
                Long contentDataId = ((ContentDataId) value).getId();
                ContentData contentData = contentDataDAO.getContentData(contentDataId).getSecond();
                value = new ContentDataWithId(contentData, contentDataId);
            }
            else if ((value instanceof Long) && propertyTypeQName.equals(DataTypeDefinition.CONTENT))
            {
                Long contentDataId = (Long) value;
                ContentData contentData = contentDataDAO.getContentData(contentDataId).getSecond();
                value = new ContentDataWithId(contentData, contentDataId);
            }
            // done
            return value;
        }
        catch (TypeConversionException e)
        {
            throw new TypeConversionException(
                    "The property value is not compatible with the type defined for the property: \n" +
                    "   property: " + (propertyDef == null ? "unknown" : propertyDef) + "\n" +
                    "   property value: " + propertyValue, e);
        }
    }
}