View Javadoc
1   /*
2   Copyright (c) 2026 James Ahlborn
3   
4   Licensed under the Apache License, Version 2.0 (the "License");
5   you may not use this file except in compliance with the License.
6   You may obtain a copy of the License at
7   
8       http://www.apache.org/licenses/LICENSE-2.0
9   
10  Unless required by applicable law or agreed to in writing, software
11  distributed under the License is distributed on an "AS IS" BASIS,
12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  See the License for the specific language governing permissions and
14  limitations under the License.
15  */
16  
17  package com.healthmarketscience.jackcess.impl;
18  
19  import java.io.IOException;
20  import java.nio.ByteBuffer;
21  import java.time.LocalDateTime;
22  import java.util.ArrayList;
23  import java.util.Arrays;
24  import java.util.Collections;
25  import java.util.Iterator;
26  import java.util.List;
27  import java.util.Objects;
28  
29  import com.healthmarketscience.jackcess.Column;
30  import com.healthmarketscience.jackcess.NameMap;
31  import com.healthmarketscience.jackcess.PropertyMap;
32  import com.healthmarketscience.jackcess.Table;
33  import com.healthmarketscience.jackcess.query.Query;
34  
35  /**
36   * Reads and writes a name map, which tables and queries share.  The encoding
37   * is
38   * <pre>
39   *   NameMap := magic [u32 reserved record*]
40   *   record  := guid own, u32 type, byte[16] payload, utf16 name, u32 zero
41   * </pre>
42   * The name is null terminated.  The trailing zero is left off the last
43   * record, and there is no record count, so a reader stops at the end of the
44   * data.  The payload is a date and 8 bytes of writer state for a table
45   * record, the table guid for a column or a reference record, and the writer
46   * tag for the end record.  A query which tracks no names, such as a
47   * union or a pass-through query, has the magic alone.
48   *
49   * @author James Ahlborn
50   * @usage _advanced_class_
51   */
52  public abstract class NameMapImpl implements NameMap
53  {
54    private static final int MAGIC = 0x550ECC0A;
55    private static final int MAGIC_LENGTH = 4;
56    private static final int HEADER_LENGTH = 8;
57    private static final int GUID_LENGTH = 16;
58    private static final int PAYLOAD_LENGTH = 16;
59    /** the length of a record without its name and trailing zero */
60    private static final int FIXED_LENGTH = GUID_LENGTH + 4 + PAYLOAD_LENGTH;
61    private static final int TRAILER_LENGTH = 4;
62  
63    static final int TABLE_TYPE = 0;
64    static final int REFERENCE_TYPE = 6;
65    static final int COLUMN_TYPE = 7;
66    static final int END_TYPE = 12;
67  
68    /** the word after the magic, {@code null} when the data is the magic
69        alone */
70    private final Integer _reserved;
71    final List<RecordImpl> _records = new ArrayList<>();
72  
73    /**
74     * @param bytes the value of a name map property
75     * @throws IOException if the bytes are not a valid name map
76     */
77    protected NameMapImpl(byte[] bytes) throws IOException
78    {
79      ByteBuffer bb = PageChannel.wrap(bytes);
80      if((bb.remaining() < MAGIC_LENGTH) || (bb.getInt() != MAGIC) ||
81         (bb.hasRemaining() && (bb.remaining() < MAGIC_LENGTH))) {
82        throw new IOException(
83            "Unknown name map type " +
84            ByteUtil.toHexString(PageChannel.wrap(bytes), 0,
85                                 Math.min(bytes.length, MAGIC_LENGTH)));
86      }
87      _reserved = (bb.hasRemaining() ? bb.getInt() : null);
88  
89      while(bb.hasRemaining()) {
90        if(bb.remaining() < FIXED_LENGTH) {
91          throw new IOException("Name map ends inside a record");
92        }
93        byte[] guid = ByteUtil.getBytes(bb, GUID_LENGTH);
94        int type = bb.getInt();
95        byte[] payload = ByteUtil.getBytes(bb, PAYLOAD_LENGTH);
96        String name = readName(bb);
97        if(bb.hasRemaining()) {
98          // every record but the last one ends with a zero, so a zero which
99          // ends the data could not be written back
100         if((bb.remaining() < TRAILER_LENGTH) || (bb.getInt() != 0) ||
101            !bb.hasRemaining()) {
102           throw new IOException(
103               "Name map record " + name + " has an unexpected end");
104         }
105       }
106       _records.add(new RecordImpl(type, guid, payload, name));
107     }
108   }
109 
110   /**
111    * A name is utf-16le whatever the database charset is, because only a jet 4
112    * or later file carries a name map.
113    */
114   private static String readName(ByteBuffer bb) throws IOException {
115     StringBuilder sb = new StringBuilder();
116     while(bb.remaining() >= 2) {
117       char c = bb.getChar();
118       if(c == 0) {
119         return sb.toString();
120       }
121       sb.append(c);
122     }
123     throw new IOException("Name map ends inside a name");
124   }
125 
126   /**
127    * @return {@code true} if the records of this name map can be changed
128    */
129   protected abstract boolean isWritable();
130 
131   @Override
132   public Iterator<Record> iterator() {
133     return Collections.<Record>unmodifiableList(_records).iterator();
134   }
135 
136   @Override
137   public List<Record> getTableRecords() {
138     return getRecords(TABLE_TYPE);
139   }
140 
141   @Override
142   public List<Record> getColumnRecords() {
143     return getRecords(COLUMN_TYPE);
144   }
145 
146   List<Record> getRecords(int type) {
147     List<Record> recs = new ArrayList<>();
148     for(RecordImpl rec : _records) {
149       if(rec._type == type) {
150         recs.add(rec);
151       }
152     }
153     return recs;
154   }
155 
156   @Override
157   public Record getRecord(String guid) {
158     return findRecord(guid);
159   }
160 
161   RecordImpl findRecord(String guid) {
162     for(RecordImpl rec : _records) {
163       if(((rec._type == TABLE_TYPE) || (rec._type == COLUMN_TYPE)) &&
164          guid.equalsIgnoreCase(rec.getGuid())) {
165         return rec;
166       }
167     }
168     return null;
169   }
170 
171   @Override
172   public Integer getWriterTag() {
173     for(RecordImpl rec : _records) {
174       if(rec._type == END_TYPE) {
175         return PageChannel.wrap(rec._payload).getInt();
176       }
177     }
178     return null;
179   }
180 
181   /**
182    * @return the name map encoded as it is stored in the property
183    */
184   public byte[] toBytes() {
185     if((_reserved == null) && _records.isEmpty()) {
186       return PageChannel.createBuffer(MAGIC_LENGTH).putInt(MAGIC).array();
187     }
188 
189     int len = HEADER_LENGTH;
190     for(RecordImpl rec : _records) {
191       len += FIXED_LENGTH + ((rec._name.length() + 1) * 2) + TRAILER_LENGTH;
192     }
193     if(!_records.isEmpty()) {
194       len -= TRAILER_LENGTH;
195     }
196 
197     ByteBuffer bb = PageChannel.createBuffer(len);
198     bb.putInt(MAGIC);
199     bb.putInt((_reserved != null) ? _reserved : 0);
200     for(Iterator<RecordImpl> iter = _records.iterator(); iter.hasNext(); ) {
201       RecordImpl rec = iter.next();
202       bb.put(rec._guid);
203       bb.putInt(rec._type);
204       bb.put(rec._payload);
205       for(int i = 0; i < rec._name.length(); ++i) {
206         bb.putChar(rec._name.charAt(i));
207       }
208       bb.putChar((char)0);
209       if(iter.hasNext()) {
210         bb.putInt(0);
211       }
212     }
213     return bb.array();
214   }
215 
216   /**
217    * @return the guid in the given properties, or {@code null} if there is
218    *         none
219    */
220   static String getGuid(PropertyMap props) {
221     Object guid = props.getValue(PropertyMap.GUID_PROP);
222     return ((guid instanceof String) ? (String)guid : null);
223   }
224 
225   /**
226    * @return the given guid in the form a map can be keyed on
227    */
228   static String toGuidKey(String guid) {
229     return StringUtil.toUpperCase(guid);
230   }
231 
232   /**
233    * @return the given guid encoded as a record stores it
234    * @throws IllegalArgumentException if the guid is not valid
235    */
236   static byte[] toGuidBytes(String guid) {
237     ByteBuffer guidBuf = PageChannel.createBuffer(GUID_LENGTH);
238     if(!ColumnImpl.writeGUIDValue(guidBuf, guid)) {
239       throw new IllegalArgumentException("Invalid GUID: " + guid);
240     }
241     return guidBuf.array();
242   }
243 
244   @Override
245   public String toString() {
246     return ToStringBuilder.builder(this)
247       .append(null, _records)
248       .toString();
249   }
250 
251   final class RecordImpl implements Record
252   {
253     final int _type;
254     final byte[] _guid;
255     final byte[] _payload;
256     String _name;
257 
258     RecordImpl(int type, byte[] guid, byte[] payload, String name) {
259       _type = type;
260       _guid = guid;
261       _payload = payload;
262       _name = name;
263     }
264 
265     @Override
266     public RecordType getType() {
267       switch(_type) {
268       case TABLE_TYPE:
269         return RecordType.TABLE;
270       case REFERENCE_TYPE:
271         return RecordType.REFERENCE;
272       case COLUMN_TYPE:
273         return RecordType.COLUMN;
274       case END_TYPE:
275         return RecordType.END;
276       default:
277         return RecordType.UNKNOWN;
278       }
279     }
280 
281     @Override
282     public String getGuid() {
283       return toGuid(_guid);
284     }
285 
286     @Override
287     public String getParentGuid() {
288       return (((_type == COLUMN_TYPE) || (_type == REFERENCE_TYPE)) ?
289               toGuid(_payload) : null);
290     }
291 
292     @Override
293     public String getName() {
294       return _name;
295     }
296 
297     @Override
298     public void setName(String name) {
299       if(!isWritable()) {
300         throw new UnsupportedOperationException(
301             "The name map of a query cannot be changed");
302       }
303       _name = Objects.requireNonNull(name, "name");
304     }
305 
306     @Override
307     public LocalDateTime getDate() {
308       return ((_type == TABLE_TYPE) ?
309               ColumnImpl.ldtFromLocalDateDouble(
310                   PageChannel.wrap(_payload).getDouble()) :
311               null);
312     }
313 
314     private String toGuid(byte[] bytes) {
315       return (isZero(bytes) ? null :
316               ColumnImpl.readGUIDValue(PageChannel.wrap(bytes),
317                                        PageChannel.DEFAULT_BYTE_ORDER));
318     }
319 
320     private boolean isZero(byte[] bytes) {
321       return Arrays.equals(bytes, new byte[bytes.length]);
322     }
323 
324     @Override
325     public String toString() {
326       return ToStringBuilder.valueBuilder(this)
327         .append("type", getType())
328         .appendIfNotNull("guid", getGuid())
329         .appendIfNotNull("parentGuid", getParentGuid())
330         .append("name", _name)
331         .toString();
332     }
333   }
334 
335   static final class MismatchImpl implements Mismatch
336   {
337     private final MismatchType _type;
338     private final Table _table;
339     private final Query _query;
340     private final Column _column;
341     private final Record _record;
342 
343     MismatchImpl(MismatchType type, Table table, Column column,
344                  Record record) {
345       this(type, table, null, column, record);
346     }
347 
348     MismatchImpl(MismatchType type, Query query, Record record) {
349       this(type, null, query, null, record);
350     }
351 
352     private MismatchImpl(MismatchType type, Table table, Query query,
353                          Column column, Record record) {
354       _type = type;
355       _table = table;
356       _query = query;
357       _column = column;
358       _record = record;
359     }
360 
361     @Override
362     public MismatchType getType() {
363       return _type;
364     }
365 
366     @Override
367     public Table getTable() {
368       return _table;
369     }
370 
371     @Override
372     public Query getQuery() {
373       return _query;
374     }
375 
376     @Override
377     public Column getColumn() {
378       return _column;
379     }
380 
381     @Override
382     public Record getRecord() {
383       return _record;
384     }
385 
386     @Override
387     public String toString() {
388       return ToStringBuilder.valueBuilder(this)
389         .append("type", _type)
390         .appendIfNotNull("table", ((_table != null) ?
391                                    _table.getName() : null))
392         .appendIfNotNull("query", ((_query != null) ?
393                                    _query.getName() : null))
394         .appendIfNotNull("column", ((_column != null) ?
395                                     _column.getName() : null))
396         .appendIfNotNull("record", _record)
397         .toString();
398     }
399   }
400 }