QueryNameMapImpl.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.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import com.healthmarketscience.jackcess.TableMetaData;
import com.healthmarketscience.jackcess.query.Query;

/**
 * The name map of a query, which ms access uses to repair the sql of the query
 * when a table or column it uses is renamed.  Its records cannot be changed,
 * for the reason {@link com.healthmarketscience.jackcess.NameMap} gives.
 *
 * @author James Ahlborn
 * @usage _advanced_class_
 */
public class QueryNameMapImpl extends NameMapImpl
{
  /** the database which holds the query */
  private final DatabaseImpl _database;

  private QueryNameMapImpl(byte[] bytes, DatabaseImpl database)
    throws IOException
  {
    super(bytes);
    _database = database;
  }

  /**
   * @param bytes the value of the name map property of a query
   * @param database the database which holds the query
   * @return the decoded name map
   * @throws IOException if the bytes are not a valid name map
   */
  public static QueryNameMapImpl read(byte[] bytes, DatabaseImpl database)
    throws IOException
  {
    return new QueryNameMapImpl(bytes, database);
  }

  @Override
  protected boolean isWritable() {
    return false;
  }

  /**
   * Compares each record with the table or column its guid names, which is
   * how ms access finds the objects a query uses.  A record whose guid names
   * nothing is found by its name instead.  A record for a source query is
   * compared with that query, and the column records under it are not
   * checked, because they carry the guids of the table columns the source
   * query reads.  The records for a linked table are not checked, because the
   * guid of a link is on the link and not on the table it opens.
   */
  @Override
  public List<Mismatch> findMismatches() throws IOException {
    DatabaseImpl db = _database;
    List<Mismatch> mismatches = new ArrayList<>();
    Map<String,TableImpl> tablesByGuid = null;
    // the table found for each table record, keyed by the record guid, so a
    // column record can find its table through its parent guid
    Map<String,TableImpl> recordTables = new HashMap<>();
    // the guids of the table records for a link or a source query, whose
    // column records are not checked
    Set<String> skippedGuids = new HashSet<>();
    SourceQueries queries = null;

    for(RecordImpl rec : _records) {
      if(rec._type == TABLE_TYPE) {
        if(tablesByGuid == null) {
          tablesByGuid = readTablesByGuid(db);
        }
        String guid = rec.getGuid();
        TableImpl table = ((guid != null) ?
                           tablesByGuid.get(toGuidKey(guid)) : null);
        if(table != null) {
          recordTables.put(toGuidKey(guid), table);
          if(!rec._name.equals(table.getName())) {
            mismatches.add(new MismatchImpl(MismatchType.NAME_DIFFERS, table,
                                            null, rec));
          }
          continue;
        }

        if(queries == null) {
          queries = new SourceQueries(db);
        }
        Query query = ((guid != null) ? queries.getByGuid(guid) : null);
        if(query != null) {
          skippedGuids.add(toGuidKey(guid));
          if(!rec._name.equals(query.getName())) {
            mismatches.add(new MismatchImpl(MismatchType.NAME_DIFFERS, query,
                                            rec));
          }
          continue;
        }

        TableMetaData metaData = db.getTableMetaData(rec._name);
        if(metaData == null) {
          query = queries.getByName(rec._name);
          if(query == null) {
            mismatches.add(new MismatchImpl(MismatchType.MISSING_OBJECT, null,
                                            null, rec));
            continue;
          }
          if(getGuid(query.getProperties()) != null) {
            mismatches.add(new MismatchImpl(MismatchType.GUID_DIFFERS, query,
                                            rec));
          }
        }
        if((metaData == null) || metaData.isLinked()) {
          if(guid != null) {
            skippedGuids.add(toGuidKey(guid));
          }
          continue;
        }
        // getTableMetaData finds a system table, which a query can read, so
        // open it the same way
        table = db.getTable(rec._name, true);
        if(guid != null) {
          recordTables.put(toGuidKey(guid), table);
        }
        if(getGuid(table.getProperties()) != null) {
          mismatches.add(new MismatchImpl(MismatchType.GUID_DIFFERS, table,
                                          null, rec));
        }

      } else if(rec._type == COLUMN_TYPE) {
        String parentGuid = rec.getParentGuid();
        String parentKey = ((parentGuid != null) ? toGuidKey(parentGuid) :
                            null);
        if(skippedGuids.contains(parentKey)) {
          continue;
        }
        TableImpl table = recordTables.get(parentKey);
        if(table == null) {
          mismatches.add(new MismatchImpl(MismatchType.MISSING_OBJECT, null,
                                          null, rec));
          continue;
        }

        ColumnImpl col = findColumn(table, rec.getGuid());
        if(col != null) {
          if(!rec._name.equals(col.getName())) {
            mismatches.add(new MismatchImpl(MismatchType.NAME_DIFFERS, table,
                                            col, rec));
          }
          continue;
        }

        col = (table.hasColumn(rec._name) ? table.getColumn(rec._name) :
               null);
        if(col == null) {
          mismatches.add(new MismatchImpl(MismatchType.MISSING_OBJECT, table,
                                          null, rec));
        } else if(getGuid(col.getProperties()) != null) {
          mismatches.add(new MismatchImpl(MismatchType.GUID_DIFFERS, table,
                                          col, rec));
        }
      }
    }

    return mismatches;
  }

  /**
   * @return the local tables of the database, keyed by their guid
   */
  private static Map<String,TableImpl> readTablesByGuid(DatabaseImpl db)
    throws IOException
  {
    Map<String,TableImpl> tables = new HashMap<>();
    for(String name : db.getTableNames()) {
      if(db.getTableMetaData(name).isLinked()) {
        continue;
      }
      TableImpl table = db.getTable(name);
      String guid = getGuid(table.getProperties());
      if(guid != null) {
        tables.put(toGuidKey(guid), table);
      }
    }
    return tables;
  }

  /**
   * The queries of the database, by guid and by name, which a query can read
   * like a table.
   */
  private static final class SourceQueries
  {
    private final Map<String,Query> _byGuid = new HashMap<>();
    private final Map<String,Query> _byName = new HashMap<>();

    private SourceQueries(DatabaseImpl db) throws IOException {
      for(Query query : db.getQueries()) {
        _byName.put(DatabaseImpl.toLookupName(query.getName()), query);
        String guid = getGuid(query.getProperties());
        if(guid != null) {
          _byGuid.put(toGuidKey(guid), query);
        }
      }
    }

    private Query getByGuid(String guid) {
      return _byGuid.get(toGuidKey(guid));
    }

    private Query getByName(String name) {
      return _byName.get(DatabaseImpl.toLookupName(name));
    }
  }

  private static ColumnImpl findColumn(TableImpl table, String guid)
    throws IOException
  {
    for(ColumnImpl col : table.getColumns()) {
      String colGuid = getGuid(col.getProperties());
      if((colGuid != null) && colGuid.equalsIgnoreCase(guid)) {
        return col;
      }
    }
    return null;
  }
}