SchemaCheck.java

/*
** Module   : SchemaCheck.java
** Abstract : Detects schema changes and reflects them in FWD runtime state.
**
** Copyright (c) 2022-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 ECF 20221001 Created initial version, refactored from BS' changes to DatabaseManager.
**     BS  20221103 DMOs loading and unloading complete for tables, columns, indexes.
**     BS  20221104 JavaDocs updated.
**     ECF 20221207 Fixed package path for generated DMO interface. Renamed some methods.
**     ECF 20230125 Changed signature and access of run method variant to be used more easily during database
**                  bootstrap. Prevent concurrent access for any given database. Cleaned up data type code.
**     ECF 20230127 Fixed some problems with initial value processing.
** 002 BS  20230331 Added support for extent fields, DTTZ fields and sequences.
** 003 OM  20230227 Fixed index processing in generateSchemaAst().
** 004 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 005 CA  20230602 The AST for the index component must have the name (for the field) normalized the same way
**                  as the table field is normalized (via 'rightTrimLower').
** 006 OM  20240318 Added dump-table (mandatory table attribute) when a new table is registered.
** 007 OM  20240402 Added proper support for deregistration/dropping of dynamic tables.
** 008 OM  20240412 Added sorting direction for index components.
** 009 OM  20240507 Added customizations of tables based on the comments/remarks from SQL metadata.
** 010 OM  20240516 The SQL tables can be skipped from import if custom comment is set.
**     OM  20240517 Throw StopConditionException() in the mutable database failed validation.
*/

/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
**
**   0. Attribution Requirement.
**
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
**
**   1. No License To Use Trademarks.
**
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
**
**   2. No Misrepresentation of Affiliation.
**
**     You may not represent yourself as Golden Code Development Corporation or FWD.
**
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
**
**   3. No Misrepresentation of Source or Origin.
**
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.persist;

import static com.goldencode.p2j.persist.DynamicTablesHelper.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.logging.*;
import java.util.stream.*;
import com.goldencode.asm.*;
import com.goldencode.ast.*;
import com.goldencode.p2j.convert.ParmType;
import com.goldencode.p2j.persist.dialect.*;
import com.goldencode.p2j.persist.meta.*;
import com.goldencode.p2j.persist.orm.*;
import com.goldencode.p2j.persist.orm.schema.*;
import com.goldencode.p2j.persist.orm.schema.element.*;
import com.goldencode.p2j.schema.*;
import com.goldencode.p2j.uast.*;
import com.goldencode.p2j.util.*;
import antlr.*;
import com.goldencode.p2j.util.logging.*;

/**
 * This class uses JDBC metadata to detect external modifications to a mutable database schema and updates
 * the FWD runtime state to reflect such changes.
 */
public class SchemaCheck
{
   /** Logger. */
   private static final CentralLogger LOG = CentralLogger.get(SchemaCheck.class);
   
   /** Constant for SQL NULL text reported by JDBC metadata */
   // TODO: make this more dialect-aware; have only seen this reported by MariaSB so far
   private static final String SQL_NULL = "NULL";
   
   /** Counter used for generation of unique names for dynamic tables. */
   private static final AtomicLong tableCounter = new AtomicLong(1);
   
   /** One {@code SchemaCheck} instance per mutable database, on which to synchronize operations */
   private static final ConcurrentMap<Database, SchemaCheck> instances = new ConcurrentHashMap<>();
   
   /**
    * Delimiters for pairs of [Token: value] in SQL comment. This is a RegExp used in
    * {@link String#split(String)}.
    */
   private static final String DELIMITERS = "\\|";
   
   /** Database being inspected. */
   private final Database database;
   
   /** Package into which DMO interface and implementation classes are to be added */
   private final String dmoPackage;
   
   /** Stored metadata information on dynamic tables mapped by their legacy name. */
   private final Map<String, DmoMeta> knownTables = new HashMap<>();
   
   /** Token that specifies the legacy name for a table/field/index. */
   private static final String LEGACY_NAME = "LegacyName:";
   
   /** Token that specifies the dump name of a table. */
   private static final String DUMP_NAME = "DumpName:";
   
