Skip to content

Latest commit

 

History

History
941 lines (749 loc) · 32 KB

File metadata and controls

941 lines (749 loc) · 32 KB

ADR-010: Automated Search Index Table DDL Management

Status: ✅ Proposed Date: 2025-12-18 Decision Makers: Development Team, Database Team Related Issues: Manual table creation overhead, time-consuming setup Supersedes: None


Context

Currently, when a new search index is created via the iDempiere UI (AD_SearchIndex), the corresponding PostgreSQL table must be created manually by a DBA. This creates several problems:

Current Workflow (Manual)

  1. User creates AD_SearchIndex record with name product_search
  2. User runs CreateSearchIndex process
  3. Process fails with error: relation "idx_product_search_ts" does not exist
  4. DBA must manually execute DDL:
    CREATE TABLE idx_product_search_ts (
        ad_client_id NUMERIC(10),
        ad_table_id NUMERIC(10),
        record_id NUMERIC(10),
        idx_tsvector TSVECTOR,
        created TIMESTAMP DEFAULT NOW(),
        updated TIMESTAMP DEFAULT NOW()
    );
    
    CREATE UNIQUE INDEX idx_product_search_client_table_record
        ON idx_product_search_ts (ad_client_id, ad_table_id, record_id);
    
    CREATE INDEX idx_product_search_tsvector_gin
        ON idx_product_search_ts USING GIN (idx_tsvector);
  5. User re-runs CreateSearchIndex process (now succeeds)

Problems

Problem Impact Frequency
Manual DBA intervention Process blocked until DBA available Every new index
Human error risk Wrong table name, missing columns, incorrect indexes 20-30% failure rate
Time overhead 15-60 minutes per index creation High
Documentation lag DDL templates outdated after schema changes Medium
Multi-tenant errors Forgot ad_client_id in UNIQUE constraint (ADR-006) Critical
No rollback Failed index creation leaves orphan tables Medium

Real-World Example

CloudEmpiere e-commerce deployment:

  • 12 search indexes needed (products, customers, orders, etc.)
  • Manual DDL creation: 12 × 30 minutes = 6 hours of DBA time
  • 2 indexes had wrong UNIQUE constraint (missing ad_client_id)
  • 1 index had wrong GIN index type (GIST instead of GIN)
  • Total rework: +4 hours

Cost: 10 hours DBA time (€500-€1000)


Decision

We will automate search index table creation by implementing a DDL generator that creates PostgreSQL tables dynamically when AD_SearchIndex records are created.

Proposed Solution

Component: SearchIndexTableManager (new class)

Capabilities:

  1. Automatic table creation when AD_SearchIndex is saved
  2. Schema validation before DDL execution
  3. Transactional DDL (rollback on failure)
  4. Template-based DDL (consistent with ADR-006)
  5. Idempotent operations (safe to re-run)

Architecture

┌─────────────────────────────────────────────────────────────┐
│                    User Creates AD_SearchIndex              │
│                  (SearchIndexName = "product_search")       │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│              MSearchIndex.beforeSave() Hook                 │
│         Check: Does table "idx_product_search_ts" exist?    │
└────────────────────────┬────────────────────────────────────┘
                         │
                    NO   │   YES
         ┌───────────────┴──────────────┐
         ▼                               ▼
┌─────────────────────────┐    ┌─────────────────────────┐
│ SearchIndexTableManager │    │ Skip DDL (table exists) │
│   .createTable()        │    └─────────────────────────┘
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────────────────────────────────────────┐
│              DDL Template Rendering                         │
│  - Table name: idx_{{searchIndexName}}_ts                   │
│  - Columns: ad_client_id, ad_table_id, record_id, idx_tsvec │
│  - UNIQUE: (ad_client_id, ad_table_id, record_id)          │
│  - GIN index: idx_tsvector                                  │
└────────┬────────────────────────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────────────────────────┐
│              Execute DDL in Transaction                     │
│  - CREATE TABLE                                             │
│  - CREATE UNIQUE INDEX                                      │
│  - CREATE GIN INDEX                                         │
│  - COMMIT (success) or ROLLBACK (failure)                   │
└────────┬────────────────────────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────────────────────────┐
│                    Table Ready for Use                      │
│         User can immediately run CreateSearchIndex          │
└─────────────────────────────────────────────────────────────┘

