Skip to main content

Overview

The Document system is the foundation of data management in Loopar. Documents represent data models with automatic database mapping, validation, lifecycle hooks, and field management. Every data entity in Loopar extends from the Document base classes.

Document Hierarchy

Loopar documents follow a three-tier inheritance structure:
  • CoreDocument: Core field management, validation, and lifecycle methods
  • BaseDocument: List views, filtering, and query building
  • Custom Document: Your business logic and custom methods

CoreDocument

The CoreDocument class provides the fundamental document functionality:
packages/loopar/core/document/core-document.js
export default class CoreDocument {
  #fields = {};
  #protectedPassword = "********";

  constructor(props) {
    Object.assign(this, props);
  }

  async __init__() {
    await this.#makeFields(JSON.parse(this.__ENTITY__.doc_structure));
    await this.setApp();
    await this.onLoad();
  }

  get fields() {
    return this.#fields;
  }

  async onLoad() {
    // Override in subclasses
  }
}

Key Features

Dynamic Fields

Fields are automatically created from entity metadata

Validation

Built-in validation for all field types

Lifecycle Hooks

onLoad, validate, save, delete hooks

Child Tables

Support for parent-child relationships

BaseDocument

The BaseDocument extends CoreDocument with querying and list functionality:
packages/loopar/core/document/base-document.js
export default class BaseDocument extends CoreDocument {
  async getList({ fields = null, filters = {}, q = null, rowsOnly = false } = {}) {
    if (this.__ENTITY__.is_single) {
      return loopar.throw({
        code: 404,
        message: "This document is single, you can't get list"
      });
    }

    const pagination = {
      page: loopar.session.get(this.__ENTITY__.name + "_page") || 1,
      pageSize: 10,
      totalPages: 4,
      totalRecords: 1,
      sortBy: "id",
      sortOrder: "asc",
      __ENTITY__: this.__ENTITY__.name
    };

    const listFields = fields || this.getFieldListNames();
    const condition = combineSequelizeConditions(this.buildCondition(q), filters);
    
    pagination.totalRecords = await this.records(condition);
    pagination.totalPages = Math.ceil(pagination.totalRecords / pagination.pageSize);
    
    const rows = await loopar.db.getList(
      this.__ENTITY__.name, 
      [...listFields, "id"], 
      condition
    );

    return {
      labels: this.getFieldListLabels(),
      fields: listFields,
      rows: rows,
      pagination: pagination,
      q
    };
  }
}

Creating Documents

There are two ways to work with documents:

Creating New Documents

// Create a new document instance
const user = await loopar.newDocument('User', {
  name: 'john_doe',
  email: '[email protected]',
  full_name: 'John Doe'
});

// Save to database
await user.save();
console.log('User created:', user.name);

Using the Document API

packages/loopar/core/loopar/document.js
export class Document extends Console {
  async getDocument(document, name, data = null, { ifNotFound = 'throw', parse = false } = {}) {
    return await documentManage.getDocument(document, name, data, { ifNotFound, parse });
  }

  async newDocument(document, data = {}) {
    return await documentManage.newDocument(document, data);
  }

  async deleteDocument(document, name, { sofDelete = true, force = false } = {}) {
    const Doc = await this.getDocument(document, name);
    await Doc.delete({ sofDelete, force, updateHistory });
  }

  async getList(document, { data = {}, fields = null, filters = {}, q = null } = {}) {
    const doc = await this.newDocument(document, data);
    return await doc.getList({ fields, filters, q });
  }
}

Field Management

Fields are dynamically created based on the entity’s doc_structure:
async #makeField({ field, fieldName = field.data.name, value = null } = {}) {
  if (!this.#fields[fieldName]) {
    if (field.element === FORM_TABLE) {
      const val = loopar.utils.isJSON(value) ? JSON.parse(value) : value;
      this.#fields[fieldName] = new DynamicField(
        field,
        (Array.isArray(val) && val.length > 0) ? value : this.__DATA__[fieldName]
      );
    } else {
      this.#fields[fieldName] = new DynamicField(field, value || this.__DATA__[fieldName]);
    }

    // Create getter
    Object.defineProperty(this, `get${nameToGet(fieldName)}`, {
      get: () => {
        return this.#fields[fieldName];
      }
    });

    // Create property accessor
    Object.defineProperty(this, fieldName, {
      get: () => {
        return this.#fields[fieldName].value;
      },
      set: (val) => {
        this.#fields[fieldName].value = val;
      },
      enumerable: true
    });
  }
}
Fields are created as JavaScript properties with getters and setters, allowing natural property access like user.email = '[email protected]'.