   /** Token that specifies the legacy LABEL attribute. */
   private static final String LABEL = "Label:";
   
   /** Token that specifies the legacy COLUMN-LABEL attribute. */
   private static final String COLUMN_LABEL = "ColumnLabel:";
   
   /** Token that specifies the legacy FORMAT attribute. */
   private static final String FORMAT = "Format:";
   
   /** Token that specifies the legacy CASE-SENSITIVE attribute. */
   private static final String CASE_SENSITIVE = "CaseSensitive:";
   
   /** Token that specifies the legacy DIGITS attribute, where available. */
   private static final String DIGITS = "Digits:";
   
   /** Token that specifies the legacy type. */
   private static final String LEGACY_TYPE = "Type:";
   
   /** Token that specifies the legacy type. */
   public static final String SKIP = "Skip:";
   
   /** Collection of all known comment tokens. */
   private static final Set<String> COMMENT_TOKENS = new HashSet(){{
         add(CASE_SENSITIVE);
         add(COLUMN_LABEL);
         add(DIGITS);
         add(DUMP_NAME);
         add(FORMAT);
         add(LABEL);
         add(LEGACY_NAME);
         add(LEGACY_TYPE);
         add(SKIP);
   }};
   
   /**
    * Notify the FWD runtime that a mutable database schema may have been modified. This causes the specified
    * database's schema to be scanned and compared with a previously captured state, for the purpose of
    * updating the FWD runtime's state to match the detected schema changes.
    * <p>
    * This is intended to be a development-time operation and should not be invoked while the target
    * database is in active use, as DMO classes will be added and potentially removed as part of the update.
    * 
    * @param   ldbName
    *          Logical name of the database whose schema is meant to have changed.
    */
   public static void run(character ldbName)
   {
      try
      {
         Database database = DatabaseManager.getMutableDatabase(ldbName.toStringMessage());
         run(database, false);
      }
      catch (PersistenceException exc)
      {
         if (LOG.isLoggable(Level.SEVERE))
         {
            LOG.log(Level.SEVERE, "Error running schema check", exc);
         }
         throw new StopConditionException(
               "Failed to scan mutable database '" + ldbName.toStringMessage() + "'", exc);
      }
   }
   
   /**
    * Notify the FWD runtime that a mutable database schema may have been modified. This causes the specified
    * database's schema to be scanned and compared with a previously captured state, for the purpose of
    * updating the FWD runtime's state to match the detected schema changes.
    * <p>
    * This is intended to be a development-time operation and should not be invoked while the target
    * database is in active use, as DMO classes will be added and potentially removed as part of the update.
    * 
    * @param   database
    *          Helper object which represents physical database whose schema is meant to have changed.
    * @param   bootstrap
    *          {@code true} if invoked during database bootstrap; {@code false} if invoked by application
    *          logic.
    */
   static void run(Database database, boolean bootstrap)
   {
      SchemaCheck check = instances.computeIfAbsent(database, SchemaCheck::new);
      
      synchronized (check)
      {
         check.processChanges(bootstrap);
      }
   }
   
   /**
    * Private constructor, meant to be called only from {@link #run(Database, boolean)}.
    * 
    * @param   database
    *          Database whose schema is being processed.
    */
   private SchemaCheck(Database database)
   {
      this.database = database;
      this.dmoPackage = DMO_BASE_PACKAGE + '.' + database.getName();
   }
   
