PropertyMaps.java

/*
Copyright (c) 2011 James Ahlborn

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.healthmarketscience.jackcess.impl;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import com.healthmarketscience.jackcess.DataType;
import com.healthmarketscience.jackcess.InvalidValueException;
import com.healthmarketscience.jackcess.PropertyMap;

/**
 * Collection of PropertyMap instances read from a single property data block.
 *
 * @author James Ahlborn
 */
public class PropertyMaps implements Iterable<PropertyMapImpl>
{
  /** the name of the "default" properties for a PropertyMaps instance */
  public static final String DEFAULT_NAME = "";

  private static final short PROPERTY_NAME_LIST = 0x80;
  private static final short DEFAULT_PROPERTY_VALUE_LIST = 0x00;
  private static final short COLUMN_PROPERTY_VALUE_LIST = 0x01;
  private static final short INDEX_PROPERTY_VALUE_LIST = 0x02;
  /** the length of a value entry without its data */
  private static final int VALUE_HEADER_LENGTH = 8;
  /** the longest value entry.  Its length is a short, which is read as
      signed */
  private static final int MAX_VALUE_LENGTH = Short.MAX_VALUE;

  /** maps the PropertyMap name (case-insensitive) and block type to the
      PropertyMap instance.  the type belongs in the key because a block of
      type 0x02 holds the properties of an index, and access names an index
      after its column by default, so a table can hold a column block and an
      index block with the same name */
  private final Map<Key,PropertyMapImpl> _maps =
    new LinkedHashMap<>();
  private final int _objectId;
  private final RowIdImpl _rowId;
  private final Handler _handler;
  private final Owner _owner;

  public PropertyMaps(int objectId, RowIdImpl rowId, Handler handler,
                      Owner owner) {
    _objectId = objectId;
    _rowId = rowId;
    _handler = handler;
    _owner = owner;
  }

  public int getObjectId() {
    return _objectId;
  }

  public int getSize() {
    return _maps.size();
  }

  public boolean isEmpty() {
    return _maps.isEmpty();
  }

  /**
   * @return the unnamed "default" PropertyMap in this group, creating if
   *         necessary.
   */
  public PropertyMapImpl getDefault() {
    return get(DEFAULT_NAME, DEFAULT_PROPERTY_VALUE_LIST);
  }

  /**
   * @return the PropertyMap with the given name in this group, creating if
   *         necessary
   */
  public PropertyMapImpl get(String name) {
    return get(name, COLUMN_PROPERTY_VALUE_LIST);
  }

  /**
   * @return the PropertyMap for the index with the given name in this group,
   *         creating if necessary.  An index has its own block, and access
   *         names an index after its column by default, so this is a separate
   *         map from the one {@link #get} returns for the same name.
   */
  public PropertyMapImpl getIndex(String name) {
    return get(name, INDEX_PROPERTY_VALUE_LIST);
  }

  /**
   * @return the PropertyMap with the given name and type in this group,
   *         creating if necessary
   */
  private PropertyMapImpl get(String name, short type) {
    Key key = new Key(name, type);
    PropertyMapImpl map = _maps.get(key);
    if(map == null) {
      map = new PropertyMapImpl(name, type, this);
      _maps.put(key, map);
    }
    return map;
  }

  /**
   * The name and block type which together identify a PropertyMap within one
   * group.
   */
  private static final class Key
  {
    private final String _lookupName;
    private final short _type;

    private Key(String name, short type) {
      _lookupName = DatabaseImpl.toLookupName(name);
      _type = type;
    }

    @Override
    public int hashCode() {
      return _lookupName.hashCode() + _type;
    }

    @Override
    public boolean equals(Object o) {
      return ((this == o) ||
              ((o instanceof Key) && (_type == ((Key)o)._type) &&
               _lookupName.equals(((Key)o)._lookupName)));
    }

    @Override
    public String toString() {
      return _lookupName + "[" + _type + "]";
    }
  }

  @Override
  public Iterator<PropertyMapImpl> iterator() {
    return _maps.values().iterator();
  }

  public byte[] write() throws IOException {
    return _handler.write(this);
  }

  public void save() throws IOException {
    _handler.save(this);
    if(_owner != null) {
      _owner.propertiesUpdated();
    }
  }

  @Override
  public String toString() {
    return ToStringBuilder.builder(this)
      .append(null, _maps.values())
      .toString();
  }

  public static String getTrimmedStringProperty(
      PropertyMap props, String propName)
  {
    return StringUtil.trimToNull((String)props.getValue(propName));
  }

