ComplexColumnCreator.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.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import com.healthmarketscience.jackcess.Column;
import com.healthmarketscience.jackcess.ColumnBuilder;
import com.healthmarketscience.jackcess.DataType;
import com.healthmarketscience.jackcess.IndexBuilder;
import com.healthmarketscience.jackcess.PropertyMap;
import com.healthmarketscience.jackcess.TableBuilder;
import com.healthmarketscience.jackcess.complex.ComplexDataType;
/**
* Creates the tables and the rows which support a complex column. The column
* itself holds only an id per row, and the values live in a "flat" table whose
* shape is copied from a "type" table. A row in {@code MSysComplexColumns}
* ties the two to the column.
*
* @author James Ahlborn
* @usage _advanced_class_
*/
public class ComplexColumnCreator
{
/** the name ms access gives to every version history complex column */
public static final String VERSION_HISTORY_COL_NAME =
"VersionHistory_F5F8918F-0A3F-4DA9-AE71-184EE5012880";
/** the date column of every version history type table */
private static final String MODIFIED_COL_NAME =
"Modified_F9B5E312-4155-4c59-9AAE-391C1B295827";
/** the prefix of the type table of a multi-value or attachment column */
private static final String TYPE_TABLE_PREFIX = "MSysComplexType_";
/** the prefix of the type table of a version history column */
private static final String VERSION_HISTORY_TYPE_PREFIX =
"MSysComplexTypeVH_";
/** the prefix of the table which holds the values of a complex column */
private static final String FLAT_TABLE_PREFIX = "f_";
/** the name ms access gives the primary key index of every flat table */
private static final String FLAT_PK_INDEX_NAME = "MSysComplexPKIndex";
private static final String COMPLEX_COLUMNS_TABLE = "MSysComplexColumns";
private static final String COL_COMPLEX_ID = "ComplexID";
private static final String COL_TABLE_ID = "ConceptualTableID";
private static final String COL_COMPLEX_TYPE_OBJECT_ID =
"ComplexTypeObjectID";
private static final String COL_FLAT_TABLE_ID = "FlatTableID";
private static final String COL_COLUMN_NAME = "ColumnName";
/** the MSysObjects flags of a table which holds a complex column, without
which ms access never looks for the values */
static final int CATALOG_FLAGS_HAS_COMPLEX = 0x00040000;
/** the MSysObjects flags of a flat table. the low bits mark it as complex
support, without which ms access will not open the table which holds the
column, and 0x80000000 hides it from the navigation pane */
private static final int CATALOG_FLAGS_FLAT_TABLE = 0x800A0000;
/** the MSysObjects flags of a version history type table */
private static final int CATALOG_FLAGS_TYPE_TABLE = 0x80030000;
private ComplexColumnCreator() {
}
/**
* @return the name of the shipped type table which holds the shape of a
* multi-value of the given type, or {@code null} for a type which
* has none
*/
private static String getTypeTableName(DataType valueType) {
switch(valueType) {
case BYTE:
return TYPE_TABLE_PREFIX + "UnsignedByte";
case INT:
return TYPE_TABLE_PREFIX + "Short";
case LONG:
return TYPE_TABLE_PREFIX + "Long";
case FLOAT:
return TYPE_TABLE_PREFIX + "IEEESingle";
case DOUBLE:
return TYPE_TABLE_PREFIX + "IEEEDouble";
case GUID:
return TYPE_TABLE_PREFIX + "GUID";
case NUMERIC:
return TYPE_TABLE_PREFIX + "Decimal";
case TEXT:
return TYPE_TABLE_PREFIX + "Text";
default:
return null;
}
}
/**
* Builds a version history column for a memo column which keeps one. Ms
* access gives every such column the same name, so a table can have only one
* memo column with a history.
*/
static ColumnBuilder newVersionHistoryColumn(ColumnBuilder memoCol) {
return new VersionHistoryColumnBuilder(memoCol.getName());
}
/**
* Creates the tables and the row which support the given complex column, and
* gives the column the id of that row.
*
* @param parentName the name of the table which holds the column
* @param parentId the object id of that table, which is its table
* definition page number
*/
static void create(TableMutator mutator, String parentName, int parentId,
ColumnBuilder col)
throws IOException
{
DatabaseImpl db = mutator.getDatabase();
ComplexColumnDesc desc = col.getComplexDesc();
TableImpl typeTable = getTypeTable(db, col, desc);
TableImpl flatTable = createFlatTable(db, parentName, col, desc, typeTable);
TableImpl complexColumns = db.getSystemTable(COMPLEX_COLUMNS_TABLE);
if(complexColumns == null) {
throw new IOException(
"The database has no " + COMPLEX_COLUMNS_TABLE +
" table, so it cannot hold a complex column (Column=" +
col.getName() + ")");
}
Map<String,Object> row = new LinkedHashMap<>();
row.put(COL_COMPLEX_ID, Column.AUTO_NUMBER);
row.put(COL_TABLE_ID, parentId);
row.put(COL_COMPLEX_TYPE_OBJECT_ID, typeTable.getTableDefPageNumber());
row.put(COL_FLAT_TABLE_ID, flatTable.getTableDefPageNumber());
row.put(COL_COLUMN_NAME, col.getName());
Object complexId = complexColumns.addRowFromMap(row).get(COL_COMPLEX_ID);
mutator.getColumnState(col).setComplexId(((Number)complexId).intValue());
}
/**
* Finds the type table which holds the shape of the given column's values,
* creating it for a version history column, which is the one kind whose type
* table is not shared.
*/
private static TableImpl getTypeTable(DatabaseImpl db, ColumnBuilder col,
ComplexColumnDesc desc)
throws IOException
{
if(desc.getType() == ComplexDataType.VERSION_HISTORY) {
return createVersionHistoryTypeTable(db, desc);
}
String typeName = ((desc.getType() == ComplexDataType.ATTACHMENT) ?
(TYPE_TABLE_PREFIX + "Attachment") :
getTypeTableName(desc.getValueType()));
TableImpl typeTable =
((typeName != null) ? db.getSystemTable(typeName) : null);
if(typeTable == null) {
throw new IOException(
"The database has no " + typeName + " table, which holds the shape " +
"of the values of this column (Column=" + col.getName() + ")");
}
return typeTable;
}
/**
* Creates the per column type table of a version history column, which holds
* one version of the memo column's value and the time it was written.
*/
private static TableImpl createVersionHistoryTypeTable(
DatabaseImpl db, ComplexColumnDesc desc)
throws IOException
{
TableBuilder table = new TableBuilder(
VERSION_HISTORY_TYPE_PREFIX + newNameGuid())
.addColumn(new ColumnBuilder(desc.getMemoColumnName(), DataType.MEMO))
.addColumn(new ColumnBuilder(MODIFIED_COL_NAME,
DataType.SHORT_DATE_TIME));
return new TableCreator(db).createTable(
table, CATALOG_FLAGS_TYPE_TABLE, true);
}
/**
* Creates the table which holds the values of one complex column: a foreign
* key back to the row of the table which holds the column, an autonumber
* primary key which is the id of one value, and the columns of the type
* table.
*/
private static TableImpl createFlatTable(
DatabaseImpl db, String parentName, ColumnBuilder col,
ComplexColumnDesc desc, TableImpl typeTable)
throws IOException
{
JetFormat format = db.getFormat();
// ms access cuts a name it builds at the length a name may be, which the
// long name of a version history column needs. Neither name is matched
// when a complex column is read, so a cut name costs nothing
String fkName = StringUtil.truncate(
"_" + col.getName(), format.MAX_COLUMN_NAME_LENGTH);
String pkName = StringUtil.truncate(
parentName + "_" + col.getName(), format.MAX_COLUMN_NAME_LENGTH);
ColumnBuilder fkCol = new ColumnBuilder(fkName, DataType.LONG);
ColumnBuilder pkCol = new ColumnBuilder(pkName, DataType.LONG)
.setAutoNumber(true);
TableBuilder table = new TableBuilder(
StringUtil.truncate(
FLAT_TABLE_PREFIX + newNameGuid() + "_" + col.getName(),
format.MAX_TABLE_NAME_LENGTH));
table.addColumn(fkCol);
if(desc.getType() == ComplexDataType.MULTI_VALUE) {
// ms access writes the primary key before the one value of a
// multi-value column, and after the several values of the other kinds
table.addColumn(pkCol);
}
for(ColumnImpl typeCol : typeTable.getColumns()) {
ColumnBuilder valueCol = copyValueColumn(typeCol);
if((desc.getType() == ComplexDataType.MULTI_VALUE) &&
(desc.getRowSource() != null)) {
// the row source of a multi-value column lives on the value column of
// its flat table, which is where ms access reads it
valueCol.putProperty(PropertyMap.ROW_SOURCE_TYPE_PROP,
desc.getRowSourceType());
valueCol.putProperty(PropertyMap.ROW_SOURCE_PROP, desc.getRowSource());
valueCol.putProperty(PropertyMap.COLUMN_COUNT_PROP, DataType.INT,
(short)desc.getColumnCount());
valueCol.putProperty(PropertyMap.BOUND_COLUMN_PROP, DataType.INT,
(short)1);
}
table.addColumn(valueCol);
}
if(desc.getType() != ComplexDataType.MULTI_VALUE) {
table.addColumn(pkCol);
}
table.addIndex(new IndexBuilder(FLAT_PK_INDEX_NAME)
.addColumns(pkName).setPrimaryKey());
// jackcess reads the values of one row through this index, so it writes
// the index whatever ms access requires
table.addIndex(new IndexBuilder(
StringUtil.truncate(fkName,
format.MAX_INDEX_NAME_LENGTH))
.addColumns(fkName));
TableCreator creator = new TableCreator(db);
// this bit names the column which points back at the table which holds the
// complex column. without it ms access will not open that table
creator.setExtraFlags(fkCol, ColumnImpl.COMPLEX_FK_EXT_FLAG_MASK);
return creator.createTable(table, CATALOG_FLAGS_FLAT_TABLE, true);
}
/**
* Copies one column of a type table, which describes the shape of a value
* rather than holding one.
*/
private static ColumnBuilder copyValueColumn(ColumnImpl typeCol) {
ColumnBuilder valueCol = new ColumnBuilder(typeCol.getName(),
typeCol.getType());
if(typeCol.getType().isTextual()) {
// the length of a text value is the type table's to give, and every
// other type has one length
valueCol.setLength(typeCol.getLength());
}
valueCol.setCompressedUnicode(typeCol.isCompressedUnicode());
if(typeCol.getType().getHasScalePrecision()) {
valueCol.setScale(typeCol.getScale());
valueCol.setPrecision(typeCol.getPrecision());
}
return valueCol;
}
/**
* @return 32 hex digits, which is the shape of the guid in the name of a
* flat table or a version history type table
*/
private static String newNameGuid() {
return StringUtil.toUpperCase(
UUID.randomUUID().toString().replace("-", ""));
}
/**
* The complex column which holds the versions of a memo column. A caller
* never names or sees it, and asks for it with {@link
* ColumnBuilder#setAppendOnly}, so it declares itself rather than taking a
* declaration through the public api.
*/
private static final class VersionHistoryColumnBuilder extends ColumnBuilder
{
private final ComplexColumnDesc _desc;
private VersionHistoryColumnBuilder(String memoColName) {
super(VERSION_HISTORY_COL_NAME, DataType.COMPLEX_TYPE);
setAutoNumber(true);
_desc = ComplexColumnDesc.versionHistory(memoColName);
}
@Override
public ComplexColumnDesc getComplexDesc() {
return _desc;
}
}
}