   /**
    * Notify the FWD runtime that a mutable database schema has been modified. This causes the specified
    * database's schema to be scanned and compared with a previously captured state, for the purpose of
    * updating the FWD runtime's state to match the detected schema changes.
    * <p>
    * This is intended to be a development-time operation and should not be invoked while the target
    * database is in active use, as DMO classes will be added and potentially removed as part of the update.
    */
   public void processChanges(boolean bootstrap)
   {
      try
      {
         SchemaComparator comparator = SchemaComparator.getInstanceExistingState(database);
         ComparisonResult result = bootstrap
                                   ? comparator.getFreshResult()
                                   : comparator.compareWithPrevious();
         if (!result.isSame())
         {
            SchemaValidator.validate(result.getState().getSequences());

            comparator.enrichExtentFields(result);

            Set<Table> tablesToUnload = new HashSet<>();
            Set<Table> tablesToLoad = new HashSet<>();
            for (Difference difference : result.getDifferences())
            {
               // Sequences are not attached to tables directly. Differences in sequences (or any other change
               // not related to tables) should not reload any classes.
               if (difference.getUpdatedTable() == null)
               {
                  continue;
               }

               Element newElement = difference.getNewElement();
               if (newElement != null)
               {
                  // New element added, we will be loading a new DMO
                  tablesToLoad.add(difference.getUpdatedTable());
                  
                  if (!(newElement instanceof Table))
                  {
                     // It's not a table that was created.
                     // Table already existed, we will be unloading the old version.
                     tablesToUnload.add(difference.getUpdatedTable());
                  }
               }
               
               Element oldElement = difference.getOldElement();
               if (oldElement != null)
               {
                  // Table was dropped, we will remove the class
                  tablesToUnload.add(difference.getUpdatedTable());
                  
                  if (!(oldElement instanceof Table))
                  {
                     // It's not a table that was dropped. Table still exists, we will re-create the class.
                     tablesToLoad.add(difference.getUpdatedTable());
                  }
               }
            }
            
            // deregister obsolete tables
            // TODO: this alone will not allow DMO classes to unload; need to sever all strong references in
            // the runtime, including in the ProxyFactory cache. For now, this is best efforts (just release
            // the classloader's references), and we load a new class instead of re-using the old name.
            tablesToUnload.forEach(this::deregisterTable);
            
            // Register tables
            for (Table table : tablesToLoad)
            {
               try
               {
                  registerTable(table);
               }
               catch (Exception ex)
               {
                  // Table validation failed, removing it from result so that it can be reloaded next time.
                  comparator.removeTableFromLastKnownState(table);
                  throw ex;
               }
            }
         }
      }
      catch (PersistenceException exc)
      {
         LOG.log(Level.SEVERE, "State of database " + database + " could not be read/written.", exc);
         throw new StopConditionException("Failed to update mutable database '" + database + "'.", exc);
      }
   }
   
   /**
    * Table was dropped. This method will remove the DMO that was previously registered for the table.
    * <p>
    * TODO: this will remove the strong reference within the {@code AsmClassLoader}, but we also need to
    * remove strong references to the DMO interface (and implementation class) throughout the runtime.
    * 
    * @param   table
    *          Table object
    */
   private void deregisterTable(Table table)
   {
      DmoMeta dmoMeta = knownTables.remove(table.getName());
      if (dmoMeta == null)
      {
         LOG.warning("Imbalanced table registration. Cannot deregister " + table.getName() + 
                     " because it was not registered, or was already deregistered.");
         return;
      }
      
      String className = dmoMeta.getImplementationClass().getName();
      String ifaceName = dmoMeta.getDmoImplInterface().getName();
      
      try
      {
         AsmClassLoader asmLoder = AsmClassLoader.getInstance();
         asmLoder.unloadClass(className);
         asmLoder.unloadClass(ifaceName);
         asmLoder.unloadClass(ifaceName + "$Buf");
      }
      catch (ClassNotFoundException e)
      {
         LOG.log(Level.SEVERE, "Class " + className + " could not be unloaded.", e);
      }
      
      TableMapper.removeDynamicTable(dmoMeta);
      DmoMetadataManager.deregisterDmos(ifaceName, className);
      MetadataManager.removeDynamicTable(database, dmoMeta);
   }
   
