Add Contact standard object, related lists, meilisearch, pagination, search, AI assistant

This commit is contained in:
Francisco Gaona
2026-01-16 18:01:26 +01:00
parent 51c82d3d95
commit 20fc90a3fb
62 changed files with 6613 additions and 278 deletions

View File

@@ -15,7 +15,11 @@ export class SchemaManagementService {
objectDefinition: ObjectDefinition,
fields: FieldDefinition[],
) {
const tableName = this.getTableName(objectDefinition.apiName);
const tableName = this.getTableName(
objectDefinition.apiName,
objectDefinition.label,
objectDefinition.pluralLabel,
);
// Check if table already exists
const exists = await knex.schema.hasTable(tableName);
@@ -44,8 +48,10 @@ export class SchemaManagementService {
knex: Knex,
objectApiName: string,
field: FieldDefinition,
objectLabel?: string,
pluralLabel?: string,
) {
const tableName = this.getTableName(objectApiName);
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.alterTable(tableName, (table) => {
this.addFieldColumn(table, field);
@@ -61,8 +67,10 @@ export class SchemaManagementService {
knex: Knex,
objectApiName: string,
fieldApiName: string,
objectLabel?: string,
pluralLabel?: string,
) {
const tableName = this.getTableName(objectApiName);
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.alterTable(tableName, (table) => {
table.dropColumn(fieldApiName);
@@ -71,11 +79,44 @@ export class SchemaManagementService {
this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`);
}
/**
* Alter a field in an existing object table
* Handles safe updates like changing NOT NULL or constraints
* Warns about potentially destructive operations
*/
async alterFieldInTable(
knex: Knex,
objectApiName: string,
fieldApiName: string,
field: FieldDefinition,
objectLabel?: string,
pluralLabel?: string,
options?: {
skipTypeChange?: boolean; // Skip if type change would lose data
},
) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
const skipTypeChange = options?.skipTypeChange ?? true;
await knex.schema.alterTable(tableName, (table) => {
// Drop the existing column and recreate with new definition
// Note: This approach works for metadata changes, but type changes may need data migration
table.dropColumn(fieldApiName);
});
// Recreate the column with new definition
await knex.schema.alterTable(tableName, (table) => {
this.addFieldColumn(table, field);
});
this.logger.log(`Altered field ${fieldApiName} in table ${tableName}`);
}
/**
* Drop an object table
*/
async dropObjectTable(knex: Knex, objectApiName: string) {
const tableName = this.getTableName(objectApiName);
async dropObjectTable(knex: Knex, objectApiName: string, objectLabel?: string, pluralLabel?: string) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.dropTableIfExists(tableName);
@@ -94,15 +135,30 @@ export class SchemaManagementService {
let column: Knex.ColumnBuilder;
switch (field.type) {
// Text types
case 'String':
case 'TEXT':
case 'EMAIL':
case 'PHONE':
case 'URL':
column = table.string(columnName, field.length || 255);
break;
case 'Text':
case 'LONG_TEXT':
column = table.text(columnName);
break;
case 'PICKLIST':
case 'MULTI_PICKLIST':
column = table.string(columnName, 255);
break;
// Numeric types
case 'Number':
case 'NUMBER':
case 'CURRENCY':
case 'PERCENT':
if (field.scale && field.scale > 0) {
column = table.decimal(
columnName,
@@ -115,18 +171,28 @@ export class SchemaManagementService {
break;
case 'Boolean':
case 'BOOLEAN':
column = table.boolean(columnName).defaultTo(false);
break;
// Date types
case 'Date':
case 'DATE':
column = table.date(columnName);
break;
case 'DateTime':
case 'DATE_TIME':
column = table.datetime(columnName);
break;
case 'TIME':
column = table.time(columnName);
break;
// Relationship types
case 'Reference':
case 'LOOKUP':
column = table.uuid(columnName);
if (field.referenceObject) {
const refTableName = this.getTableName(field.referenceObject);
@@ -134,19 +200,30 @@ export class SchemaManagementService {
}
break;
// Email (legacy)
case 'Email':
column = table.string(columnName, 255);
break;
// Phone (legacy)
case 'Phone':
column = table.string(columnName, 50);
break;
// Url (legacy)
case 'Url':
column = table.string(columnName, 255);
break;
// File types
case 'FILE':
case 'IMAGE':
column = table.text(columnName); // Store file path or URL
break;
// JSON
case 'Json':
case 'JSON':
column = table.json(columnName);
break;
@@ -174,16 +251,35 @@ export class SchemaManagementService {
/**
* Convert object API name to table name (convert to snake_case, pluralize)
*/
private getTableName(apiName: string): string {
// Convert PascalCase to snake_case
const snakeCase = apiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string {
const toSnakePlural = (source: string): string => {
const cleaned = source.replace(/[\s-]+/g, '_');
const snake = cleaned
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/__+/g, '_')
.toLowerCase()
.replace(/^_/, '');
// Simple pluralization (append 's' if not already plural)
// In production, use a proper pluralization library
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
if (snake.endsWith('s')) return snake;
return `${snake}s`;
};
const fromApi = toSnakePlural(apiName);
const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null;
const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null;
if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) {
return fromLabel;
}
if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) {
return fromPlural;
}
if (fromLabel && fromLabel !== fromApi) return fromLabel;
if (fromPlural && fromPlural !== fromApi) return fromPlural;
return fromApi;
}
/**