UNPKG

@statezero/core

Version:

The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate

361 lines (360 loc) 14.4 kB
import { Manager } from "./manager.js"; import { ValidationError } from "./errors.js"; import { modelStoreRegistry } from "../../syncEngine/registries/modelStoreRegistry.js"; import { isNil } from "lodash-es"; import { QueryExecutor } from "./queryExecutor"; import { wrapReactiveModel } from "../../reactiveAdaptor.js"; import { DateParsingHelpers } from "./dates.js"; import { FileObject } from './files.js'; import { configInstance } from "../../config.js"; import { ModelSerializer } from "./serializers.js"; import { parseStateZeroError, MultipleObjectsReturned, DoesNotExist, } from "./errors.js"; import axios from "axios"; /** * A constructor for a Model. * * @typedef {Function} ModelConstructor * @param {any} data - Data to initialize the model. * @returns {Model} * * @property {Manager} objects - The model's manager. * @property {string} configKey - The configuration key. * @property {string} modelName - The model name. * @property {string} primaryKeyField - The primary key field (default 'id'). */ /** * Base Model class with integrated API implementation. * * @abstract */ export class Model { constructor(data = {}) { this.serializer = new ModelSerializer(this.constructor); const serializedData = this.serializer.toInternal(data); this._data = serializedData; this._pk = serializedData[this.constructor.primaryKeyField] || undefined; this.__version = 0; return wrapReactiveModel(this); } touch() { this.__version++; } /** * Returns the primary key of the model instance. * * @returns {number|undefined} The primary key. */ get pk() { return this._pk; } /** * Sets the primary key of the model instance. * * @param {number|undefined} value - The new primary key value. */ set pk(value) { this._pk = value; this.touch(); } /** * Instantiate from pk using queryset scoped singletons */ static fromPk(pk, querySet) { let qsId = querySet ? querySet.__uuid : ""; let key = `${qsId}__${this.configKey}__${this.modelName}__${pk}`; if (!this.instanceCache.has(key)) { const instance = new this(); instance.pk = pk; this.instanceCache.set(key, instance); } return this.instanceCache.get(key); } /** * Gets a field value from the internal data store * * @param {string} field - The field name * @returns {any} The field value */ getField(field) { // Access the reactive __version property to establish dependency for vue integration const trackVersion = this.__version; const ModelClass = this.constructor; if (ModelClass.primaryKeyField === field) return this._pk; // check local overrides let value = this._data[field]; // if its not been overridden, get it from the store if (value === undefined && !isNil(this._pk)) { let storedValue = modelStoreRegistry.getEntity(ModelClass, this._pk); if (storedValue) value = storedValue[field]; // if stops null -> undefined } // Use serializer to convert internal format to live format return this.serializer.toLiveField(field, value); } /** * Sets a field value in the internal data store * * @param {string} field - The field name * @param {any} value - The field value to set */ setField(field, value) { const ModelClass = this.constructor; // Use serializer to convert live format to internal format const internalValue = this.serializer.toInternalField(field, value); if (ModelClass.primaryKeyField === field) { this._pk = internalValue; } else { this._data[field] = internalValue; } } /** * Validates that the provided data object only contains keys * defined in the model's allowed fields. Supports nested fields * using double underscore notation (e.g., author__name). * * @param {Object} data - The object to validate. * @throws {ValidationError} If an unknown key is found. */ static validateFields(data) { if (isNil(data)) return; const allowedFields = this.fields; for (const key of Object.keys(data)) { if (key === "repr" || key === "type") continue; // Handle nested fields by splitting on double underscore // and taking just the base field name const baseField = key.split("__")[0]; if (!allowedFields.includes(baseField)) { let errorMsg = `Invalid field: ${baseField}. Allowed fields are: ${allowedFields.join(", ")}`; console.error(errorMsg); throw new ValidationError(errorMsg); } } } /** * Serializes the model instance. * * By default, it returns all enumerable own properties. * Subclasses should override this to return specific keys. * * @param {boolean} includeRepr - Whether to include the repr field (for caching). Default: false. * @returns {Object} The serialized model data. */ serialize(includeRepr = false) { const ModelClass = this.constructor; const data = {}; // Collect all field values (already in internal format) for (const field of ModelClass.fields) { if (field === ModelClass.primaryKeyField) { data[field] = this._pk; } else { let value = this._data[field]; // Get from store if not in local data if (value === undefined && !isNil(this._pk)) { const storedData = modelStoreRegistry.getEntity(ModelClass, this._pk); if (storedData) { value = storedData[field]; } } data[field] = value; } } // Include repr field if requested (for caching purposes) if (includeRepr && !isNil(this._pk)) { const storedData = modelStoreRegistry.getEntity(ModelClass, this._pk); if (storedData && storedData.repr) { data.repr = storedData.repr; } } // Data is already in internal format, so return as-is for API transmission return data; } /** * Saves the model instance by either creating a new record or updating an existing one. * * @returns {Promise<Model>} A promise that resolves to the updated model instance. */ async save() { const ModelClass = this.constructor; const pkField = ModelClass.primaryKeyField; const querySet = !this.pk ? ModelClass.objects.newQuerySet() : ModelClass.objects.filter({ [pkField]: this.pk }); const data = this.serialize(); let instance; if (!this.pk) { // Create new instance instance = await QueryExecutor.execute(querySet, "create", { data }); } else { // Update existing instance instance = await QueryExecutor.execute(querySet, "update_instance", { data, }); } this._pk = instance.pk; this._data = {}; return this; } /** * Deletes the instance from the database. * * Returns a tuple with the number of objects deleted and an object mapping * model names to the number of objects deleted, matching Django's behavior. * * @returns {Promise<[number, Object]>} A promise that resolves to the deletion result. * @throws {Error} If the instance has not been saved (no primary key). */ async delete() { if (!this.pk) { throw new Error("Cannot delete unsaved instance"); } const ModelClass = this.constructor; const pkField = ModelClass.primaryKeyField; const querySet = ModelClass.objects.filter({ [pkField]: this.pk }); // Pass the instance data with primary key as the args const args = { [pkField]: this.pk }; const result = await QueryExecutor.execute(querySet, "delete_instance", args); // result -> [deletedCount, { [modelName]: deletedCount }]; return result; } /** * Refreshes the model instance with data from the database. * * @returns {Promise<void>} A promise that resolves when the instance has been refreshed. * @throws {Error} If the instance has not been saved (no primary key). */ async refreshFromDb() { if (!this.pk) { throw new Error("Cannot refresh unsaved instance"); } const ModelClass = this.constructor; const fresh = await ModelClass.objects.get({ [ModelClass.primaryKeyField]: this.pk, }); // clear the current data and fresh data will flow this._data = {}; } /** * Validates the model instance using the same serialize behavior as save() * @param {string} validateType - 'create' or 'update' (defaults to auto-detect) * @param {boolean} partial - Whether to allow partial validation * @returns {Promise<boolean>} Promise that resolves to true if valid, throws error if invalid */ async validate(validateType = null, partial = false) { const ModelClass = this.constructor; if (!validateType) { validateType = this.pk ? "update" : "create"; } // Validate the validateType parameter if (!["update", "create"].includes(validateType)) { throw new Error(`Validation type must be 'update' or 'create', not '${validateType}'`); } // Use the same serialize logic as save() const data = this.serialize(); // Delegate to static method return ModelClass.validate(data, validateType, partial); } /** * Static method to validate data without creating an instance * @param {Object} data - Data to validate * @param {string} validateType - 'create' or 'update' * @param {boolean} partial - Whether to allow partial validation * @returns {Promise<boolean>} Promise that resolves to true if valid, throws error if invalid */ static async validate(data, validateType = "create", partial = false) { const ModelClass = this; // Validate the validateType parameter if (!["update", "create"].includes(validateType)) { throw new Error(`Validation type must be 'update' or 'create', not '${validateType}'`); } // Get backend config and check if it exists const config = configInstance.getConfig(); const backend = config.backendConfigs[ModelClass.configKey]; if (!backend) { throw new Error(`No backend configuration found for key: ${ModelClass.configKey}`); } // Build URL for validate endpoint const baseUrl = backend.API_URL.replace(/\/+$/, ""); const url = `${baseUrl}/${ModelClass.modelName}/validate/`; // Prepare headers const headers = { "Content-Type": "application/json", ...(backend.getAuthHeaders ? backend.getAuthHeaders() : {}), }; // Make direct API call to validate endpoint try { const response = await axios.post(url, { data: data, validate_type: validateType, partial: partial, }, { headers }); // Backend returns {"valid": true} on success return response.data.valid === true; } catch (error) { if (error.response && error.response.data) { const parsedError = parseStateZeroError(error.response.data); if (Error.captureStackTrace) { Error.captureStackTrace(parsedError, ModelClass.validate); } throw parsedError; } throw new Error(`Validation failed: ${error.message}`); } } /** * Get field permissions for the current user (cached on the class) * @param {boolean} refresh - Force refresh the cached permissions * @returns {Promise<{visible_fields: string[], creatable_fields: string[], editable_fields: string[]}>} */ static async getFieldPermissions(refresh = false) { const ModelClass = this; // Return cached permissions if available and not forcing refresh if (!refresh && ModelClass._fieldPermissionsCache) { return ModelClass._fieldPermissionsCache; } // Get backend config and check if it exists const config = configInstance.getConfig(); const backend = config.backendConfigs[ModelClass.configKey]; if (!backend) { throw new Error(`No backend configuration found for key: ${ModelClass.configKey}`); } // Build URL for field permissions endpoint const baseUrl = backend.API_URL.replace(/\/+$/, ""); const url = `${baseUrl}/${ModelClass.modelName}/field-permissions/`; // Prepare headers const headers = { "Content-Type": "application/json", ...(backend.getAuthHeaders ? backend.getAuthHeaders() : {}), }; // Make direct API call to field permissions endpoint try { const response = await axios.get(url, { headers }); // Cache the permissions on the class ModelClass._fieldPermissionsCache = response.data; // Backend returns {visible_fields: [], creatable_fields: [], editable_fields: []} return response.data; } catch (error) { if (error.response && error.response.data) { const parsedError = parseStateZeroError(error.response.data); if (Error.captureStackTrace) { Error.captureStackTrace(parsedError, ModelClass.getFieldPermissions); } throw parsedError; } throw new Error(`Failed to get field permissions: ${error.message}`); } } } /** * Creates a new Model instance. * * @param {any} [data={}] - The data for initialization. */ Model.instanceCache = new Map();