   /**
    * Table was created. This method will create a DMO for the table.
    *
    * @param   table
    *          A table object which contains information on the DMO/table to be constructed and register.
    */
   private void registerTable(Table table)
   {
      SchemaValidator.validate(table);
      
      long fieldId = 1;
      Map<String, String> clsFields4GLtoORM = new HashMap<>();
      for (Attribute field : table.getAttributes())
      {
         if (field.isPrimaryKey())
         {
            // skip surrogate primary key
            continue;
         }
         
         String fieldName4GL = field.getName();
         String fieldName4GLNorm = TextOps.rightTrimLower(fieldName4GL);
         String fieldNameORM = ReservedProperty.isReservedProperty(fieldName4GL)
                               ? fieldName4GL
                               : "field" + (fieldId++);
         clsFields4GLtoORM.put(fieldName4GLNorm, fieldNameORM);
      }
      
      DmoMeta dmoMeta = null;
      String ifaceName = getInterfaceName(table);
      try
      {
         String tableName = table.getName();
         Map<String, String> legacyAttributes = processComment(table.getComment());
         String legacyTableName = getTokenValue(legacyAttributes, LEGACY_NAME, tableName);
         String dumpName =        getTokenValue(legacyAttributes, DUMP_NAME,   table.getName());
         // TODO: add other tokens 
         
         Aast root = generateSchemaAst(table, ifaceName, tableName, legacyTableName, clsFields4GLtoORM);
         
         ConversionPool.Results results = ConversionPool.runTask(ConversionProfile.P2O, root);
         root = (Aast) results.getStoredObject("p2o.prog");
         ConversionPool.runTask(ConversionProfile.P2O_POST, results, root);
         
         root = (Aast) results.getStoredObject("p2o.peer");
         root.putAnnotation("permanent", true);
         root.putAnnotation("package", DMO_BASE_PACKAGE);
         
         // the dump-name is mandatory and unique when saved to _file meta table
         Aast tableNode = root.getImmediateChild(P2OLookup.CLASS, null);
         tableNode.putAnnotation("dump-name", dumpName);
         
         // generate interface Java ASTs
         results = ConversionPool.runTask(ConversionProfile.JAVA_DMO, root);
         Aast iface = (Aast) results.getStoredObject(DMO_IFACE_CATEGORY, ifaceName);
         String fullIfaceName = dmoPackage + "." + ifaceName;
         
         // generate bytecode from Java ASTs
         results = ConversionPool.runTask(ConversionProfile.BREW_DMO_ASM, iface);
         
         AsmClassLoader loader = AsmClassLoader.getInstance();
         Map<String, Class<?>> classes = new HashMap<>();
         byte[] code;
         
         // load interfaces first
         List<String> keys = (List<String>) results.getStoredObject("iface.keys");
         for (String name : keys)
         {
            try
            {
               code = (byte[]) results.getStoredObject("ifaces", name);
               Class<?> clazz = loader.loadDynamicClass(name, code);
               classes.put(name, clazz);
            }
            catch (ClassNotFoundException e)
            {
               LOG.log(Level.SEVERE, "Class " + name + " could not be loaded.", e);
               throw e; // will be caught below and wrapped in a StopConditionException
            }
         }
         
         Class<? extends DataModelObject> dmoIface =
            (Class<? extends DataModelObject>) classes.get(fullIfaceName);
         dmoMeta = DmoMetadataManager.registerDmo(dmoIface);
         
         Class<? extends Record> dmoClass = dmoMeta.getImplementationClass();
         String fullImplClassName = dmoClass.getName();
         classes.put(fullImplClassName, dmoClass);
         
         if (Persistence.isActive())
         {
            MetadataManager.addDynamicTableToFile(table.getDatabase(), dmoIface.getName());
         }
         
         knownTables.put(tableName, dmoMeta);
         
         LOG.info("Successfully registered new DMO " + fullIfaceName);
      }
      catch (Exception e)
      {
         if (dmoMeta != null)
         {
            // rollback. Make sure no meta information remains behind
            TableMapper.removeDynamicTable(dmoMeta);
            DmoMetadataManager.deregisterDmos(ifaceName, dmoMeta.getImplementationClass().getName());
            MetadataManager.removeDynamicTable(table.getDatabase(), dmoMeta);
         }
         throw new StopConditionException("DMO creation and registration failed", e);
      }
      finally
      {
         // clean up after TRPL rules in the AST registry
         AstManager.get().removeTree(getAstPath(ifaceName));
      }
   }
   
   /**
    * Get the path representing the DMO interface in the AST registry.
    * 
    * @param   ifaceName
    *          Short name of DMO interface.
    * 
    * @return  AST registry path name for the given DMO interface.
    */
   private String getAstPath(String ifaceName)
   {
      return DMO_BASE_PACKAGE.replace('.', '/') + "/" + database.getName() + "/" + ifaceName + ".java";
   }
   