  /**
   * Utility class for reading/writing property blocks.
   */
  static final class Handler
  {
    /** the current database */
    private final DatabaseImpl _database;
    /** the system table "property" column */
    private final ColumnImpl _propCol;
    /** cache of PropColumns used to read/write property values */
    private final Map<DataType,PropColumn> _columns =
      new HashMap<>();

    Handler(DatabaseImpl database) {
      _database = database;
      _propCol = _database.getSystemCatalog().getColumn(
          DatabaseImpl.CAT_COL_PROPS);
    }

    /**
     * @return a PropertyMaps instance decoded from the given bytes (always
     *         returns non-{@code null} result).
     */
    public PropertyMaps read(byte[] propBytes, int objectId,
                             RowIdImpl rowId, Owner owner)
      throws IOException
    {
      PropertyMaps maps = new PropertyMaps(objectId, rowId, this, owner);
      if((propBytes == null) || (propBytes.length == 0)) {
        return maps;
      }

      ByteBuffer bb = PageChannel.wrap(propBytes);

      // check for known header
      boolean knownType = false;
      for(byte[] tmpType : JetFormat.PROPERTY_MAP_TYPES) {
        if(ByteUtil.matchesRange(bb, bb.position(), tmpType)) {
          ByteUtil.forward(bb, tmpType.length);
          knownType = true;
          break;
        }
      }

      if(!knownType) {
        throw new IOException("Unknown property map type " +
                              ByteUtil.toHexString(bb, 4));
      }

      // parse each data "chunk"
      List<String> propNames = null;
      while(bb.hasRemaining()) {

        requireRemaining(bb, 6, "chunk header");
        int len = bb.getInt();
        short type = bb.getShort();
        int endPos = requireEnd(bb, bb.position() + len - 6, "chunk");

        ByteBuffer bbBlock = PageChannel.narrowBuffer(bb, bb.position(),
                                                      endPos);

        if(type == PROPERTY_NAME_LIST) {
          propNames = readPropertyNames(bbBlock);
        } else {
          readPropertyValues(bbBlock, propNames, type, maps);
        }

        bb.position(endPos);
      }

      return maps;
    }

    /**
     * @return a byte[] encoded from the given PropertyMaps instance
     */
    public byte[] write(PropertyMaps maps)
      throws IOException
    {
      if(maps == null) {
        return null;
      }

      ByteArrayBuilder bab = new ByteArrayBuilder();

      bab.put(_database.getFormat().PROPERTY_MAP_TYPE);

      // grab the property names from all the maps
      Set<String> propNames = new LinkedHashSet<>();
      for(PropertyMapImpl propMap : maps) {
        for(PropertyMap.Property prop : propMap) {
          propNames.add(prop.getName());
        }
      }

      if(propNames.isEmpty()) {
        return null;
      }

      // write the full set of property names
      writeBlock(null, propNames, PROPERTY_NAME_LIST, bab);

      // write all the map values
      for(PropertyMapImpl propMap : maps) {
        if(!propMap.isEmpty()) {
          writeBlock(propMap, propNames, propMap.getType(), bab);
        }
      }

      return bab.toArray();
    }

    /**
     * Saves PropertyMaps instance to the db.
     */
    public void save(PropertyMaps maps) throws IOException
    {
      RowIdImpl rowId = maps._rowId;
      if(rowId == null) {
        throw new IllegalStateException(
            "PropertyMaps cannot be saved without a row id");
      }

      byte[] mapsBytes = write(maps);

      // for now assume all properties come from system catalog table
      _propCol.getTable().updateValue(_propCol, rowId, mapsBytes);
    }

    private void writeBlock(
        PropertyMapImpl propMap, Set<String> propNames,
        short blockType, ByteArrayBuilder bab)
      throws IOException
    {
      int blockStartPos = bab.position();
      bab.reserveInt()
        .putShort(blockType);

      if(blockType == PROPERTY_NAME_LIST) {
        writePropertyNames(propNames, bab);
      } else {
        writePropertyValues(propMap, propNames, bab);
      }

      int len = bab.position() - blockStartPos;
      bab.putInt(blockStartPos, len);
    }

    /**
     * @return the property names parsed from the given data chunk
     */
    private List<String> readPropertyNames(ByteBuffer bbBlock)
      throws IOException
    {
      List<String> names = new ArrayList<>();
      while(bbBlock.hasRemaining()) {
        names.add(readPropName(bbBlock));
      }
      return names;
    }

    private void writePropertyNames(Set<String> propNames,
                                    ByteArrayBuilder bab) {
      for(String propName : propNames) {
        writePropName(propName, bab);
      }
    }