Lifecycle Hooks

Documents support various lifecycle hooks:

Available Hooks

Called after document initialization. Use for setup logic:
async onLoad() {
  // Custom initialization logic
  this.computed_field = this.field1 + this.field2;
}
Called before saving. Add custom validation:
async validate() {
  const errors = Object.values(this.fields)
    .filter(field => field.name !== ID)
    .map(e => e.validate())
    .filter(e => !e.valid)
    .map(e => e.message);

  const selectTypes = await this.validateLinkDocuments();
  errors.push(...selectTypes);

  errors.length > 0 && loopar.throw(errors.join('<br/>'));
}
Saves document to database with validation:
async save() {
  this.setUniqueName();

  if (validate) await this.validate();

  if (this.__IS_NEW__ || this.__ENTITY__.is_single) {
    await loopar.db.insertRow(
      this.__ENTITY__.name, 
      this.stringifyValues, 
      this.__ENTITY__.is_single
    );
    this.__DOCUMENT_NAME__ = this.name;
  } else {
    await loopar.db.updateRow(
      this.__ENTITY__.name,
      this.valuesToSetDataBase,
      this.__DOCUMENT_NAME__,
      this.__ENTITY__.is_single
    );
  }

  await this.updateHistory();
}
Deletes document with connection checking:
async delete() {
  const { sofDelete, force, updateHistory } = arguments[0] || {};
  const connections = await this.getConnectedDocuments();

  if (connections.length > 0 && !force) {
    loopar.throw({
      ...{ VALIDATION_ERROR },
      message: `Cannot delete ${this.__ENTITY__.name}.${this.name} - it has connections`
    });
    return;
  }

  await loopar.db.beginTransaction();
  await this.deleteChildRecords(true);
  await loopar.db.deleteRow(this.__ENTITY__.name, this.__DOCUMENT_NAME__, sofDelete);
  updateHistory && await this.updateHistory("Deleted");
  await loopar.db.endTransaction();
}

Validation

Documents include automatic field validation based on field types:
validatorRules() {
  var type = (this.element === INPUT ? this.data.format || this.element : this.element) || 'text';
  type = type.charAt(0).toUpperCase() + type.slice(1);

  if (this['is' + type]) {
    return this['is' + type]();
  }

  return { valid: true };
}

isEmail() {
  var regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
  return {
    valid: regex.test(this.value),
    message: 'Invalid email address'
  }
}

validatorRequired() {
  const required = [true, 'true', 1, '1'].includes(this.data.required);
  return {
    valid: !required || !(typeof this.value == "undefined" || this.value.toString().length === 0),
    message: `${this.__label()} is required`
  }
}

Child Documents

Support for parent-child table relationships:
async deleteChildRecords(force = false) {
  const ID = await this.__ID__();
  const childValuesReq = this.childValuesReq;

  if (Object.keys(childValuesReq).length === 0) return;
  
  for (const [key, value] of Object.entries(childValuesReq)) {
    if (key == this.name) continue;
    if (!await loopar.db.hasTable(key)) continue;
    
    const values = loopar.utils.isJSON(value) ? JSON.parse(value) : Array.isArray(value) ? value : null;

    if (values || force) {
      await loopar.db.sequelize.query(
        `DELETE FROM ${loopar.db.tableName(key)} WHERE parent_id = ?`,
        {
          replacements: [ID],
          type: Sequelize.QueryTypes.DELETE,
          transaction: loopar.db.transaction
        }
      );
    }
  }
}