   /**
    * Convert table name to interface name for DMOs.
    *
    * @param table Table object
    */
   private String getInterfaceName(Table table)
   {
      long tableId = tableCounter.getAndIncrement();
      
      return table.getName() + "_" + tableId;
   }
   
   /**
    * Generate AST that represents the target table and has the same structure as .p2o files from
    * the conversion stage.
    *
    * @param  table
    *         Object containing definitions of the target table.
    * @param  ifaceName
    *         The name of the target DMO interface.
    * @param  tableName
    *         The name of the target SQL table.
    * @param  legacyTableName
    *         The legacy (Progress) name of the table.
    * @param  fields4GLtoORM
    *         Map 4GL field names to ORM names.
    *
    * @return the root node of the generated AST.
    */
   Aast generateSchemaAst(Table table,
                          String ifaceName,
                          String tableName,
                          String legacyTableName,
                          Map<String, String> fields4GLtoORM)
   {
      Dialect dialect = Dialect.getDialect(database);
      Aast root = new ProgressAst(new CommonToken(ProgressParserTokenTypes.DATABASE, database.getName()));
      
      ProgressAst tableAst = new ProgressAst(new CommonToken(ProgressParserTokenTypes.TABLE, tableName));
      tableAst.putAnnotation("class-name", ifaceName);
      tableAst.putAnnotation("table-name", tableName);
      tableAst.putAnnotation(P2OLookup.HISTORICAL, legacyTableName);
      root.addChild(tableAst);
      
      Set<Attribute> attributesToSkip = getAttributesToSkip(table.getAttributes(), dialect);
      
      // add field nodes to table
      Map<String, Aast> fieldAsts = new HashMap<>();
      for (Attribute field : table.getAttributes())
      {
         if (attributesToSkip.contains(field))
         {
            // skip surrogate primary key, timezone field for DTTZ, etc.
            continue;
         }
         
         String fieldName4GL = TextOps.rightTrimLower(field.getName());
         String fieldNameORM = fields4GLtoORM.get(fieldName4GL);
         
         Map<String, String> legacyAttributes = processComment(field.getComment());
         
         ParmType parmType = dialect.translateToParmType(field.getType(), legacyAttributes.get(LEGACY_TYPE));
         int legacyType = propTypeToProgress.get(parmType);
         
         ProgressAst fieldAst = new ProgressAst(new CommonToken(legacyType, fieldNameORM));
         fieldAsts.put(fieldName4GL, fieldAst);
         ReservedProperty resPropName = ReservedProperty.getByName(fieldName4GL);
         if (resPropName != null)
         {
            fieldAst.putAnnotation("__fieldid", (long) resPropName.id);
         }
         
         fieldAst.putAnnotation(P2OLookup.HISTORICAL,
                                getTokenValue(legacyAttributes, LEGACY_NAME, field.getName()));
         if (legacyAttributes.containsKey(LABEL))
         {
            fieldAst.putAnnotation(P2OLookup.LABEL, legacyAttributes.get(LABEL));
         }
         if (legacyAttributes.containsKey(COLUMN_LABEL))
         {
            fieldAst.putAnnotation(P2OLookup.COLUMN_LABEL, legacyAttributes.get(COLUMN_LABEL));
         }
         if (legacyAttributes.containsKey(FORMAT))
         {
            fieldAst.putAnnotation(P2OLookup.FORMAT, legacyAttributes.get(FORMAT));
         }
         // TODO: add any other attributes of the fields
         
         Long dec = null;
         if (legacyAttributes.containsKey(DIGITS))
         {
            try
            {
               dec = Long.parseLong(legacyAttributes.get(DIGITS));
            }
            catch (NumberFormatException e)
            {
               LOG.log(Level.INFO, "Failed to parse DECIMALS from SQL comment: " + e.getMessage());
            }
         }
         if (dec == null)
         {
            dec = dialect.getDecimals(field.getType(), field.getPrecision(), null /*ignored*/);
         }
         if (dec != null)
         {
            fieldAst.putAnnotation(P2OLookup.DECIMALS, dec);
         }
         
         long extent = field.getExtent();
         if (extent > 0)
         {
            fieldAst.putAnnotation(P2OLookup.EXTENT, extent);
         }
         
         fieldAst.putAnnotation(P2OLookup.NON_NULLABLE, field.getNullable());
         
         if (parmType == ParmType.CHAR || parmType == ParmType.TEXT)
         {
            boolean cs = DialectHelper.getCaseSensitive(legacyAttributes.get(CASE_SENSITIVE));
            fieldAst.putAnnotation(P2OLookup.CASE_SENSITIVE, cs);
         }
         
         tableAst.addChild(fieldAst);
         
         // add initial value node to field
         
         String valStr = field.getDefaultValue();
         Integer valType = null;
         
         if (valStr == null          ||
             SQL_NULL.equals(valStr) ||
             (parmType == ParmType.CHAR && ResourceIdHelper.UNKNOWN_STRING.equals(valStr)))
         {
            valType = ProgressParserTokenTypes.UNKNOWN_VAL;
            valStr = ResourceIdHelper.UNKNOWN_STRING;
         }
         else if (!valStr.isEmpty())
         {
            switch (parmType)
            {
               case CHAR:
                  valType = ProgressParserTokenTypes.STRING;
                  valStr = character.javaRuntimeToProgressSourceString(valStr);
                  break;
               case INT64:
               case INT:
               case RECID:
                  valType = ProgressParserTokenTypes.NUM_LITERAL;
                  break;
               case DEC:
                  valType = ProgressParserTokenTypes.DEC_LITERAL;
                  break;
               case LOG:
                  valType = dialect.resolveBooleanConstant(valStr)
                            ? ProgressParserTokenTypes.BOOL_TRUE
                            : ProgressParserTokenTypes.BOOL_FALSE;
                  break;
               case DATE:
                  valType = ProgressParserTokenTypes.DATE_LITERAL;
                  break;
               case DT:
                  valType = ProgressParserTokenTypes.DATETIME_LITERAL;
                  break;
               case DTTZ:
                  valType = ProgressParserTokenTypes.DATETIME_TZ_LITERAL;
                  break;
               case RAW:
                  valType = null;
                  break;
               default:
                  valType = null;
                  if (LOG.isLoggable(Level.WARNING))
                  {
                     LOG.warning("Unsupported initial value literal type in mutable table field: " +
                                 parmType + "; (" + legacyTableName + "." + field.getName() + ")");
                  }
            }
         }
         
         if (valType != null)
         {
            ProgressAst initNode = new ProgressAst(new CommonToken(ProgressParserTokenTypes.KW_INIT, ""));
            fieldAst.addChild(initNode);
            ProgressAst initVal = new ProgressAst(new CommonToken(valType, valStr));
            initNode.addChild(initVal);
         }
      }
      
      // add index nodes to table
      for (Index index : table.getIndexes())
      {
         if (index.getPrimary())
         {
            // skip surrogate primary key
            continue;
         }
         
         String indexName = TextOps.rightTrimLower(index.getName());
         ProgressAst indexAst = new ProgressAst(new CommonToken(ProgressParserTokenTypes.INDEX, indexName));
         tableAst.addChild(indexAst);
         
         if (index.getUnique())
         {
            indexAst.putAnnotation(P2OLookup.UNIQUE, true);
         }
         
//         if (index.isWord())
//         {
//            indexAst.putAnnotation(P2OLookup.WORD, true);
//         }
         
         // add index fields to index
         for (Tuple<String, Boolean> col: index.getColumns())
         {
            String col4GL = TextOps.rightTrimLower(col.getKey());
            ProgressAst comp = new ProgressAst(new CommonToken(ProgressParserTokenTypes.INDEX_FIELD, col4GL));
            indexAst.addChild(comp);
            Boolean sortDir = col.getValue();
            boolean isAscending = sortDir == null || sortDir;
            comp.addChild(new ProgressAst(isAscending
                        ? new CommonToken(ProgressParserTokenTypes.KW_ASCEND, "ASCENDING")
                        : new CommonToken(ProgressParserTokenTypes.KW_DESCEND, "DESCENDING")));
            if (!isAscending)
            {
               comp.putAnnotation("descend", true);
            }
         }
      }
      
      assignIds(root, 1);
      
      // now assign each index field's refid to the corresponding field's ID
      Aast indexAst = tableAst.getImmediateChild(ProgressParserTokenTypes.INDEX, null);
      while (indexAst != null)
      {
         Aast compAst = indexAst.getImmediateChild(ProgressParserTokenTypes.INDEX_FIELD, null);
         while (compAst != null)
         {
            Aast fieldAst = fieldAsts.get(dialect.extractColumnName(compAst.getText()));
            if (fieldAst != null)
            {
               compAst.putAnnotation("refid", fieldAst.getId());
               compAst = indexAst.getImmediateChild(ProgressParserTokenTypes.INDEX_FIELD, compAst);
            }
            else
            {
               // 'recid' column is normal to occur as the last component of a non-unique index
               if (!Session.PK.equals(compAst.getText()))
               {
                  LOG.severe("Failed to process " + compAst.getText() + " index component.");
                  throw new StopConditionException(
                        "Failed to process " + compAst.getText() + " index component on mutable table.");
               }
               
               compAst = null;
            }
         }
         
         indexAst = tableAst.getImmediateChild(ProgressParserTokenTypes.INDEX, indexAst);
      }
      
      return root;
   }

