Add record access strategy
This commit is contained in:
33
backend/src/object/models/base.model.ts
Normal file
33
backend/src/object/models/base.model.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Model } from 'objection';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* Base model for all dynamic and system models
|
||||
* Provides common functionality for all objects
|
||||
*/
|
||||
export class BaseModel extends Model {
|
||||
// Common fields
|
||||
id?: string;
|
||||
tenantId?: string;
|
||||
ownerId?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
||||
// Hook to set system-managed fields
|
||||
async $beforeInsert() {
|
||||
if (!this.id) {
|
||||
this.id = randomUUID();
|
||||
}
|
||||
if (!this.created_at) {
|
||||
this.created_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
if (!this.updated_at) {
|
||||
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
async $beforeUpdate() {
|
||||
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
201
backend/src/object/models/dynamic-model.factory.ts
Normal file
201
backend/src/object/models/dynamic-model.factory.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { ModelClass, JSONSchema, RelationMappings, Model } from 'objection';
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export interface FieldDefinition {
|
||||
apiName: string;
|
||||
label: string;
|
||||
type: string;
|
||||
isRequired?: boolean;
|
||||
isUnique?: boolean;
|
||||
referenceObject?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface RelationDefinition {
|
||||
name: string;
|
||||
type: 'belongsTo' | 'hasMany' | 'hasManyThrough';
|
||||
targetObjectApiName: string;
|
||||
fromColumn: string;
|
||||
toColumn: string;
|
||||
}
|
||||
|
||||
export interface ObjectMetadata {
|
||||
apiName: string;
|
||||
tableName: string;
|
||||
fields: FieldDefinition[];
|
||||
relations?: RelationDefinition[];
|
||||
}
|
||||
|
||||
export class DynamicModelFactory {
|
||||
/**
|
||||
* Get relation name from lookup field API name
|
||||
* Converts "ownerId" -> "owner", "customFieldId" -> "customfield"
|
||||
*/
|
||||
static getRelationName(lookupFieldApiName: string): string {
|
||||
return lookupFieldApiName.replace(/Id$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
getModel?: (apiName: string) => ModelClass<any>,
|
||||
): ModelClass<any> {
|
||||
const { tableName, fields, apiName, relations = [] } = meta;
|
||||
|
||||
// Build JSON schema properties
|
||||
const properties: Record<string, any> = {
|
||||
id: { type: 'string' },
|
||||
tenantId: { type: 'string' },
|
||||
ownerId: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
updated_at: { type: 'string', format: 'date-time' },
|
||||
};
|
||||
|
||||
// Don't require id or tenantId - they'll be set automatically
|
||||
const required: string[] = [];
|
||||
|
||||
// Add custom fields
|
||||
for (const field of fields) {
|
||||
properties[field.apiName] = this.fieldToJsonSchema(field);
|
||||
|
||||
// Only mark as required if explicitly required AND not a system field
|
||||
const systemFields = ['id', 'tenantId', 'ownerId', 'name', 'created_at', 'updated_at'];
|
||||
if (field.isRequired && !systemFields.includes(field.apiName)) {
|
||||
required.push(field.apiName);
|
||||
}
|
||||
}
|
||||
|
||||
// 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: DynamicModelFactory.getRelationName(f.apiName),
|
||||
referenceObject: f.referenceObject,
|
||||
targetTable: this.getTableName(f.referenceObject),
|
||||
}));
|
||||
|
||||
// Create the dynamic model class extending BaseModel
|
||||
class DynamicModel extends BaseModel {
|
||||
static tableName = tableName;
|
||||
|
||||
static objectApiName = apiName;
|
||||
|
||||
static lookupFields = lookupFieldsInfo;
|
||||
|
||||
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 {
|
||||
type: 'object',
|
||||
required,
|
||||
properties,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return DynamicModel as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a field definition to JSON schema property
|
||||
*/
|
||||
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
|
||||
switch (field.type.toUpperCase()) {
|
||||
case 'TEXT':
|
||||
case 'STRING':
|
||||
case 'EMAIL':
|
||||
case 'URL':
|
||||
case 'PHONE':
|
||||
case 'PICKLIST':
|
||||
case 'MULTI_PICKLIST':
|
||||
return {
|
||||
type: 'string',
|
||||
...(field.isUnique && { uniqueItems: true }),
|
||||
};
|
||||
|
||||
case 'LONG_TEXT':
|
||||
return { type: 'string' };
|
||||
|
||||
case 'NUMBER':
|
||||
case 'DECIMAL':
|
||||
case 'CURRENCY':
|
||||
case 'PERCENT':
|
||||
return {
|
||||
type: 'number',
|
||||
...(field.isUnique && { uniqueItems: true }),
|
||||
};
|
||||
|
||||
case 'INTEGER':
|
||||
return {
|
||||
type: 'integer',
|
||||
...(field.isUnique && { uniqueItems: true }),
|
||||
};
|
||||
|
||||
case 'BOOLEAN':
|
||||
return { type: 'boolean', default: false };
|
||||
|
||||
case 'DATE':
|
||||
return { type: 'string', format: 'date' };
|
||||
|
||||
case 'DATE_TIME':
|
||||
return { type: 'string', format: 'date-time' };
|
||||
|
||||
case 'LOOKUP':
|
||||
case 'BELONGS_TO':
|
||||
return { type: 'string' };
|
||||
|
||||
default:
|
||||
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`;
|
||||
}
|
||||
}
|
||||
68
backend/src/object/models/model.registry.ts
Normal file
68
backend/src/object/models/model.registry.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ModelClass } from 'objection';
|
||||
import { BaseModel } from './base.model';
|
||||
import { DynamicModelFactory, ObjectMetadata } from './dynamic-model.factory';
|
||||
|
||||
/**
|
||||
* Registry to store and retrieve dynamic models
|
||||
* One registry per tenant
|
||||
*/
|
||||
@Injectable()
|
||||
export class ModelRegistry {
|
||||
private registry = new Map<string, ModelClass<BaseModel>>();
|
||||
|
||||
/**
|
||||
* Register a model in the registry
|
||||
*/
|
||||
registerModel(apiName: string, modelClass: ModelClass<BaseModel>): void {
|
||||
this.registry.set(apiName, modelClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a model from the registry
|
||||
*/
|
||||
getModel(apiName: string): ModelClass<BaseModel> {
|
||||
const model = this.registry.get(apiName);
|
||||
if (!model) {
|
||||
throw new Error(`Model for ${apiName} not found in registry`);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model exists in the registry
|
||||
*/
|
||||
hasModel(apiName: string): boolean {
|
||||
return this.registry.has(apiName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and register a model from metadata
|
||||
*/
|
||||
createAndRegisterModel(
|
||||
metadata: ObjectMetadata,
|
||||
): ModelClass<BaseModel> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered model names
|
||||
*/
|
||||
getAllModelNames(): string[] {
|
||||
return Array.from(this.registry.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the registry (useful for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this.registry.clear();
|
||||
}
|
||||
}
|
||||
184
backend/src/object/models/model.service.ts
Normal file
184
backend/src/object/models/model.service.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Knex } from 'knex';
|
||||
import { ModelClass } from 'objection';
|
||||
import { BaseModel } from './base.model';
|
||||
import { ModelRegistry } from './model.registry';
|
||||
import { ObjectMetadata } from './dynamic-model.factory';
|
||||
import { TenantDatabaseService } from '../../tenant/tenant-database.service';
|
||||
import { UserModel, RoleModel, PermissionModel } from './system-models';
|
||||
|
||||
/**
|
||||
* Service to manage dynamic models for a specific tenant
|
||||
*/
|
||||
@Injectable()
|
||||
export class ModelService {
|
||||
private readonly logger = new Logger(ModelService.name);
|
||||
private tenantRegistries = new Map<string, ModelRegistry>();
|
||||
|
||||
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
/**
|
||||
* Get or create a registry for a tenant
|
||||
*/
|
||||
getTenantRegistry(tenantId: string): ModelRegistry {
|
||||
if (!this.tenantRegistries.has(tenantId)) {
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
// Register system models that are defined as static Objection models
|
||||
this.registerSystemModels(registry);
|
||||
|
||||
this.tenantRegistries.set(tenantId, registry);
|
||||
}
|
||||
return this.tenantRegistries.get(tenantId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register static system models in the registry
|
||||
* Uses simplified models without complex relationMappings to avoid modelPath issues
|
||||
*/
|
||||
private registerSystemModels(registry: ModelRegistry): void {
|
||||
// Register system models by their API name (used in referenceObject fields)
|
||||
// These are simplified versions without relationMappings to avoid dependency issues
|
||||
registry.registerModel('User', UserModel as any);
|
||||
registry.registerModel('Role', RoleModel as any);
|
||||
registry.registerModel('Permission', PermissionModel as any);
|
||||
|
||||
this.logger.debug('Registered system models: User, Role, Permission');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and register a model for a tenant
|
||||
*/
|
||||
async createModelForObject(
|
||||
tenantId: string,
|
||||
objectMetadata: ObjectMetadata,
|
||||
): Promise<ModelClass<BaseModel>> {
|
||||
const registry = this.getTenantRegistry(tenantId);
|
||||
const model = registry.createAndRegisterModel(objectMetadata);
|
||||
|
||||
this.logger.log(
|
||||
`Registered model for ${objectMetadata.apiName} in tenant ${tenantId}`,
|
||||
);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a model for a tenant and object
|
||||
*/
|
||||
getModel(tenantId: string, objectApiName: string): ModelClass<BaseModel> {
|
||||
const registry = this.getTenantRegistry(tenantId);
|
||||
return registry.getModel(objectApiName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a bound model (with knex connection) for a tenant and object
|
||||
*/
|
||||
async getBoundModel(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
): Promise<ModelClass<BaseModel>> {
|
||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
const model = this.getModel(tenantId, objectApiName);
|
||||
|
||||
// Bind knex to the model and also to all models in the registry
|
||||
// This ensures system models also have knex bound when they're used in relations
|
||||
const registry = this.getTenantRegistry(tenantId);
|
||||
const allModels = registry.getAllModelNames();
|
||||
|
||||
// Bind knex to all models to ensure relations work
|
||||
for (const modelName of allModels) {
|
||||
try {
|
||||
const m = registry.getModel(modelName);
|
||||
if (m && !m.knex()) {
|
||||
m.knex(knex);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors for models that don't need binding
|
||||
}
|
||||
}
|
||||
|
||||
return model.bindKnex(knex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model exists for a tenant
|
||||
*/
|
||||
hasModel(tenantId: string, objectApiName: string): boolean {
|
||||
const registry = this.getTenantRegistry(tenantId);
|
||||
return registry.hasModel(objectApiName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all model names for a tenant
|
||||
*/
|
||||
getAllModelNames(tenantId: string): string[] {
|
||||
const registry = this.getTenantRegistry(tenantId);
|
||||
return registry.getAllModelNames();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a model is registered with all its dependencies.
|
||||
* This method handles recursive model creation for related objects.
|
||||
*
|
||||
* @param tenantId - The tenant ID
|
||||
* @param objectApiName - The object API name to ensure registration for
|
||||
* @param fetchMetadata - Callback function to fetch object metadata (provided by ObjectService)
|
||||
* @param visited - Set to track visited models and prevent infinite loops
|
||||
*/
|
||||
async ensureModelWithDependencies(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
fetchMetadata: (apiName: string) => Promise<ObjectMetadata>,
|
||||
visited: Set<string> = new Set(),
|
||||
): Promise<void> {
|
||||
// Prevent infinite recursion
|
||||
if (visited.has(objectApiName)) {
|
||||
return;
|
||||
}
|
||||
visited.add(objectApiName);
|
||||
|
||||
// Check if model already exists
|
||||
if (this.hasModel(tenantId, objectApiName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the object metadata
|
||||
const objectMetadata = await fetchMetadata(objectApiName);
|
||||
|
||||
// Extract lookup fields to find dependencies
|
||||
const lookupFields = objectMetadata.fields.filter(
|
||||
f => f.type === 'LOOKUP' && f.referenceObject
|
||||
);
|
||||
|
||||
// Recursively ensure all dependent models are registered first
|
||||
for (const field of lookupFields) {
|
||||
if (field.referenceObject) {
|
||||
try {
|
||||
await this.ensureModelWithDependencies(
|
||||
tenantId,
|
||||
field.referenceObject,
|
||||
fetchMetadata,
|
||||
visited,
|
||||
);
|
||||
} catch (error) {
|
||||
// If related object doesn't exist (e.g., system tables), skip it
|
||||
this.logger.debug(
|
||||
`Skipping registration of related model ${field.referenceObject}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now create and register this model (all dependencies are ready)
|
||||
await this.createModelForObject(tenantId, objectMetadata);
|
||||
this.logger.log(`Registered model for ${objectApiName} in tenant ${tenantId}`);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to ensure model for ${objectApiName}: ${error.message}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
85
backend/src/object/models/system-models.ts
Normal file
85
backend/src/object/models/system-models.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Model } from 'objection';
|
||||
|
||||
/**
|
||||
* Simplified User model for use in dynamic object relations
|
||||
* This version doesn't include complex relationMappings to avoid modelPath issues
|
||||
*/
|
||||
export class UserModel extends Model {
|
||||
static tableName = 'users';
|
||||
static objectApiName = 'User';
|
||||
|
||||
id!: string;
|
||||
email!: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
name?: string;
|
||||
isActive!: boolean;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
firstName: { type: 'string' },
|
||||
lastName: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
isActive: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// No relationMappings to avoid modelPath resolution issues
|
||||
// These simplified models are only used for lookup relations from dynamic models
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified Role model for use in dynamic object relations
|
||||
*/
|
||||
export class RoleModel extends Model {
|
||||
static tableName = 'roles';
|
||||
static objectApiName = 'Role';
|
||||
|
||||
id!: string;
|
||||
name!: string;
|
||||
description?: string;
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified Permission model for use in dynamic object relations
|
||||
*/
|
||||
export class PermissionModel extends Model {
|
||||
static tableName = 'permissions';
|
||||
static objectApiName = 'Permission';
|
||||
|
||||
id!: string;
|
||||
name!: string;
|
||||
description?: string;
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user