Implementation

Phase 1: Core Table Manager (2 days)

File: com.cloudempiere.searchindex/src/com/cloudempiere/searchindex/util/SearchIndexTableManager.java

package com.cloudempiere.searchindex.util;

import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.logging.Level;

import org.compiere.util.CLogger;
import org.compiere.util.DB;
import org.compiere.util.Trx;

/**
 * Manages automatic creation and schema validation of search index tables.
 *
 * <p>Creates PostgreSQL tables with proper schema:
 * <ul>
 *   <li>Columns: ad_client_id, ad_table_id, record_id, idx_tsvector, created, updated</li>
 *   <li>UNIQUE constraint: (ad_client_id, ad_table_id, record_id) - See ADR-006</li>
 *   <li>GIN index: idx_tsvector</li>
 * </ul>
 *
 * <p>All operations are transactional and idempotent.
 *
 * @author CloudEmpiere Team
 * @version 1.0
 * @since 8.2
 */
public class SearchIndexTableManager {

    private static final CLogger log = CLogger.getCLogger(SearchIndexTableManager.class);

    /**
     * Table name prefix for all search index tables
     */
    private static final String TABLE_PREFIX = "idx_";

    /**
     * Table name suffix for all search index tables
     */
    private static final String TABLE_SUFFIX = "_ts";

    /**
     * Creates a search index table if it doesn't exist.
     *
     * <p>DDL Template:
     * <pre>
     * CREATE TABLE idx_{{name}}_ts (
     *     ad_client_id NUMERIC(10) NOT NULL,
     *     ad_table_id NUMERIC(10) NOT NULL,
     *     record_id NUMERIC(10) NOT NULL,
     *     idx_tsvector TSVECTOR,
     *     created TIMESTAMP DEFAULT NOW(),
     *     updated TIMESTAMP DEFAULT NOW(),
     *     CONSTRAINT idx_{{name}}_client_table_record_key
     *         UNIQUE (ad_client_id, ad_table_id, record_id)
     * );
     *
     * CREATE INDEX idx_{{name}}_tsvector_gin
     *     ON idx_{{name}}_ts USING GIN (idx_tsvector);
     * </pre>
     *
     * @param searchIndexName Search index name (e.g., "product_search")
     * @param trxName Transaction name (for rollback on error)
     * @return true if table created or already exists, false on error
     * @throws IllegalArgumentException if searchIndexName is invalid
     */
    public static boolean createTableIfNotExists(String searchIndexName, String trxName) {
        // Validate input
        if (searchIndexName == null || searchIndexName.trim().isEmpty()) {
            throw new IllegalArgumentException("searchIndexName cannot be null or empty");
        }

        // Sanitize table name (prevent SQL injection)
        String safeTableName = sanitizeTableName(searchIndexName);
        String fullTableName = TABLE_PREFIX + safeTableName + TABLE_SUFFIX;

        // Check if table already exists
        if (tableExists(fullTableName, trxName)) {
            log.fine("Table " + fullTableName + " already exists, skipping creation");
            return true;
        }

        log.info("Creating search index table: " + fullTableName);

        Trx trx = trxName != null ? Trx.get(trxName, false) : null;
        boolean localTrx = false;

        if (trx == null) {
            trx = Trx.get(Trx.createTrxName("SearchIndexDDL"), true);
            localTrx = true;
        }

        try {
            // Generate DDL
            String createTableDDL = buildCreateTableDDL(fullTableName, safeTableName);
            String createUniqueIndexDDL = buildCreateUniqueIndexDDL(fullTableName, safeTableName);
            String createGinIndexDDL = buildCreateGinIndexDDL(fullTableName, safeTableName);

            // Execute DDL in transaction
            DB.executeUpdateEx(createTableDDL, trx.getTrxName());
            log.fine("Created table: " + fullTableName);

            DB.executeUpdateEx(createUniqueIndexDDL, trx.getTrxName());
            log.fine("Created UNIQUE index: idx_" + safeTableName + "_client_table_record_key");

            DB.executeUpdateEx(createGinIndexDDL, trx.getTrxName());
            log.fine("Created GIN index: idx_" + safeTableName + "_tsvector_gin");

            if (localTrx) {
                trx.commit();
            }

            log.info("Successfully created search index table: " + fullTableName);
            return true;

        } catch (Exception e) {
            log.log(Level.SEVERE, "Failed to create search index table: " + fullTableName, e);
            if (localTrx && trx != null) {
                trx.rollback();
            }
            return false;
        } finally {
            if (localTrx && trx != null) {
                trx.close();
            }
        }
    }

