NameMapImpl.java

/*
Copyright (c) 2026 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.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;

import com.healthmarketscience.jackcess.Column;
import com.healthmarketscience.jackcess.NameMap;
import com.healthmarketscience.jackcess.PropertyMap;
import com.healthmarketscience.jackcess.Table;
import com.healthmarketscience.jackcess.query.Query;

/**
 * Reads and writes a name map, which tables and queries share.  The encoding
 * is
 * <pre>
 *   NameMap := magic [u32 reserved record*]
 *   record  := guid own, u32 type, byte[16] payload, utf16 name, u32 zero
 * </pre>
 * The name is null terminated.  The trailing zero is left off the last
 * record, and there is no record count, so a reader stops at the end of the
 * data.  The payload is a date and 8 bytes of writer state for a table
 * record, the table guid for a column or a reference record, and the writer
 * tag for the end record.  A query which tracks no names, such as a
 * union or a pass-through query, has the magic alone.
 *
 * @author James Ahlborn
 * @usage _advanced_class_
 */
public abstract class NameMapImpl implements NameMap
{
  private static final int MAGIC = 0x550ECC0A;
  private static final int MAGIC_LENGTH = 4;
  private static final int HEADER_LENGTH = 8;
  private static final int GUID_LENGTH = 16;
  private static final int PAYLOAD_LENGTH = 16;
  /** the length of a record without its name and trailing zero */
  private static final int FIXED_LENGTH = GUID_LENGTH + 4 + PAYLOAD_LENGTH;
  private static final int TRAILER_LENGTH = 4;

  static final int TABLE_TYPE = 0;
  static final int REFERENCE_TYPE = 6;
  static final int COLUMN_TYPE = 7;
  static final int END_TYPE = 12;

  /** the word after the magic, {@code null} when the data is the magic
      alone */
  private final Integer _reserved;
  final List<RecordImpl> _records = new ArrayList<>();

  /**
   * @param bytes the value of a name map property
   * @throws IOException if the bytes are not a valid name map
   */
  protected NameMapImpl(byte[] bytes) throws IOException
  {
    ByteBuffer bb = PageChannel.wrap(bytes);
    if((bb.remaining() < MAGIC_LENGTH) || (bb.getInt() != MAGIC) ||
       (bb.hasRemaining() && (bb.remaining() < MAGIC_LENGTH))) {
      throw new IOException(
          "Unknown name map type " +
          ByteUtil.toHexString(PageChannel.wrap(bytes), 0,
                               Math.min(bytes.length, MAGIC_LENGTH)));
    }
    _reserved = (bb.hasRemaining() ? bb.getInt() : null);

    while(bb.hasRemaining()) {
      if(bb.remaining() < FIXED_LENGTH) {
        throw new IOException("Name map ends inside a record");
      }
      byte[] guid = ByteUtil.getBytes(bb, GUID_LENGTH);
      int type = bb.getInt();
      byte[] payload = ByteUtil.getBytes(bb, PAYLOAD_LENGTH);
      String name = readName(bb);
      if(bb.hasRemaining()) {
        // every record but the last one ends with a zero, so a zero which
        // ends the data could not be written back
        if((bb.remaining() < TRAILER_LENGTH) || (bb.getInt() != 0) ||
           !bb.hasRemaining()) {
          throw new IOException(
              "Name map record " + name + " has an unexpected end");
        }
      }
      _records.add(new RecordImpl(type, guid, payload, name));
    }
  }

  /**
   * A name is utf-16le whatever the database charset is, because only a jet 4
   * or later file carries a name map.
   */
  private static String readName(ByteBuffer bb) throws IOException {
    StringBuilder sb = new StringBuilder();
    while(bb.remaining() >= 2) {
      char c = bb.getChar();
      if(c == 0) {
        return sb.toString();
      }
      sb.append(c);
    }
    throw new IOException("Name map ends inside a name");
  }

  /**
   * @return {@code true} if the records of this name map can be changed
   */
  protected abstract boolean isWritable();

  @Override
  public Iterator<Record> iterator() {
    return Collections.<Record>unmodifiableList(_records).iterator();
  }

  @Override
  public List<Record> getTableRecords() {
    return getRecords(TABLE_TYPE);
  }

  @Override
  public List<Record> getColumnRecords() {
    return getRecords(COLUMN_TYPE);
  }

  List<Record> getRecords(int type) {
    List<Record> recs = new ArrayList<>();
    for(RecordImpl rec : _records) {
      if(rec._type == type) {
        recs.add(rec);
      }
    }
    return recs;
  }

  @Override
  public Record getRecord(String guid) {
    return findRecord(guid);
  }

  RecordImpl findRecord(String guid) {
    for(RecordImpl rec : _records) {
      if(((rec._type == TABLE_TYPE) || (rec._type == COLUMN_TYPE)) &&
         guid.equalsIgnoreCase(rec.getGuid())) {
        return rec;
      }
    }
    return null;
  }

  @Override
  public Integer getWriterTag() {
    for(RecordImpl rec : _records) {
      if(rec._type == END_TYPE) {
        return PageChannel.wrap(rec._payload).getInt();
      }
    }
    return null;
  }