    /**
     * @return the PropertyMap created from the values parsed from the given
     *         data chunk combined with the given property names
     */
    private PropertyMapImpl readPropertyValues(
        ByteBuffer bbBlock, List<String> propNames, short blockType,
        PropertyMaps maps)
      throws IOException
    {
      String mapName = DEFAULT_NAME;

      if(bbBlock.hasRemaining()) {

        // read the map name, if any
        requireRemaining(bbBlock, 4, "map name header");
        int nameBlockLen = bbBlock.getInt();
        int endPos = requireEnd(bbBlock, bbBlock.position() + nameBlockLen - 4,
                                "map name");
        if(nameBlockLen > 6) {
          mapName = readPropName(bbBlock);
        }
        bbBlock.position(endPos);
      }

      PropertyMapImpl map = maps.get(mapName, blockType);

      // read the values
      while(bbBlock.hasRemaining()) {

        requireRemaining(bbBlock, 8, "value header");
        int valLen = bbBlock.getShort();
        int endPos = requireEnd(bbBlock, bbBlock.position() + valLen - 2,
                                "value");
        byte flags = bbBlock.get();
        DataType dataType = DataType.fromByte(bbBlock.get());
        int nameIdx = bbBlock.getShort();
        int dataSize = bbBlock.getShort();

        // the name list is a chunk of its own, which a corrupt block can
        // leave out or put after the values which use it
        if((propNames == null) || (nameIdx < 0) ||
           (nameIdx >= propNames.size())) {
          throw new IOException(
              "Property name index " + nameIdx + " is not one of the " +
              ((propNames != null) ? propNames.size() : 0) + " known names");
        }
        String propName = propNames.get(nameIdx);
        PropColumn col = getColumn(dataType, propName, dataSize, null);

        if((dataSize < 0) || (dataSize > bbBlock.remaining())) {
          throw new IOException(
              "Property " + propName + " holds " + dataSize + " bytes, " +
              bbBlock.remaining() + " left in the block");
        }
        // a fixed length value which is short would underflow the decode
        DataType colType = col.getType();
        if(!colType.isVariableLength() &&
           (dataSize < colType.getFixedSize())) {
          throw new IOException(
              "Property " + propName + " holds " + dataSize +
              " bytes, too few for " + colType);
        }

        byte[] data = ByteUtil.getBytes(bbBlock, dataSize);
        Object value = col.read(data);

        map.putStored(propName, dataType, value, flags);

        bbBlock.position(endPos);
      }

      return map;
    }

    private void writePropertyValues(
        PropertyMapImpl propMap, Set<String> propNames, ByteArrayBuilder bab)
      throws IOException
    {
      // write the map name, if any
      String mapName = propMap.getName();
      int blockStartPos = bab.position();
      bab.reserveInt();
      writePropName(mapName, bab);
      int len = bab.position() - blockStartPos;
      bab.putInt(blockStartPos, len);

      // write the map values
      int nameIdx = 0;
      for(String propName : propNames) {

        PropertyMapImpl.PropertyImpl prop = (PropertyMapImpl.PropertyImpl)
          propMap.get(propName);

        if(prop != null) {

          Object value = prop.getValue();
          if(value != null) {

            int valStartPos = bab.position();
            bab.reserveShort();

            // the whole byte, not just the ddl bit
            bab.put(prop.getFlags());
            bab.put(prop.getType().getValue());
            bab.putShort((short)nameIdx);

            PropColumn col = getColumn(prop.getType(), propName, -1, value);

            ByteBuffer data;
            try {
              data = col.write(value, _database.getFormat().MAX_ROW_SIZE);
            } catch(InvalidValueException e) {
              throw new InvalidValueException(
                  "Invalid value for property " + propName + ": " +
                  e.getMessage(), e);
            }
            if((VALUE_HEADER_LENGTH + data.remaining()) > MAX_VALUE_LENGTH) {
              throw new InvalidValueException(
                  "Invalid value for property " + propName + ": " +
                  data.remaining() + " bytes is too long, max " +
                  (MAX_VALUE_LENGTH - VALUE_HEADER_LENGTH) + " (Db=" +
                  _database.getName() + ")");
            }

            bab.putShort((short)data.remaining());
            bab.put(data);

            len = bab.position() - valStartPos;
            bab.putShort(valStartPos, (short)len);
          }
        }

        ++nameIdx;
      }
    }

    /**
     * A length in a property block is a signed field read from the file, so
     * it can promise more bytes than the block holds.
     */
    private static void requireRemaining(ByteBuffer buffer, int len,
                                         String what)
      throws IOException
    {
      if(buffer.remaining() < len) {
        throw new IOException(
            "Property " + what + " is " + len + " bytes, " +
            buffer.remaining() + " left in the block");
      }
    }