    /**
     * Drops a search index table if it exists.
     *
     * <p><strong>WARNING:</strong> This is a destructive operation. All indexed data will be lost.
     *
     * @param searchIndexName Search index name
     * @param trxName Transaction name
     * @return true if table dropped or doesn't exist, false on error
     */
    public static boolean dropTableIfExists(String searchIndexName, String trxName) {
        String safeTableName = sanitizeTableName(searchIndexName);
        String fullTableName = TABLE_PREFIX + safeTableName + TABLE_SUFFIX;

        if (!tableExists(fullTableName, trxName)) {
            log.fine("Table " + fullTableName + " does not exist, skipping drop");
            return true;
        }

        log.warning("Dropping search index table: " + fullTableName + " (all data will be lost)");

        String dropDDL = "DROP TABLE IF EXISTS " + fullTableName + " CASCADE";

        try {
            DB.executeUpdateEx(dropDDL, trxName);
            log.info("Dropped search index table: " + fullTableName);
            return true;
        } catch (Exception e) {
            log.log(Level.SEVERE, "Failed to drop search index table: " + fullTableName, e);
            return false;
        }
    }

    /**
     * Checks if a search index table exists in the database.
     *
     * @param tableName Full table name (e.g., "idx_product_search_ts")
     * @param trxName Transaction name
     * @return true if table exists, false otherwise
     */
    public static boolean tableExists(String tableName, String trxName) {
        String sql = "SELECT COUNT(*) FROM pg_tables WHERE schemaname='adempiere' AND tablename=?";

        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            pstmt = DB.prepareStatement(sql, trxName);
            pstmt.setString(1, tableName.toLowerCase());
            rs = pstmt.executeQuery();

            if (rs.next()) {
                return rs.getInt(1) > 0;
            }
            return false;

        } catch (SQLException e) {
            log.log(Level.SEVERE, "Error checking if table exists: " + tableName, e);
            return false;
        } finally {
            DB.close(rs, pstmt);
        }
    }

    /**
     * Validates table schema matches expected structure.
     *
     * <p>Checks:
     * <ul>
     *   <li>Required columns exist (ad_client_id, ad_table_id, record_id, idx_tsvector)</li>
     *   <li>UNIQUE constraint on (ad_client_id, ad_table_id, record_id)</li>
     *   <li>GIN index on idx_tsvector</li>
     * </ul>
     *
     * @param searchIndexName Search index name
     * @param trxName Transaction name
     * @return true if schema is valid, false otherwise
     */
    public static boolean validateTableSchema(String searchIndexName, String trxName) {
        String safeTableName = sanitizeTableName(searchIndexName);
        String fullTableName = TABLE_PREFIX + safeTableName + TABLE_SUFFIX;

        if (!tableExists(fullTableName, trxName)) {
            log.warning("Cannot validate schema: table " + fullTableName + " does not exist");
            return false;
        }

        boolean hasRequiredColumns = validateColumns(fullTableName, trxName);
        boolean hasUniqueConstraint = validateUniqueConstraint(fullTableName, safeTableName, trxName);
        boolean hasGinIndex = validateGinIndex(fullTableName, safeTableName, trxName);

        if (hasRequiredColumns && hasUniqueConstraint && hasGinIndex) {
            log.fine("Table schema validation passed: " + fullTableName);
            return true;
        } else {
            log.warning("Table schema validation failed: " + fullTableName);
            log.warning("  Required columns: " + hasRequiredColumns);
            log.warning("  UNIQUE constraint: " + hasUniqueConstraint);
            log.warning("  GIN index: " + hasGinIndex);
            return false;
        }
    }

    // ========== Private Helper Methods ==========

    private static String sanitizeTableName(String tableName) {
        // Remove any characters that aren't alphanumeric or underscore
        String sanitized = tableName.replaceAll("[^a-zA-Z0-9_]", "_");

        // Ensure table name isn't too long (PostgreSQL limit: 63 chars)
        // Reserve space for prefix (4) + suffix (3) = 7 chars
        if (sanitized.length() > 56) {
            sanitized = sanitized.substring(0, 56);
        }

        return sanitized.toLowerCase();
    }

    private static String buildCreateTableDDL(String fullTableName, String safeTableName) {
        StringBuilder ddl = new StringBuilder();
        ddl.append("CREATE TABLE ").append(fullTableName).append(" (\n");
        ddl.append("    ad_client_id NUMERIC(10) NOT NULL,\n");
        ddl.append("    ad_table_id NUMERIC(10) NOT NULL,\n");
        ddl.append("    record_id NUMERIC(10) NOT NULL,\n");
        ddl.append("    idx_tsvector TSVECTOR,\n");
        ddl.append("    created TIMESTAMP DEFAULT NOW(),\n");
        ddl.append("    updated TIMESTAMP DEFAULT NOW(),\n");
        ddl.append("    CONSTRAINT idx_").append(safeTableName).append("_client_table_record_key\n");
        ddl.append("        UNIQUE (ad_client_id, ad_table_id, record_id)\n");
        ddl.append(")");
        return ddl.toString();
    }

    private static String buildCreateUniqueIndexDDL(String fullTableName, String safeTableName) {
        // Note: UNIQUE constraint already created in table DDL
        // This method exists for potential future use (e.g., CREATE UNIQUE INDEX IF NOT EXISTS)
        return "-- UNIQUE constraint created via table DDL";
    }

    private static String buildCreateGinIndexDDL(String fullTableName, String safeTableName) {
        StringBuilder ddl = new StringBuilder();
        ddl.append("CREATE INDEX idx_").append(safeTableName).append("_tsvector_gin\n");
        ddl.append("    ON ").append(fullTableName).append(" USING GIN (idx_tsvector)");
        return ddl.toString();
    }

    private static boolean validateColumns(String tableName, String trxName) {
        String sql = "SELECT column_name FROM information_schema.columns " +
                     "WHERE table_schema='adempiere' AND table_name=? AND column_name IN (?,?,?,?)";

        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            pstmt = DB.prepareStatement(sql, trxName);
            pstmt.setString(1, tableName.toLowerCase());
            pstmt.setString(2, "ad_client_id");
            pstmt.setString(3, "ad_table_id");
            pstmt.setString(4, "record_id");
            pstmt.setString(5, "idx_tsvector");
            rs = pstmt.executeQuery();

            int columnCount = 0;
            while (rs.next()) {
                columnCount++;
            }

            return columnCount == 4;

        } catch (SQLException e) {
            log.log(Level.SEVERE, "Error validating columns: " + tableName, e);
            return false;
        } finally {
            DB.close(rs, pstmt);
        }
    }

    private static boolean validateUniqueConstraint(String tableName, String safeTableName, String trxName) {
        String sql = "SELECT COUNT(*) FROM pg_indexes " +
                     "WHERE schemaname='adempiere' AND tablename=? " +
                     "AND indexdef LIKE '%UNIQUE%' " +
                     "AND indexdef LIKE '%ad_client_id%' " +
                     "AND indexdef LIKE '%ad_table_id%' " +
                     "AND indexdef LIKE '%record_id%'";

        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            pstmt = DB.prepareStatement(sql, trxName);
            pstmt.setString(1, tableName.toLowerCase());
            rs = pstmt.executeQuery();

            if (rs.next()) {
                return rs.getInt(1) > 0;
            }
            return false;

        } catch (SQLException e) {
            log.log(Level.SEVERE, "Error validating UNIQUE constraint: " + tableName, e);
            return false;
        } finally {
            DB.close(rs, pstmt);
        }
    }

    private static boolean validateGinIndex(String tableName, String safeTableName, String trxName) {
        String sql = "SELECT COUNT(*) FROM pg_indexes " +
                     "WHERE schemaname='adempiere' AND tablename=? " +
                     "AND indexname LIKE '%tsvector_gin%'";

        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            pstmt = DB.prepareStatement(sql, trxName);
            pstmt.setString(1, tableName.toLowerCase());
            rs = pstmt.executeQuery();

            if (rs.next()) {
                return rs.getInt(1) > 0;
            }
            return false;

        } catch (SQLException e) {
            log.log(Level.SEVERE, "Error validating GIN index: " + tableName, e);
            return false;
        } finally {
            DB.close(rs, pstmt);
        }
    }
}

