Status: ✅ Proposed Date: 2025-12-18 Decision Makers: Development Team, Database Team Related Issues: Manual table creation overhead, time-consuming setup Supersedes: None
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:
- User creates
AD_SearchIndexrecord with nameproduct_search - User runs
CreateSearchIndexprocess - Process fails with error:
relation "idx_product_search_ts" does not exist - 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);
- User re-runs
CreateSearchIndexprocess (now succeeds)
| 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 |
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)
We will automate search index table creation by implementing a DDL generator that creates PostgreSQL tables dynamically when AD_SearchIndex records are created.
Component: SearchIndexTableManager (new class)
Capabilities:
- Automatic table creation when
AD_SearchIndexis saved - Schema validation before DDL execution
- Transactional DDL (rollback on failure)
- Template-based DDL (consistent with ADR-006)
- Idempotent operations (safe to re-run)
┌─────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────┘
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);
}
}
}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();
}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;");
}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 $$;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));
}@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);
}If automated table creation causes issues:
// 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.
-- 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 $$;| 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 |
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
-
Zero DBA overhead
- No manual DDL required
- Self-service for developers
-
Error elimination
- Consistent schema (no typos)
- Always includes ADR-006 fix (ad_client_id in UNIQUE)
-
Faster deployments
- 12 indexes: 6 hours → 1 minute
- Supports rapid iteration
-
Transactional safety
- Rollback on error
- No orphan tables
-
More complex code
- New class to maintain
- More unit tests required
-
Less DBA control
- Tables created without review
- Must trust template logic
-
Migration complexity
- Existing deployments need migration script
- One-time effort
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
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
Package DDL in 2Pack for distribution:
Pros: Standard iDempiere distribution
Cons:
- Still manual export/import
- Not dynamic
- REJECTED: Doesn't solve automation problem
- ADR-006: Multi-Tenant Data Integrity (UNIQUE constraint pattern)
- ADR-002: SQL Injection Prevention (table name sanitization)
- iDempiere Model Validator Best Practices: http://wiki.idempiere.org/en/Model_Validator
- PostgreSQL DDL Best Practices: https://www.postgresql.org/docs/current/ddl.html
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
- 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:
- Review and approve ADR
- Create Linear issue (CLD-XXXX)
- Implement Phase 1-4 (estimated 4 days)
- Deploy to staging for testing
- Production rollout
Last Updated: 2025-12-18 Related ADRs: ADR-002 (SQL Injection), ADR-006 (Multi-Tenant Integrity)