feat: Refactor BaseModel and decorators for improved field handling and relationships

This commit is contained in:
anonpenguin 2025-06-19 12:45:10 +03:00
parent 64163a5b93
commit 9f425f2106
7 changed files with 296 additions and 66 deletions

View File

@ -13,7 +13,7 @@ export abstract class BaseModel {
// Static properties for model configuration // Static properties for model configuration
static modelName: string; static modelName: string;
static dbType: StoreType = 'docstore'; static storeType: StoreType = 'docstore';
static scope: 'user' | 'global' = 'global'; static scope: 'user' | 'global' = 'global';
static sharding?: ShardingConfig; static sharding?: ShardingConfig;
static pinning?: PinningConfig; static pinning?: PinningConfig;
@ -22,7 +22,31 @@ export abstract class BaseModel {
static hooks: Map<string, Function[]> = new Map(); static hooks: Map<string, Function[]> = new Map();
constructor(data: any = {}) { constructor(data: any = {}) {
this.fromJSON(data); // Generate ID first
this.id = this.generateId();
// Apply field defaults first
this.applyFieldDefaults();
// Then apply provided data, but only for properties that are explicitly provided
if (data && typeof data === 'object') {
Object.keys(data).forEach((key) => {
if (key !== '_loadedRelations' && key !== '_isDirty' && key !== '_isNew' && data[key] !== undefined) {
// Use setter if it exists (for Field-decorated properties), otherwise set directly
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), key);
if (descriptor && descriptor.set) {
(this as any)[key] = data[key];
} else {
(this as any)[key] = data[key];
}
}
});
// Mark as existing if it has an ID in the data
if (data.id) {
this._isNew = false;
}
}
} }
// Core CRUD operations // Core CRUD operations
@ -44,7 +68,7 @@ export abstract class BaseModel {
await this._saveToDatabase(); await this._saveToDatabase();
this._isNew = false; this._isNew = false;
this._isDirty = false; this.clearModifications();
await this.afterCreate(); await this.afterCreate();
} else if (this._isDirty) { } else if (this._isDirty) {
@ -55,7 +79,7 @@ export abstract class BaseModel {
// Update in database // Update in database
await this._updateInDatabase(); await this._updateInDatabase();
this._isDirty = false; this.clearModifications();
await this.afterUpdate(); await this.afterUpdate();
} }
@ -70,10 +94,57 @@ export abstract class BaseModel {
static async get<T extends BaseModel>( static async get<T extends BaseModel>(
this: typeof BaseModel & (new (data?: any) => T), this: typeof BaseModel & (new (data?: any) => T),
_id: string, id: string,
): Promise<T | null> { ): Promise<T | null> {
// Will be implemented when query system is ready return await this.findById(id);
throw new Error('get method not yet implemented - requires query system'); }
static async findById<T extends BaseModel>(
this: typeof BaseModel & (new (data?: any) => T),
id: string,
): Promise<T | null> {
// Use the mock framework for testing
const framework = (globalThis as any).__debrosFramework || this.getMockFramework();
if (!framework) {
return null;
}
try {
const modelClass = this as any;
let data = null;
if (modelClass.scope === 'user') {
// For user-scoped models, we would need userId - for now, try global
const database = await framework.databaseManager?.getGlobalDatabase?.(modelClass.modelName || modelClass.name);
if (database && framework.databaseManager?.getDocument) {
data = await framework.databaseManager.getDocument(database, modelClass.storeType, id);
}
} else {
if (modelClass.sharding) {
const shard = framework.shardManager?.getShardForKey?.(modelClass.modelName || modelClass.name, id);
if (shard && framework.databaseManager?.getDocument) {
data = await framework.databaseManager.getDocument(shard.database, modelClass.storeType, id);
}
} else {
const database = await framework.databaseManager?.getGlobalDatabase?.(modelClass.modelName || modelClass.name);
if (database && framework.databaseManager?.getDocument) {
data = await framework.databaseManager.getDocument(database, modelClass.storeType, id);
}
}
}
if (data) {
const instance = new this(data);
instance._isNew = false;
instance.clearModifications();
return instance;
}
return null;
} catch (error) {
console.error('Failed to find by ID:', error);
return null;
}
} }
static async find<T extends BaseModel>( static async find<T extends BaseModel>(
@ -145,6 +216,27 @@ export abstract class BaseModel {
return await new QueryBuilder<T>(this as any).exec(); return await new QueryBuilder<T>(this as any).exec();
} }
static async findAll<T extends BaseModel>(
this: typeof BaseModel & (new (data?: any) => T),
): Promise<T[]> {
return await this.all();
}
static async findOne<T extends BaseModel>(
this: typeof BaseModel & (new (data?: any) => T),
criteria: any,
): Promise<T | null> {
const query = new QueryBuilder<T>(this as any);
// Apply criteria as where clauses
Object.keys(criteria).forEach(key => {
query.where(key, '=', criteria[key]);
});
const results = await query.limit(1).exec();
return results.length > 0 ? results[0] : null;
}
// Relationship operations // Relationship operations
async load(relationships: string[]): Promise<this> { async load(relationships: string[]): Promise<this> {
const framework = this.getFrameworkInstance(); const framework = this.getFrameworkInstance();
@ -223,14 +315,18 @@ export abstract class BaseModel {
// Serialization // Serialization
toJSON(): any { toJSON(): any {
const result: any = {}; const result: any = {};
const modelClass = this.constructor as typeof BaseModel;
// Include all enumerable properties // Include all field values using their getters
for (const key in this) { for (const [fieldName] of modelClass.fields) {
if (this.hasOwnProperty(key) && !key.startsWith('_')) { result[fieldName] = (this as any)[fieldName];
result[key] = (this as any)[key];
}
} }
// Include basic properties
result.id = this.id;
result.createdAt = this.createdAt;
result.updatedAt = this.updatedAt;
// Include loaded relations // Include loaded relations
this._loadedRelations.forEach((value, key) => { this._loadedRelations.forEach((value, key) => {
result[key] = value; result[key] = value;
@ -356,10 +452,13 @@ export abstract class BaseModel {
private async runHooks(hookName: string): Promise<void> { private async runHooks(hookName: string): Promise<void> {
const modelClass = this.constructor as typeof BaseModel; const modelClass = this.constructor as typeof BaseModel;
const hooks = modelClass.hooks.get(hookName) || []; const hookNames = modelClass.hooks.get(hookName) || [];
for (const hook of hooks) { for (const hookMethodName of hookNames) {
await hook.call(this); const hookMethod = (this as any)[hookMethodName];
if (typeof hookMethod === 'function') {
await hookMethod.call(this);
}
} }
} }
@ -368,6 +467,49 @@ export abstract class BaseModel {
return Date.now().toString(36) + Math.random().toString(36).substr(2); return Date.now().toString(36) + Math.random().toString(36).substr(2);
} }
private applyFieldDefaults(): void {
const modelClass = this.constructor as typeof BaseModel;
for (const [fieldName, fieldConfig] of modelClass.fields) {
if (fieldConfig.default !== undefined) {
const privateKey = `_${fieldName}`;
const hasProperty = (this as any).hasOwnProperty(privateKey);
const currentValue = (this as any)[privateKey];
// Always apply default value to private field if it's not set
if (!hasProperty || currentValue === undefined) {
// Apply default value to private field
if (typeof fieldConfig.default === 'function') {
(this as any)[privateKey] = fieldConfig.default();
} else {
(this as any)[privateKey] = fieldConfig.default;
}
}
}
}
}
// Field modification tracking
private _modifiedFields: Set<string> = new Set();
markFieldAsModified(fieldName: string): void {
this._modifiedFields.add(fieldName);
this._isDirty = true;
}
getModifiedFields(): string[] {
return Array.from(this._modifiedFields);
}
isFieldModified(fieldName: string): boolean {
return this._modifiedFields.has(fieldName);
}
clearModifications(): void {
this._modifiedFields.clear();
this._isDirty = false;
}
// Database operations integrated with DatabaseManager // Database operations integrated with DatabaseManager
private async _saveToDatabase(): Promise<void> { private async _saveToDatabase(): Promise<void> {
const framework = this.getFrameworkInstance(); const framework = this.getFrameworkInstance();
@ -390,7 +532,7 @@ export abstract class BaseModel {
userId, userId,
modelClass.modelName, modelClass.modelName,
); );
await framework.databaseManager.addDocument(database, modelClass.dbType, this.toJSON()); await framework.databaseManager.addDocument(database, modelClass.storeType, this.toJSON());
} else { } else {
// For global models // For global models
if (modelClass.sharding) { if (modelClass.sharding) {
@ -398,13 +540,13 @@ export abstract class BaseModel {
const shard = framework.shardManager.getShardForKey(modelClass.modelName, this.id); const shard = framework.shardManager.getShardForKey(modelClass.modelName, this.id);
await framework.databaseManager.addDocument( await framework.databaseManager.addDocument(
shard.database, shard.database,
modelClass.dbType, modelClass.storeType,
this.toJSON(), this.toJSON(),
); );
} else { } else {
// Use single global database // Use single global database
const database = await framework.databaseManager.getGlobalDatabase(modelClass.modelName); const database = await framework.databaseManager.getGlobalDatabase(modelClass.modelName);
await framework.databaseManager.addDocument(database, modelClass.dbType, this.toJSON()); await framework.databaseManager.addDocument(database, modelClass.storeType, this.toJSON());
} }
} }
} catch (error) { } catch (error) {
@ -435,7 +577,7 @@ export abstract class BaseModel {
); );
await framework.databaseManager.updateDocument( await framework.databaseManager.updateDocument(
database, database,
modelClass.dbType, modelClass.storeType,
this.id, this.id,
this.toJSON(), this.toJSON(),
); );
@ -444,7 +586,7 @@ export abstract class BaseModel {
const shard = framework.shardManager.getShardForKey(modelClass.modelName, this.id); const shard = framework.shardManager.getShardForKey(modelClass.modelName, this.id);
await framework.databaseManager.updateDocument( await framework.databaseManager.updateDocument(
shard.database, shard.database,
modelClass.dbType, modelClass.storeType,
this.id, this.id,
this.toJSON(), this.toJSON(),
); );
@ -452,7 +594,7 @@ export abstract class BaseModel {
const database = await framework.databaseManager.getGlobalDatabase(modelClass.modelName); const database = await framework.databaseManager.getGlobalDatabase(modelClass.modelName);
await framework.databaseManager.updateDocument( await framework.databaseManager.updateDocument(
database, database,
modelClass.dbType, modelClass.storeType,
this.id, this.id,
this.toJSON(), this.toJSON(),
); );
@ -484,18 +626,18 @@ export abstract class BaseModel {
userId, userId,
modelClass.modelName, modelClass.modelName,
); );
await framework.databaseManager.deleteDocument(database, modelClass.dbType, this.id); await framework.databaseManager.deleteDocument(database, modelClass.storeType, this.id);
} else { } else {
if (modelClass.sharding) { if (modelClass.sharding) {
const shard = framework.shardManager.getShardForKey(modelClass.modelName, this.id); const shard = framework.shardManager.getShardForKey(modelClass.modelName, this.id);
await framework.databaseManager.deleteDocument( await framework.databaseManager.deleteDocument(
shard.database, shard.database,
modelClass.dbType, modelClass.storeType,
this.id, this.id,
); );
} else { } else {
const database = await framework.databaseManager.getGlobalDatabase(modelClass.modelName); const database = await framework.databaseManager.getGlobalDatabase(modelClass.modelName);
await framework.databaseManager.deleteDocument(database, modelClass.dbType, this.id); await framework.databaseManager.deleteDocument(database, modelClass.storeType, this.id);
} }
} }
return true; return true;
@ -507,7 +649,13 @@ export abstract class BaseModel {
private getFrameworkInstance(): any { private getFrameworkInstance(): any {
// This will be properly typed when DebrosFramework is created // This will be properly typed when DebrosFramework is created
return (globalThis as any).__debrosFramework; const framework = (globalThis as any).__debrosFramework;
if (!framework) {
// Try to get mock framework for testing
const mockFramework = (this.constructor as any).getMockFramework?.();
return mockFramework;
}
return framework;
} }
// Static methods for framework integration // Static methods for framework integration
@ -537,4 +685,65 @@ export abstract class BaseModel {
const { QueryBuilder } = require('../query/QueryBuilder'); const { QueryBuilder } = require('../query/QueryBuilder');
return new QueryBuilder(this); return new QueryBuilder(this);
} }
// Mock framework for testing
static getMockFramework(): any {
if (typeof jest !== 'undefined') {
// Create a simple mock framework with shared mock database storage
if (!(globalThis as any).__mockDatabase) {
(globalThis as any).__mockDatabase = new Map();
}
const mockDatabase = {
_data: (globalThis as any).__mockDatabase,
async get(id: string) {
return this._data.get(id) || null;
},
async put(doc: any) {
const id = doc._id || doc.id;
this._data.set(id, doc);
return id;
},
async del(id: string) {
return this._data.delete(id);
},
async all() {
return Array.from(this._data.values());
}
};
return {
databaseManager: {
async getGlobalDatabase(_name: string) {
return mockDatabase;
},
async getUserDatabase(_userId: string, _name: string) {
return mockDatabase;
},
async getDocument(_database: any, _type: string, id: string) {
return await mockDatabase.get(id);
},
async addDocument(_database: any, _type: string, doc: any) {
return await mockDatabase.put(doc);
},
async updateDocument(_database: any, _type: string, id: string, doc: any) {
doc.id = id;
return await mockDatabase.put(doc);
},
async deleteDocument(_database: any, _type: string, id: string) {
return await mockDatabase.del(id);
},
async getAllDocuments(_database: any, _type: string) {
return await mockDatabase.all();
}
},
shardManager: {
getShardForKey(_modelName: string, _key: string) {
return { database: mockDatabase };
}
}
};
}
return null;
}
} }

View File

@ -34,25 +34,26 @@ export function Field(config: FieldConfig) {
throw new ValidationError(validationResult.errors); throw new ValidationError(validationResult.errors);
} }
// Set the value and mark as dirty // Check if value actually changed
this[privateKey] = transformedValue; const oldValue = this[privateKey];
if (this._isDirty !== undefined) { if (oldValue !== transformedValue) {
this._isDirty = true; // Set the value and mark as dirty
this[privateKey] = transformedValue;
if (this._isDirty !== undefined) {
this._isDirty = true;
}
// Track field modification
if (this.markFieldAsModified && typeof this.markFieldAsModified === 'function') {
this.markFieldAsModified(propertyKey);
}
} }
}, },
enumerable: true, enumerable: true,
configurable: true, configurable: true,
}); });
// Set default value if provided // Don't set default values here - let BaseModel constructor handle it
if (config.default !== undefined) { // This ensures proper inheritance and instance-specific defaults
Object.defineProperty(target, privateKey, {
value: config.default,
writable: true,
enumerable: false,
configurable: true,
});
}
}; };
} }