Phase 2: Integration with MSearchIndex (0.5 days)

File: com.cloudempiere.searchindex/src/com/cloudempiere/searchindex/model/MSearchIndex.java

Add model validator hook:

@Override
protected boolean beforeSave(boolean newRecord) {
    // Automatic table creation when new search index is created
    if (newRecord) {
        String searchIndexName = getSearchIndexName();

        if (!SearchIndexTableManager.createTableIfNotExists(searchIndexName, get_TrxName())) {
            log.saveError("Error", "Failed to create search index table: idx_" +
                          searchIndexName + "_ts");
            return false;
        }

        log.info("Created search index table: idx_" + searchIndexName + "_ts");
    }

    return super.beforeSave(newRecord);
}

@Override
protected boolean beforeDelete() {
    // Optional: Automatic table drop when search index is deleted
    // CAUTION: This will delete all indexed data!

    String deleteTableOnIndexDelete = MSysConfig.getValue(
        "SEARCHINDEX_DELETE_TABLE_ON_INDEX_DELETE", "N", getAD_Client_ID()
    );

    if ("Y".equals(deleteTableOnIndexDelete)) {
        String searchIndexName = getSearchIndexName();

        if (!SearchIndexTableManager.dropTableIfExists(searchIndexName, get_TrxName())) {
            log.saveError("Error", "Failed to drop search index table: idx_" +
                          searchIndexName + "_ts");
            return false;
        }

        log.warning("Dropped search index table: idx_" + searchIndexName + "_ts");
    }

    return super.beforeDelete();
}

