WIP - use objection and working lookup field to owner

This commit is contained in:
Francisco Gaona
2025-12-24 21:43:58 +01:00
parent 4520f94b69
commit c5305490c1
14 changed files with 334 additions and 112 deletions

View File

@@ -125,6 +125,7 @@ model FieldDefinition {
isSystem Boolean @default(false)
isCustom Boolean @default(true)
displayOrder Int @default(0)
uiMetadata Json? @map("ui_metadata")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -51,13 +51,29 @@ export class FieldMapperService {
* Convert a field definition from the database to a frontend-friendly FieldConfig
*/
mapFieldToDTO(field: any): FieldConfigDTO {
const uiMetadata = field.uiMetadata || {};
// Parse ui_metadata if it's a JSON string or object
let uiMetadata: any = {};
const metadataField = field.ui_metadata || field.uiMetadata;
if (metadataField) {
if (typeof metadataField === 'string') {
try {
uiMetadata = JSON.parse(metadataField);
} catch (e) {
uiMetadata = {};
}
} else {
uiMetadata = metadataField;
}
}
const frontendType = this.mapFieldType(field.type);
const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup');
return {
id: field.id,
apiName: field.apiName,
label: field.label,
type: this.mapFieldType(field.type),
type: frontendType,
// Display properties
placeholder: uiMetadata.placeholder || field.description,
@@ -82,7 +98,10 @@ export class FieldMapperService {
step: uiMetadata.step,
accept: uiMetadata.accept,
relationObject: field.referenceObject,
relationDisplayField: uiMetadata.relationDisplayField,
// For lookup fields, provide default display field if not specified
relationDisplayField: isLookupField
? (uiMetadata.relationDisplayField || 'name')
: uiMetadata.relationDisplayField,
// Formatting
format: uiMetadata.format,

View File

@@ -30,8 +30,13 @@ export interface ObjectMetadata {
export class DynamicModelFactory {
/**
* Create a dynamic model class from object metadata
* @param meta Object metadata
* @param getModel Function to retrieve model classes from registry
*/
static createModel(meta: ObjectMetadata): ModelClass<any> {
static createModel(
meta: ObjectMetadata,
getModel?: (apiName: string) => ModelClass<any>,
): ModelClass<any> {
const { tableName, fields, apiName, relations = [] } = meta;
// Build JSON schema properties
@@ -57,12 +62,16 @@ export class DynamicModelFactory {
}
}
// Build relation mappings
const relationMappings: RelationMappings = {};
for (const rel of relations) {
// Relations are resolved dynamically, skipping for now
// Will be handled by ModelRegistry.getModel()
}
// Build relation mappings from lookup fields
const lookupFields = fields.filter(f => f.type === 'LOOKUP' && f.referenceObject);
// Store lookup fields metadata for later use
const lookupFieldsInfo = lookupFields.map(f => ({
apiName: f.apiName,
relationName: f.apiName.replace(/Id$/, '').toLowerCase(),
referenceObject: f.referenceObject,
targetTable: this.getTableName(f.referenceObject),
}));
// Create the dynamic model class extending Model directly
class DynamicModel extends Model {
@@ -76,8 +85,41 @@ export class DynamicModelFactory {
static tableName = tableName;
static objectApiName = apiName;
static lookupFields = lookupFieldsInfo;
static relationMappings = relationMappings;
static get relationMappings(): RelationMappings {
const mappings: RelationMappings = {};
// Build relation mappings from lookup fields
for (const lookupInfo of lookupFieldsInfo) {
// Use getModel function if provided, otherwise use string reference
let modelClass: any = lookupInfo.referenceObject;
if (getModel) {
const resolvedModel = getModel(lookupInfo.referenceObject);
// Only use resolved model if it exists, otherwise skip this relation
// It will be resolved later when the model is registered
if (resolvedModel) {
modelClass = resolvedModel;
} else {
// Skip this relation if model not found yet
continue;
}
}
mappings[lookupInfo.relationName] = {
relation: Model.BelongsToOneRelation,
modelClass,
join: {
from: `${tableName}.${lookupInfo.apiName}`,
to: `${lookupInfo.targetTable}.id`,
},
};
}
return mappings;
}
static get jsonSchema() {
return {
@@ -159,4 +201,16 @@ export class DynamicModelFactory {
return { type: 'string' };
}
}
/**
* Get table name from object API name
*/
private static getTableName(objectApiName: string): string {
// Convert PascalCase/camelCase to snake_case and pluralize
const snakeCase = objectApiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
}
}

View File

@@ -42,7 +42,12 @@ export class ModelRegistry {
createAndRegisterModel(
metadata: ObjectMetadata,
): ModelClass<BaseModel> {
const model = DynamicModelFactory.createModel(metadata);
// Create model with a getModel function that resolves from this registry
// Returns undefined if model not found (for models not yet registered)
const model = DynamicModelFactory.createModel(
metadata,
(apiName: string) => this.registry.get(apiName),
);
this.registerModel(metadata.apiName, model);
return model;
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CustomMigrationService } from '../migration/custom-migration.service';
import { ModelService } from './models/model.service';
@@ -6,6 +6,8 @@ import { ObjectMetadata } from './models/dynamic-model.factory';
@Injectable()
export class ObjectService {
private readonly logger = new Logger(ObjectService.name);
constructor(
private tenantDbService: TenantDatabaseService,
private customMigrationService: CustomMigrationService,
@@ -107,14 +109,14 @@ export class ObjectService {
description: 'The user who owns this record',
isRequired: false, // Auto-set by system
isUnique: false,
referenceObject: null,
referenceObject: 'User',
isSystem: true,
isCustom: false,
},
{
apiName: 'name',
label: 'Name',
type: 'TEXT',
type: 'STRING',
description: 'The primary name field for this record',
isRequired: false, // Optional field
isUnique: false,
@@ -156,13 +158,22 @@ export class ObjectService {
.first();
if (!existingField) {
await knex('field_definitions').insert({
const fieldData: any = {
id: knex.raw('(UUID())'),
objectDefinitionId: objectDef.id,
...field,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
};
// For lookup fields, set ui_metadata with relationDisplayField
if (field.type === 'LOOKUP') {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: 'name',
});
}
await knex('field_definitions').insert(fieldData);
}
}
@@ -226,6 +237,8 @@ export class ObjectService {
isRequired?: boolean;
isUnique?: boolean;
referenceObject?: string;
relationObject?: string;
relationDisplayField?: string;
defaultValue?: string;
},
) {
@@ -233,13 +246,35 @@ export class ObjectService {
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const obj = await this.getObjectDefinition(tenantId, objectApiName);
const [id] = await knex('field_definitions').insert({
// Convert frontend type to database type
const dbFieldType = this.convertFrontendFieldType(data.type);
// Use relationObject if provided (alias for referenceObject)
const referenceObject = data.referenceObject || data.relationObject;
const fieldData: any = {
id: knex.raw('(UUID())'),
objectDefinitionId: obj.id,
...data,
apiName: data.apiName,
label: data.label,
type: dbFieldType,
description: data.description,
isRequired: data.isRequired ?? false,
isUnique: data.isUnique ?? false,
referenceObject: referenceObject,
defaultValue: data.defaultValue,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
};
// Store relationDisplayField in UI metadata if provided
if (data.relationDisplayField) {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: data.relationDisplayField,
});
}
const [id] = await knex('field_definitions').insert(fieldData);
return knex('field_definitions').where({ id }).first();
}
@@ -279,6 +314,39 @@ export class ObjectService {
};
}
/**
* Convert frontend field type to database field type
*/
private convertFrontendFieldType(frontendType: string): string {
const typeMap: Record<string, string> = {
'text': 'TEXT',
'textarea': 'LONG_TEXT',
'password': 'TEXT',
'email': 'EMAIL',
'number': 'NUMBER',
'currency': 'CURRENCY',
'percent': 'PERCENT',
'select': 'PICKLIST',
'multiSelect': 'MULTI_PICKLIST',
'boolean': 'BOOLEAN',
'date': 'DATE',
'datetime': 'DATE_TIME',
'time': 'TIME',
'url': 'URL',
'color': 'TEXT',
'json': 'JSON',
'belongsTo': 'LOOKUP',
'hasMany': 'LOOKUP',
'manyToMany': 'LOOKUP',
'markdown': 'LONG_TEXT',
'code': 'LONG_TEXT',
'file': 'FILE',
'image': 'IMAGE',
};
return typeMap[frontendType] || 'TEXT';
}
// Runtime endpoints - CRUD operations
async getRecords(
tenantId: string,
@@ -289,8 +357,8 @@ export class ObjectService {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Verify object exists
await this.getObjectDefinition(tenantId, objectApiName);
// Verify object exists and get field definitions
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
const tableName = this.getTableName(objectApiName);
@@ -301,6 +369,23 @@ export class ObjectService {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query();
// Build graph expression for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean)
.join(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
@@ -315,15 +400,16 @@ export class ObjectService {
return query.select('*');
}
} catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`);
}
// Fallback to raw Knex
// Fallback to manual data hydration
let query = knex(tableName);
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
query = query.where({ ownerId: userId });
query = query.where({ [`${tableName}.ownerId`]: userId });
}
// Apply additional filters
@@ -331,7 +417,49 @@ export class ObjectService {
query = query.where(filters);
}
return query.select('*');
// Get base records
const records = await query.select(`${tableName}.*`);
// Fetch and attach related records for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0 && records.length > 0) {
for (const field of lookupFields) {
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
const relatedTable = this.getTableName(field.referenceObject);
// Get unique IDs to fetch
const relatedIds = [...new Set(
records
.map(r => r[field.apiName])
.filter(Boolean)
)];
if (relatedIds.length > 0) {
// Fetch all related records in one query
const relatedRecords = await knex(relatedTable)
.whereIn('id', relatedIds)
.select('*');
// Create a map for quick lookup
const relatedMap = new Map(
relatedRecords.map(r => [r.id, r])
);
// Attach related records to main records
for (const record of records) {
const relatedId = record[field.apiName];
if (relatedId && relatedMap.has(relatedId)) {
record[relationName] = relatedMap.get(relatedId);
}
}
}
}
}
return records;
}
async getRecord(
@@ -343,8 +471,8 @@ export class ObjectService {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Verify object exists
await this.getObjectDefinition(tenantId, objectApiName);
// Verify object exists and get field definitions
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
const tableName = this.getTableName(objectApiName);
@@ -355,6 +483,23 @@ export class ObjectService {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query().where({ id: recordId });
// Build graph expression for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean)
.join(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
@@ -368,23 +513,48 @@ export class ObjectService {
return record;
}
} catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`);
}
// Fallback to raw Knex
let query = knex(tableName).where({ id: recordId });
// Fallback to manual data hydration
let query = knex(tableName).where({ [`${tableName}.id`]: recordId });
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
query = query.where({ ownerId: userId });
query = query.where({ [`${tableName}.ownerId`]: userId });
}
const record = await query.first();
if (!record) {
throw new NotFoundException('Record not found');
}
// Fetch and attach related records for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
for (const field of lookupFields) {
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
const relatedTable = this.getTableName(field.referenceObject);
const relatedId = record[field.apiName];
if (relatedId) {
// Fetch the related record
const relatedRecord = await knex(relatedTable)
.where({ id: relatedId })
.first();
if (relatedRecord) {
record[relationName] = relatedRecord;
}
}
}
}
return record;
}

View File

@@ -29,7 +29,8 @@ export class SetupObjectController {
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
) {
return this.objectService.getObjectDefinition(tenantId, objectApiName);
const objectDef = await this.objectService.getObjectDefinition(tenantId, objectApiName);
return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef);
}
@Get(':objectApiName/ui-config')
@@ -58,10 +59,12 @@ export class SetupObjectController {
@Param('objectApiName') objectApiName: string,
@Body() data: any,
) {
return this.objectService.createFieldDefinition(
const field = await this.objectService.createFieldDefinition(
tenantId,
objectApiName,
data,
);
// Map the created field to frontend format
return this.fieldMapperService.mapFieldToDTO(field);
}
}