   /**
    * Get a list of attributes that should not be handled as a DMO property. This is to provide unified
    * checks for surrogate primary key columns, timezone for DTTZ attributes, etc.
    *
    * @param   attributes
    *          All available columns.
    * @param   dialect
    *          Database dialect for datatype translation purposes.
    *
    * @return  Set of attributes that should not be handled as a DMO property.
    */
   private Set<Attribute> getAttributesToSkip(Collection<Attribute> attributes, Dialect dialect)
   {
      return attributes.stream()
         .map(field -> {
            if (field.isPrimaryKey())
            {
               // skip surrogate primary key
               return field;
            }
            
            Map<String, String> legacyAttributes = processComment(field.getComment());
            ParmType parmType = dialect.translateToParmType(field.getType(), legacyAttributes.get(LEGACY_TYPE));
            if (parmType == ParmType.DTTZ)
            {
               // find field that has the same name + _offset, skip this field later.
               Optional<Attribute> timezoneColumn = attributes.stream()
                  .filter(a -> a.getName().equalsIgnoreCase(field.getName() + DDLGeneratorWorker.DTZ_OFFSET))
                  .findFirst();
               if (timezoneColumn.isPresent())
               {
                  return timezoneColumn.get();
               }
            }
            return null;
         })
         .filter(Objects::nonNull)
         .collect(Collectors.toSet());
   }
   