Phase 3: UI Feedback (0.5 days)

Enhancement: Add visual feedback in ZK UI when table is created

// ZkSearchIndexUI.java - Show table creation status
if (SearchIndexTableManager.tableExists(searchIndexName, null)) {
    lblStatus.setValue("✓ Table exists: idx_" + searchIndexName + "_ts");
    lblStatus.setStyle("color: green;");
} else {
    lblStatus.setValue("⚠ Table not found: idx_" + searchIndexName + "_ts");
    lblStatus.setStyle("color: orange;");
}

Phase 4: Migration for Existing Indexes (1 day)

Migration Script: postgresql/migration/202512_create_missing_search_index_tables.sql

-- Migration: Create tables for existing AD_SearchIndex records
-- Issue: ADR-010
-- Date: 2025-12-18

DO $$
DECLARE
    v_searchindex RECORD;
    v_table_name TEXT;
    v_safe_name TEXT;
BEGIN
    -- Find all search indexes without corresponding tables
    FOR v_searchindex IN
        SELECT ad_searchindex_id, searchindexname
        FROM ad_searchindex
        WHERE isactive = 'Y'
    LOOP
        -- Build table name
        v_safe_name := LOWER(REGEXP_REPLACE(v_searchindex.searchindexname, '[^a-zA-Z0-9_]', '_', 'g'));
        v_table_name := 'idx_' || v_safe_name || '_ts';

        -- Check if table already exists
        IF NOT EXISTS (
            SELECT 1 FROM pg_tables
            WHERE schemaname = 'adempiere' AND tablename = v_table_name
        ) THEN
            RAISE NOTICE 'Creating table: %', v_table_name;

            -- Create table
            EXECUTE format(
                'CREATE TABLE %I (
                    ad_client_id NUMERIC(10) NOT NULL,
                    ad_table_id NUMERIC(10) NOT NULL,
                    record_id NUMERIC(10) NOT NULL,
                    idx_tsvector TSVECTOR,
                    created TIMESTAMP DEFAULT NOW(),
                    updated TIMESTAMP DEFAULT NOW(),
                    CONSTRAINT %I UNIQUE (ad_client_id, ad_table_id, record_id)
                )',
                v_table_name,
                'idx_' || v_safe_name || '_client_table_record_key'
            );

            -- Create GIN index
            EXECUTE format(
                'CREATE INDEX %I ON %I USING GIN (idx_tsvector)',
                'idx_' || v_safe_name || '_tsvector_gin',
                v_table_name
            );

            RAISE NOTICE 'Successfully created table: %', v_table_name;
        ELSE
            RAISE NOTICE 'Table already exists: %', v_table_name;
        END IF;
    END LOOP;

    RAISE NOTICE 'Migration completed successfully';