get childValuesReq() {
  return Object.values(this.#fields)
    .filter(field => field.name !== ID && field.element === FORM_TABLE)
    .reduce((acc, cur) => ({ ...acc, [cur.options]: cur.value }), {});
}

Querying Documents

List with Filters

// Get filtered list
const userList = await loopar.getList('User', {
  filters: { disabled: 0 },
  q: { name: 'john' },  // Search query
  fields: ['name', 'email', 'full_name']
});

console.log(userList.rows);
console.log(userList.pagination);

Building Conditions

buildCondition(q = null) {
  if (q === null) return {};

  Object.entries(q).forEach(([field, value]) => {
    if (!this.fields[field] || value === '') delete q[field];
  });

  const conditions = [];

  Object.entries(q).forEach(([key, value]) => {
    const field = this.fields[key];
    
    if (!field) return;

    if (value && value.toString().length > 0) {
      const isSelectType = [SELECT, SWITCH, CHECKBOX].includes(field.element);
      
      if ([SWITCH, CHECKBOX].includes(field.element)) {
        if ([1, '1'].includes(value)) {
          conditions.push({ [key]: 1 });
        }
      } else if (isSelectType) {
        conditions.push({ [key]: value });
      } else {
        conditions.push({ [key]: { [Op.like]: `%${value}%` } });
      }
    }
  });

  if (conditions.length === 0) return {};
  if (conditions.length === 1) return conditions[0];

  return { [Op.and]: conditions };
}

Document History

Automatic tracking of document changes:
async updateHistory(action) {
  if (loopar.installing) return;
  if (this.__ENTITY__.name !== "Document History") {
    const id = await this.__ID__();
    const hist = await loopar.newDocument("Document History");

    hist.name = loopar.utils.randomString(15);
    hist.document_id = id;
    hist.document_name = this.__DOCUMENT_NAME__;
    hist.document = this.__ENTITY__.name;
    hist.action = action || (this.__IS_NEW__ ? "Created" : "Updated");
    hist.date = dayjs(new Date());
    hist.user = loopar.currentUser?.name;
    
    await hist.save({ validate: false });
  }
}

Custom Documents

Create custom document classes for specific entities:
import BaseDocument from '@loopar/core/document/base-document.js';

export default class User extends BaseDocument {
  constructor(props) {
    super(props);
  }

  async onLoad() {
    // Initialize user-specific data
    this.display_name = this.full_name || this.name;
  }

  async validate() {
    await super.validate();
    
    // Custom validation
    if (this.email && !this.email.includes('@')) {
      loopar.throw('Invalid email format');
    }
  }

  async beforeSave() {
    // Hash password if changed
    if (this.password && this.password !== this.protectedPassword) {
      this.password = loopar.hash(this.password);
    }
  }

  async getPermissions() {
    // Custom method
    return await loopar.db.getAll('User Permission', ['*'], {
      user: this.name
    });
  }
}

Best Practices

Important Considerations
  • Always call await user.save() after modifying fields
  • Use validate: false only when absolutely necessary
  • Check for connections before deleting documents
  • Use transactions for multi-document operations
Performance Tips
  • Use fields parameter to limit returned columns
  • Implement pagination for large datasets
  • Cache frequently accessed single documents
  • Use rowsOnly: true when you don’t need metadata

Document Parsing Utility

The parseDocument function processes document data and renders markdown fields based on the document structure definition.
import { parseDocument } from 'loopar';

// Parse a document and render its markdown fields
const entity = 'Article';
const rawDoc = {
  id: 1,
  title: 'My Article',
  content: '## Hello\n\nThis is **markdown** content.',
  description: 'A regular field'
};

const parsedDoc = parseDocument(entity, rawDoc);
// parsedDoc.content is now rendered HTML
// parsedDoc.description remains unchanged
The function:
  • Reads the document structure from the entity configuration
  • Identifies fields with MARKDOWN_INPUT element type
  • Renders markdown content to HTML using the markdown renderer
  • Returns the processed document
This is useful when you need to display document data in the client with pre-rendered markdown fields.

Next Steps

Controllers

Learn how controllers interact with documents

Components

Build forms for document data entry

Build docs developers (and LLMs) love