    /**
     * @return the given end position, checked against the block
     */
    private static int requireEnd(ByteBuffer buffer, int endPos, String what)
      throws IOException
    {
      if((endPos < buffer.position()) || (endPos > buffer.limit())) {
        throw new IOException(
            "Property " + what + " ends at " + endPos + ", outside " +
            buffer.position() + " to " + buffer.limit());
      }
      return endPos;
    }

    /**
     * Reads a property name from the given data block
     */
    private String readPropName(ByteBuffer buffer) throws IOException {
      requireRemaining(buffer, 2, "name length");
      int nameLength = buffer.getShort();
      if((nameLength < 0) || (nameLength > buffer.remaining())) {
        throw new IOException(
            "Property name is " + nameLength + " bytes, " +
            buffer.remaining() + " left in the block");
      }
      byte[] nameBytes = ByteUtil.getBytes(buffer, nameLength);
      return ColumnImpl.decodeUncompressedText(nameBytes, _database.getCharset());
    }

    /**
     * Writes a property name to the given data block
     */
    private void writePropName(String propName, ByteArrayBuilder bab) {
      ByteBuffer textBuf = ColumnImpl.encodeUncompressedText(
          propName, _database.getCharset());
      bab.putShort((short)textBuf.remaining());
      bab.put(textBuf);
    }

    /**
     * Gets a PropColumn capable of reading/writing a property of the given
     * DataType
     */
    private PropColumn getColumn(DataType dataType, String propName,
                                 int dataSize, Object value)
      throws IOException
    {

      if(isPseudoGuidColumn(dataType, propName, dataSize, value)) {
        dataType = DataType.GUID;
      }

      PropColumn col = _columns.get(dataType);

      if(col == null) {

        // translate long value types into simple types
        DataType colType = dataType;
        if(dataType == DataType.MEMO) {
          colType = DataType.TEXT;
        } else if(dataType == DataType.OLE) {
          colType = DataType.BINARY;
        }

        // create column with ability to read/write the given data type
        if(colType == DataType.BOOLEAN) {
          col = new BooleanPropColumn();
        } else if(dataType == DataType.MEMO) {
          // the length of the whole value entry is checked when it is
          // written
          col = new PropColumn(colType, Integer.MAX_VALUE);
        } else {
          col = new PropColumn(colType);
        }

        _columns.put(dataType, col);
      }

      return col;
    }

    private static boolean isPseudoGuidColumn(
        DataType dataType, String propName, int dataSize, Object value)
      throws IOException
    {
      // guids seem to be marked as "binary" fields
      return((dataType == DataType.BINARY) &&
             ((dataSize == DataType.GUID.getFixedSize()) ||
              ((dataSize == -1) && ColumnImpl.isGUIDValue(value))) &&
             PropertyMap.GUID_PROP.equalsIgnoreCase(propName));
    }

    /**
     * Column adapted to work w/out a Table.
     */
    private class PropColumn extends ColumnImpl
    {
      /** the longest text value this column writes */
      private final int _maxChars;

      private PropColumn(DataType type) {
        super(null, null, type, 0, 0, 0);
        _maxChars = getLengthInUnits();
      }

      private PropColumn(DataType type, int maxChars) {
        super(null, null, type, 0, 0, 0);
        _maxChars = maxChars;
      }

      @Override
      public DatabaseImpl getDatabase() {
        return _database;
      }

      @Override
      protected ByteBuffer writeRealData(Object obj, int remainingRowLength,
                                         ByteOrder order)
        throws IOException
      {
        if(getType() == DataType.TEXT) {
          return encodeTextValue(obj, 0, _maxChars, false).order(order);
        }
        return super.writeRealData(obj, remainingRowLength, order);
      }

      @Override
      String withErrorContext(String msg) {
        // there is no table, so the property name is added by the caller
        return msg + " (Db=" + _database.getName() + ")";
      }
    }

    /**
     * Normal boolean columns do not write into the actual row data, so we
     * need to do a little extra work.
     */
    private final class BooleanPropColumn extends PropColumn
    {
      private BooleanPropColumn() {
        super(DataType.BOOLEAN);
      }

      @Override
      public Object read(byte[] data) {
        return ((data[0] != 0) ? Boolean.TRUE : Boolean.FALSE);
      }

      @Override
      public ByteBuffer write(Object obj, int remainingRowLength)
      {
        ByteBuffer buffer = PageChannel.createBuffer(1);
        buffer.put(((Number)booleanToInteger(obj)).byteValue());
        buffer.flip();
        return buffer;
      }
    }
  }

  /**
   * Utility interface for the object which owns the PropertyMaps
   */
  public static interface Owner {

    /**
     * Invoked when new properties are saved.
     */
    public void propertiesUpdated() throws IOException;
  }
}