END $$;

Testing Strategy

Unit Tests

File: com.cloudempiere.searchindex.test/src/.../SearchIndexTableManagerTest.java

@Test
public void testCreateTableIfNotExists_NewTable() {
    // Setup
    String searchIndexName = "test_index_" + System.currentTimeMillis();

    // Execute
    boolean result = SearchIndexTableManager.createTableIfNotExists(searchIndexName, null);

    // Verify
    assertTrue("Table creation should succeed", result);
    assertTrue("Table should exist",
               SearchIndexTableManager.tableExists("idx_" + searchIndexName + "_ts", null));
    assertTrue("Schema should be valid",
               SearchIndexTableManager.validateTableSchema(searchIndexName, null));

    // Cleanup
    SearchIndexTableManager.dropTableIfExists(searchIndexName, null);
}

@Test
public void testCreateTableIfNotExists_ExistingTable() {
    // Setup
    String searchIndexName = "test_existing_" + System.currentTimeMillis();
    SearchIndexTableManager.createTableIfNotExists(searchIndexName, null);

    // Execute (second call)
    boolean result = SearchIndexTableManager.createTableIfNotExists(searchIndexName, null);

    // Verify (should be idempotent)
    assertTrue("Second call should succeed (idempotent)", result);

    // Cleanup
    SearchIndexTableManager.dropTableIfExists(searchIndexName, null);
}

@Test
public void testCreateTableIfNotExists_InvalidName() {
    // Execute & Verify
    assertThrows(IllegalArgumentException.class, () -> {
        SearchIndexTableManager.createTableIfNotExists(null, null);
    });

    assertThrows(IllegalArgumentException.class, () -> {
        SearchIndexTableManager.createTableIfNotExists("", null);
    });
}

@Test
public void testValidateTableSchema_CorrectSchema() {
    // Setup
    String searchIndexName = "test_schema_" + System.currentTimeMillis();
    SearchIndexTableManager.createTableIfNotExists(searchIndexName, null);

    // Execute
    boolean result = SearchIndexTableManager.validateTableSchema(searchIndexName, null);

    // Verify
    assertTrue("Schema validation should pass", result);

    // Cleanup
    SearchIndexTableManager.dropTableIfExists(searchIndexName, null);
}

@Test
public void testDropTableIfExists() {
    // Setup
    String searchIndexName = "test_drop_" + System.currentTimeMillis();
    SearchIndexTableManager.createTableIfNotExists(searchIndexName, null);
    assertTrue("Table should exist before drop",
               SearchIndexTableManager.tableExists("idx_" + searchIndexName + "_ts", null));

    // Execute
    boolean result = SearchIndexTableManager.dropTableIfExists(searchIndexName, null);

    // Verify
    assertTrue("Drop should succeed", result);
    assertFalse("Table should not exist after drop",
                SearchIndexTableManager.tableExists("idx_" + searchIndexName + "_ts", null));
}

Integration Tests

@Test
public void testMSearchIndex_AutoCreateTable() {
    // Setup
    MSearchIndex searchIndex = new MSearchIndex(Env.getCtx(), 0, null);
    searchIndex.setSearchIndexName("test_auto_create_" + System.currentTimeMillis());
    searchIndex.setAD_SearchIndexProvider_ID(getDefaultProviderId());

    // Execute
    boolean saved = searchIndex.save();

    // Verify
    assertTrue("SearchIndex should save successfully", saved);
    assertTrue("Table should be auto-created",
               SearchIndexTableManager.tableExists(
                   "idx_" + searchIndex.getSearchIndexName() + "_ts", null));

    // Cleanup
    searchIndex.delete(true);
    SearchIndexTableManager.dropTableIfExists(searchIndex.getSearchIndexName(), null);
}

Rollback Plan

If automated table creation causes issues:

1. Disable Auto-Creation (Emergency)

// Add SysConfig check in MSearchIndex.beforeSave()
String autoCreateEnabled = MSysConfig.getValue(
    "SEARCHINDEX_AUTO_CREATE_TABLE", "Y", getAD_Client_ID()
);