  /**
   * @return the name map encoded as it is stored in the property
   */
  public byte[] toBytes() {
    if((_reserved == null) && _records.isEmpty()) {
      return PageChannel.createBuffer(MAGIC_LENGTH).putInt(MAGIC).array();
    }

    int len = HEADER_LENGTH;
    for(RecordImpl rec : _records) {
      len += FIXED_LENGTH + ((rec._name.length() + 1) * 2) + TRAILER_LENGTH;
    }
    if(!_records.isEmpty()) {
      len -= TRAILER_LENGTH;
    }

    ByteBuffer bb = PageChannel.createBuffer(len);
    bb.putInt(MAGIC);
    bb.putInt((_reserved != null) ? _reserved : 0);
    for(Iterator<RecordImpl> iter = _records.iterator(); iter.hasNext(); ) {
      RecordImpl rec = iter.next();
      bb.put(rec._guid);
      bb.putInt(rec._type);
      bb.put(rec._payload);
      for(int i = 0; i < rec._name.length(); ++i) {
        bb.putChar(rec._name.charAt(i));
      }
      bb.putChar((char)0);
      if(iter.hasNext()) {
        bb.putInt(0);
      }
    }
    return bb.array();
  }

  /**
   * @return the guid in the given properties, or {@code null} if there is
   *         none
   */
  static String getGuid(PropertyMap props) {
    Object guid = props.getValue(PropertyMap.GUID_PROP);
    return ((guid instanceof String) ? (String)guid : null);
  }

  /**
   * @return the given guid in the form a map can be keyed on
   */
  static String toGuidKey(String guid) {
    return StringUtil.toUpperCase(guid);
  }

  /**
   * @return the given guid encoded as a record stores it
   * @throws IllegalArgumentException if the guid is not valid
   */
  static byte[] toGuidBytes(String guid) {
    ByteBuffer guidBuf = PageChannel.createBuffer(GUID_LENGTH);
    if(!ColumnImpl.writeGUIDValue(guidBuf, guid)) {
      throw new IllegalArgumentException("Invalid GUID: " + guid);
    }
    return guidBuf.array();
  }

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

  final class RecordImpl implements Record
  {
    final int _type;
    final byte[] _guid;
    final byte[] _payload;
    String _name;

    RecordImpl(int type, byte[] guid, byte[] payload, String name) {
      _type = type;
      _guid = guid;
      _payload = payload;
      _name = name;
    }

    @Override
    public RecordType getType() {
      switch(_type) {
      case TABLE_TYPE:
        return RecordType.TABLE;
      case REFERENCE_TYPE:
        return RecordType.REFERENCE;
      case COLUMN_TYPE:
        return RecordType.COLUMN;
      case END_TYPE:
        return RecordType.END;
      default:
        return RecordType.UNKNOWN;
      }
    }

    @Override
    public String getGuid() {
      return toGuid(_guid);
    }

    @Override
    public String getParentGuid() {
      return (((_type == COLUMN_TYPE) || (_type == REFERENCE_TYPE)) ?
              toGuid(_payload) : null);
    }

    @Override
    public String getName() {
      return _name;
    }

    @Override
    public void setName(String name) {
      if(!isWritable()) {
        throw new UnsupportedOperationException(
            "The name map of a query cannot be changed");
      }
      _name = Objects.requireNonNull(name, "name");
    }

    @Override
    public LocalDateTime getDate() {
      return ((_type == TABLE_TYPE) ?
              ColumnImpl.ldtFromLocalDateDouble(
                  PageChannel.wrap(_payload).getDouble()) :
              null);
    }

    private String toGuid(byte[] bytes) {
      return (isZero(bytes) ? null :
              ColumnImpl.readGUIDValue(PageChannel.wrap(bytes),
                                       PageChannel.DEFAULT_BYTE_ORDER));
    }

    private boolean isZero(byte[] bytes) {
      return Arrays.equals(bytes, new byte[bytes.length]);
    }

    @Override
    public String toString() {
      return ToStringBuilder.valueBuilder(this)
        .append("type", getType())
        .appendIfNotNull("guid", getGuid())
        .appendIfNotNull("parentGuid", getParentGuid())
        .append("name", _name)
        .toString();
    }
  }

  static final class MismatchImpl implements Mismatch
  {
    private final MismatchType _type;
    private final Table _table;
    private final Query _query;
    private final Column _column;
    private final Record _record;

    MismatchImpl(MismatchType type, Table table, Column column,
                 Record record) {
      this(type, table, null, column, record);
    }

    MismatchImpl(MismatchType type, Query query, Record record) {
      this(type, null, query, null, record);
    }

    private MismatchImpl(MismatchType type, Table table, Query query,
                         Column column, Record record) {
      _type = type;
      _table = table;
      _query = query;
      _column = column;
      _record = record;
    }

    @Override
    public MismatchType getType() {
      return _type;
    }

    @Override
    public Table getTable() {
      return _table;
    }

    @Override
    public Query getQuery() {
      return _query;
    }

    @Override
    public Column getColumn() {
      return _column;
    }

    @Override
    public Record getRecord() {
      return _record;
    }

    @Override
    public String toString() {
      return ToStringBuilder.valueBuilder(this)
        .append("type", _type)
        .appendIfNotNull("table", ((_table != null) ?
                                   _table.getName() : null))
        .appendIfNotNull("query", ((_query != null) ?
                                   _query.getName() : null))
        .appendIfNotNull("column", ((_column != null) ?
                                    _column.getName() : null))
        .appendIfNotNull("record", _record)
        .toString();
    }
  }
}