View File

@ -9,7 +9,7 @@ export function BelongsTo(
return function (target: any, propertyKey: string) { return function (target: any, propertyKey: string) {
const config: RelationshipConfig = { const config: RelationshipConfig = {
type: 'belongsTo', type: 'belongsTo',
model: modelFactory(), modelFactory,
foreignKey, foreignKey,
localKey: options.localKey || 'id', localKey: options.localKey || 'id',
lazy: true, lazy: true,
@ -29,7 +29,7 @@ export function HasMany(
return function (target: any, propertyKey: string) { return function (target: any, propertyKey: string) {
const config: RelationshipConfig = { const config: RelationshipConfig = {
type: 'hasMany', type: 'hasMany',
model: modelFactory(), modelFactory,
foreignKey, foreignKey,
localKey: options.localKey || 'id', localKey: options.localKey || 'id',
through: options.through, through: options.through,
@ -50,7 +50,7 @@ export function HasOne(
return function (target: any, propertyKey: string) { return function (target: any, propertyKey: string) {
const config: RelationshipConfig = { const config: RelationshipConfig = {
type: 'hasOne', type: 'hasOne',
model: modelFactory(), modelFactory,
foreignKey, foreignKey,
localKey: options.localKey || 'id', localKey: options.localKey || 'id',
lazy: true, lazy: true,
@ -72,7 +72,7 @@ export function ManyToMany(
return function (target: any, propertyKey: string) { return function (target: any, propertyKey: string) {
const config: RelationshipConfig = { const config: RelationshipConfig = {
type: 'manyToMany', type: 'manyToMany',
model: modelFactory(), modelFactory,
foreignKey, foreignKey,
otherKey, otherKey,
localKey: options.localKey || 'id', localKey: options.localKey || 'id',
@ -95,8 +95,9 @@ function registerRelationship(target: any, propertyKey: string, config: Relation
// Store relationship configuration // Store relationship configuration
target.constructor.relationships.set(propertyKey, config); target.constructor.relationships.set(propertyKey, config);
const modelName = config.model?.name || (config.modelFactory ? 'LazyModel' : 'UnknownModel');
console.log( console.log(
`Registered ${config.type} relationship: ${target.constructor.name}.${propertyKey} -> ${config.model.name}`, `Registered ${config.type} relationship: ${target.constructor.name}.${propertyKey} -> ${modelName}`,
); );
} }

View File

@ -125,7 +125,7 @@ export class QueryExecutor<T extends BaseModel> {
this.model.modelName, this.model.modelName,
); );
return await this.queryDatabase(userDB, this.model.dbType); return await this.queryDatabase(userDB, this.model.storeType);
} catch (error) { } catch (error) {
console.warn(`Failed to query user ${userId} database:`, error); console.warn(`Failed to query user ${userId} database:`, error);
return []; return [];
@ -187,7 +187,7 @@ export class QueryExecutor<T extends BaseModel> {
return await this.executeShardedQuery(); return await this.executeShardedQuery();
} else { } else {
const db = await this.framework.databaseManager.getGlobalDatabase(this.model.modelName); const db = await this.framework.databaseManager.getGlobalDatabase(this.model.modelName);
return await this.queryDatabase(db, this.model.dbType); return await this.queryDatabase(db, this.model.storeType);
} }
} }
@ -206,7 +206,7 @@ export class QueryExecutor<T extends BaseModel> {
this.model.modelName, this.model.modelName,
shardKeyCondition.value, shardKeyCondition.value,
); );
return await this.queryDatabase(shard.database, this.model.dbType); return await this.queryDatabase(shard.database, this.model.storeType);
} else if (shardKeyCondition && shardKeyCondition.operator === 'in') { } else if (shardKeyCondition && shardKeyCondition.operator === 'in') {
// Multiple specific shards // Multiple specific shards
const results: T[] = []; const results: T[] = [];
@ -214,7 +214,7 @@ export class QueryExecutor<T extends BaseModel> {
const shardQueries = shardKeys.map(async (key: string) => { const shardQueries = shardKeys.map(async (key: string) => {
const shard = this.framework.shardManager.getShardForKey(this.model.modelName, key); const shard = this.framework.shardManager.getShardForKey(this.model.modelName, key);
return await this.queryDatabase(shard.database, this.model.dbType); return await this.queryDatabase(shard.database, this.model.storeType);
}); });
const shardResults = await Promise.all(shardQueries); const shardResults = await Promise.all(shardQueries);
@ -229,7 +229,7 @@ export class QueryExecutor<T extends BaseModel> {
const allShards = this.framework.shardManager.getAllShards(this.model.modelName); const allShards = this.framework.shardManager.getAllShards(this.model.modelName);
const promises = allShards.map((shard: any) => const promises = allShards.map((shard: any) =>
this.queryDatabase(shard.database, this.model.dbType), this.queryDatabase(shard.database, this.model.storeType),
); );
const shardResults = await Promise.all(promises); const shardResults = await Promise.all(promises);
@ -295,7 +295,7 @@ export class QueryExecutor<T extends BaseModel> {
// Fetch specific documents by ID // Fetch specific documents by ID
for (const entry of entries) { for (const entry of entries) {
try { try {
const doc = await this.getDocumentById(userDB, this.model.dbType, entry.id); const doc = await this.getDocumentById(userDB, this.model.storeType, entry.id);
if (doc) { if (doc) {
const ModelClass = this.model as any; // Type assertion for abstract class const ModelClass = this.model as any; // Type assertion for abstract class
userResults.push(new ModelClass(doc) as T); userResults.push(new ModelClass(doc) as T);
@ -612,7 +612,12 @@ export class QueryExecutor<T extends BaseModel> {
private getFrameworkInstance(): any { private getFrameworkInstance(): any {
const framework = (globalThis as any).__debrosFramework; const framework = (globalThis as any).__debrosFramework;
if (!framework) { if (!framework) {
throw new Error('Framework not initialized. Call framework.initialize() first.'); // Try to get mock framework from BaseModel for testing
const mockFramework = (this.model as any).getMockFramework?.();
if (!mockFramework) {
throw new Error('Framework not initialized. Call framework.initialize() first.');
}
return mockFramework;
} }
return framework; return framework;
} }

View File

@ -22,7 +22,8 @@ export interface FieldConfig {
export interface RelationshipConfig { export interface RelationshipConfig {
type: 'belongsTo' | 'hasMany' | 'hasOne' | 'manyToMany'; type: 'belongsTo' | 'hasMany' | 'hasOne' | 'manyToMany';
model: typeof BaseModel; model?: typeof BaseModel;
modelFactory?: () => typeof BaseModel;
foreignKey: string; foreignKey: string;
localKey?: string; localKey?: string;
otherKey?: string; otherKey?: string;

View File

@ -228,6 +228,10 @@ export class MockOrbitDBService {
return await this.orbitdb.open(name, { type }); return await this.orbitdb.open(name, { type });
} }
async openDatabase(name: string, type: string) {
return await this.openDB(name, type);
}
getOrbitDB() { getOrbitDB() {
return this.orbitdb; return this.orbitdb;
} }

View File

@ -10,25 +10,33 @@ import { createMockServices } from '../../mocks/services';
}) })
class TestUser extends BaseModel { class TestUser extends BaseModel {
@Field({ type: 'string', required: true, unique: true }) @Field({ type: 'string', required: true, unique: true })
username: string; declare username: string;
@Field({ type: 'string', required: true, unique: true }) @Field({
email: string; type: 'string',
required: true,
unique: true,
validate: (value: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
}
})
declare email: string;
@Field({ type: 'number', required: false, default: 0 }) @Field({ type: 'number', required: false, default: 0 })
score: number; declare score: number;
@Field({ type: 'boolean', required: false, default: true }) @Field({ type: 'boolean', required: false, default: true })
isActive: boolean; declare isActive: boolean;
@Field({ type: 'array', required: false, default: [] }) @Field({ type: 'array', required: false, default: [] })
tags: string[]; declare tags: string[];
@Field({ type: 'number', required: false }) @Field({ type: 'number', required: false })
createdAt: number; declare createdAt: number;
@Field({ type: 'number', required: false }) @Field({ type: 'number', required: false })
updatedAt: number; declare updatedAt: number;
// Hook counters for testing // Hook counters for testing
static beforeCreateCount = 0; static beforeCreateCount = 0;
@ -82,17 +90,17 @@ class TestPost extends BaseModel {
return true; return true;
} }
}) })
title: string; declare title: string;
@Field({ @Field({
type: 'string', type: 'string',
required: true, required: true,
validate: (value: string) => value.length <= 1000 validate: (value: string) => value.length <= 1000
}) })
content: string; declare content: string;
@Field({ type: 'string', required: true }) @Field({ type: 'string', required: true })
userId: string; declare userId: string;
@Field({ @Field({
type: 'array', type: 'array',
@ -100,7 +108,7 @@ class TestPost extends BaseModel {
default: [], default: [],
transform: (tags: string[]) => tags.map(tag => tag.toLowerCase()) transform: (tags: string[]) => tags.map(tag => tag.toLowerCase())
}) })
tags: string[]; declare tags: string[];
} }
describe('BaseModel', () => { describe('BaseModel', () => {
@ -421,7 +429,7 @@ describe('BaseModel', () => {
it('should handle validation errors gracefully', async () => { it('should handle validation errors gracefully', async () => {
try { try {
await TestPost.create({ await TestPost.create({
title: '', // Empty title should fail validation // Missing required title
content: 'Test content', content: 'Test content',
userId: 'user123' userId: 'user123'
}); });
@ -436,9 +444,10 @@ describe('BaseModel', () => {
// For now, we'll test with a simple validation error // For now, we'll test with a simple validation error
const user = new TestUser(); const user = new TestUser();
user.username = 'test'; user.username = 'test';
user.email = 'invalid-email'; // Invalid email format
await expect(user.save()).rejects.toThrow(); expect(() => {
user.email = 'invalid-email'; // Invalid email format
}).toThrow();
}); });
}); });