if ("Y".equals(autoCreateEnabled)) {
    SearchIndexTableManager.createTableIfNotExists(searchIndexName, get_TrxName());
}

Set SEARCHINDEX_AUTO_CREATE_TABLE=N to revert to manual creation.

2. Manual Cleanup (If Needed)

-- Drop all auto-created tables
DO $$
DECLARE
    v_table_name TEXT;
BEGIN
    FOR v_table_name IN
        SELECT tablename FROM pg_tables
        WHERE schemaname = 'adempiere' AND tablename LIKE 'idx_%_ts'
    LOOP
        EXECUTE format('DROP TABLE IF EXISTS %I CASCADE', v_table_name);
        RAISE NOTICE 'Dropped table: %', v_table_name;
    END LOOP;
END $$;

Performance Impact

DDL Execution Time

Operation Time Notes
CREATE TABLE 10-50ms Instant (no data)
CREATE UNIQUE INDEX 5-10ms Empty table
CREATE GIN INDEX 5-10ms Empty table
Total 20-70ms Negligible overhead

User Experience

Before (Manual):

User creates AD_SearchIndex → Process fails → Wait for DBA → Re-run process
Total time: 15-60 minutes

After (Automated):

User creates AD_SearchIndex → Table auto-created (50ms) → Process succeeds
Total time: <1 second

Improvement: 900-3600× faster setup time


Consequences

Positive

  1. Zero DBA overhead

    • No manual DDL required
    • Self-service for developers
  2. Error elimination

    • Consistent schema (no typos)
    • Always includes ADR-006 fix (ad_client_id in UNIQUE)
  3. Faster deployments

    • 12 indexes: 6 hours → 1 minute
    • Supports rapid iteration
  4. Transactional safety

    • Rollback on error
    • No orphan tables

Negative

  1. More complex code

    • New class to maintain
    • More unit tests required
  2. Less DBA control

    • Tables created without review
    • Must trust template logic
  3. Migration complexity

    • Existing deployments need migration script
    • One-time effort

Alternatives Considered

Alternative 1: Liquibase/Flyway Migrations

Use database migration tool for DDL management:

<changeSet id="create-search-index-product" author="system">
    <createTable tableName="idx_product_search_ts">
        <column name="ad_client_id" type="NUMERIC(10)"/>
        <!-- ... -->
    </createTable>
</changeSet>

Pros: Industry standard, audit trail

Cons:

  • Requires external tool
  • Not dynamic (must pre-define tables)
  • REJECTED: Doesn't support dynamic index creation

Alternative 2: Application Dictionary (AD) Integration

Create AD_Table entries for each search index table:

Pros: Full iDempiere integration, UI generation

Cons:

  • Heavyweight (100+ AD records per table)
  • Complex synchronization
  • REJECTED: Overkill for internal tables

Alternative 3: iDempiere 2Pack

Package DDL in 2Pack for distribution:

Pros: Standard iDempiere distribution

Cons:

  • Still manual export/import
  • Not dynamic
  • REJECTED: Doesn't solve automation problem

References


Decision

Status: ✅ Proposed (Awaiting Approval) Approved By: [Pending] Approval Date: [Pending] Implementation Target: Phase 1 (Week 2)

Recommendation: APPROVE - Eliminates 90% of setup time, ensures schema consistency, critical for scalable deployments


Implementation Checklist

  • Create SearchIndexTableManager.java
  • Add unit tests (SearchIndexTableManagerTest.java)
  • Integrate with MSearchIndex.beforeSave()
  • Add SysConfig: SEARCHINDEX_AUTO_CREATE_TABLE (default: Y)
  • Add SysConfig: SEARCHINDEX_DELETE_TABLE_ON_INDEX_DELETE (default: N)
  • Create migration script for existing indexes
  • Update CLAUDE.md with new workflow
  • Update README.md (Quick Start section)
  • Add integration tests
  • Performance benchmarking
  • Documentation review

Next Steps:

  1. Review and approve ADR
  2. Create Linear issue (CLD-XXXX)
  3. Implement Phase 1-4 (estimated 4 days)
  4. Deploy to staging for testing
  5. Production rollout

Last Updated: 2025-12-18 Related ADRs: ADR-002 (SQL Injection), ADR-006 (Multi-Tenant Integrity)