   /**
    * Process a comment and split in pairs of (token, value). The pairs are separated by comma, the tokens
    * always end with a colon.
    * 
    * @param   comment
    *          The comment as read from SQL remark.
    * 
    * @return  A map of pairs token/value.
    */
   public static Map<String, String> processComment(String comment)
   {
      Map<String, String>  ret = new HashMap<>();
      if (comment == null)
      {
         return ret;
      }
      
      String[] splits = comment.split(DELIMITERS);
      for (String split : splits)
      {
         int i = split.indexOf(':'); // separate token from value using colon 
         if (i > 0)
         {
            String token = split.substring(0, i + 1).trim();
            if (!COMMENT_TOKENS.contains(token))
            {
               LOG.log(Level.INFO, "Unknown token type: '" + token + "'.");
            }
            ret.putIfAbsent(token, split.substring(i + 1).trim());
         }
         else
         {
            LOG.log(Level.INFO, "Failed to parse Token: Value ('" + split + "')");
         }
      }
      return ret;
   }
   
   /**
    * Returns an attribute from the map or default value otherwise.
    * 
    * @param   attributes
    *          The map of legacy attributes.
    * @param   token
    *          The token
    * @param   def
    *          the default value to be returned in case the token is not found.
    *
    * @return  The value of the attribute or the default specified if token not found. 
    */
   public static String getTokenValue(Map<String, String> attributes, String token, String def)
   {
      String ret = attributes.get(token);
      return ret == null ? def : ret;
   }
}