@warriorteam/dynamic-table
Version:
NestJS SDK for Dynamic Table System with PostgreSQL + JSONB - Build Airtable/Notion-like applications easily
1,567 lines (1,545 loc) • 45.8 kB
JavaScript
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
for (var i = decorators.length - 1, decorator; i >= 0; i--)
if (decorator = decorators[i])
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
if (kind && result) __defProp(target, key, result);
return result;
};
var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
// src/dynamic-table.module.ts
import { Module, Global } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
// src/entities/workspace.entity.ts
import {
Entity as Entity6,
PrimaryGeneratedColumn as PrimaryGeneratedColumn6,
Column as Column6,
CreateDateColumn as CreateDateColumn5,
OneToMany as OneToMany3
} from "typeorm";
// src/entities/table.entity.ts
import {
Entity as Entity5,
PrimaryGeneratedColumn as PrimaryGeneratedColumn5,
Column as Column5,
CreateDateColumn as CreateDateColumn4,
ManyToOne as ManyToOne5,
OneToMany as OneToMany2,
JoinColumn as JoinColumn5,
Unique as Unique2
} from "typeorm";
// src/entities/field.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Unique
} from "typeorm";
var SysField = class {
};
__decorateClass([
PrimaryGeneratedColumn("uuid")
], SysField.prototype, "id", 2);
__decorateClass([
Column({ name: "table_id", type: "uuid" })
], SysField.prototype, "tableId", 2);
__decorateClass([
Column({ type: "varchar", length: 255 })
], SysField.prototype, "name", 2);
__decorateClass([
Column({ name: "key_name", type: "varchar", length: 255 })
], SysField.prototype, "keyName", 2);
__decorateClass([
Column({ type: "varchar", length: 50 })
], SysField.prototype, "type", 2);
__decorateClass([
Column({ type: "jsonb", default: {} })
], SysField.prototype, "config", 2);
__decorateClass([
Column({ name: "is_primary", type: "boolean", default: false })
], SysField.prototype, "isPrimary", 2);
__decorateClass([
Column({ name: "is_required", type: "boolean", default: false })
], SysField.prototype, "isRequired", 2);
__decorateClass([
Column({ name: "order_index", type: "integer", default: 0 })
], SysField.prototype, "orderIndex", 2);
__decorateClass([
CreateDateColumn({ name: "created_at", type: "timestamp with time zone" })
], SysField.prototype, "createdAt", 2);
__decorateClass([
ManyToOne(() => SysTable, (table) => table.fields, { onDelete: "CASCADE" }),
JoinColumn({ name: "table_id" })
], SysField.prototype, "table", 2);
SysField = __decorateClass([
Entity("sys_fields"),
Unique(["tableId", "keyName"])
], SysField);
// src/entities/view.entity.ts
import {
Entity as Entity2,
PrimaryGeneratedColumn as PrimaryGeneratedColumn2,
Column as Column2,
ManyToOne as ManyToOne2,
JoinColumn as JoinColumn2
} from "typeorm";
var SysView = class {
};
__decorateClass([
PrimaryGeneratedColumn2("uuid")
], SysView.prototype, "id", 2);
__decorateClass([
Column2({ name: "table_id", type: "uuid" })
], SysView.prototype, "tableId", 2);
__decorateClass([
Column2({ type: "varchar", length: 255 })
], SysView.prototype, "name", 2);
__decorateClass([
Column2({ type: "varchar", length: 50, default: "grid" })
], SysView.prototype, "type", 2);
__decorateClass([
Column2({ type: "jsonb", default: {} })
], SysView.prototype, "config", 2);
__decorateClass([
Column2({ name: "is_default", type: "boolean", default: false })
], SysView.prototype, "isDefault", 2);
__decorateClass([
ManyToOne2(() => SysTable, (table) => table.views, { onDelete: "CASCADE" }),
JoinColumn2({ name: "table_id" })
], SysView.prototype, "table", 2);
SysView = __decorateClass([
Entity2("sys_views")
], SysView);
// src/entities/record.entity.ts
import {
Entity as Entity4,
PrimaryGeneratedColumn as PrimaryGeneratedColumn4,
Column as Column4,
CreateDateColumn as CreateDateColumn3,
UpdateDateColumn,
ManyToOne as ManyToOne4,
OneToMany,
JoinColumn as JoinColumn4,
Index
} from "typeorm";
// src/entities/record-history.entity.ts
import {
Entity as Entity3,
PrimaryGeneratedColumn as PrimaryGeneratedColumn3,
Column as Column3,
CreateDateColumn as CreateDateColumn2,
ManyToOne as ManyToOne3,
JoinColumn as JoinColumn3
} from "typeorm";
var UsrRecordHistory = class {
};
__decorateClass([
PrimaryGeneratedColumn3("uuid")
], UsrRecordHistory.prototype, "id", 2);
__decorateClass([
Column3({ name: "record_id", type: "uuid" })
], UsrRecordHistory.prototype, "recordId", 2);
__decorateClass([
Column3({ name: "changed_by", type: "uuid", nullable: true })
], UsrRecordHistory.prototype, "changedBy", 2);
__decorateClass([
CreateDateColumn2({ name: "changed_at", type: "timestamp with time zone" })
], UsrRecordHistory.prototype, "changedAt", 2);
__decorateClass([
Column3({ type: "jsonb" })
], UsrRecordHistory.prototype, "changes", 2);
__decorateClass([
ManyToOne3(() => UsrRecord, (record) => record.history, { onDelete: "CASCADE" }),
JoinColumn3({ name: "record_id" })
], UsrRecordHistory.prototype, "record", 2);
UsrRecordHistory = __decorateClass([
Entity3("usr_record_history")
], UsrRecordHistory);
// src/entities/record.entity.ts
var UsrRecord = class {
};
__decorateClass([
PrimaryGeneratedColumn4("uuid")
], UsrRecord.prototype, "id", 2);
__decorateClass([
Column4({ name: "table_id", type: "uuid" }),
Index()
], UsrRecord.prototype, "tableId", 2);
__decorateClass([
Column4({ type: "jsonb", default: {} }),
Index("idx_usr_records_data", { synchronize: false })
], UsrRecord.prototype, "data", 2);
__decorateClass([
Column4({ name: "created_by", type: "uuid", nullable: true })
], UsrRecord.prototype, "createdBy", 2);
__decorateClass([
Column4({ name: "updated_by", type: "uuid", nullable: true })
], UsrRecord.prototype, "updatedBy", 2);
__decorateClass([
CreateDateColumn3({ name: "created_at", type: "timestamp with time zone" })
], UsrRecord.prototype, "createdAt", 2);
__decorateClass([
UpdateDateColumn({ name: "updated_at", type: "timestamp with time zone" })
], UsrRecord.prototype, "updatedAt", 2);
__decorateClass([
ManyToOne4(() => SysTable, (table) => table.records, { onDelete: "CASCADE" }),
JoinColumn4({ name: "table_id" })
], UsrRecord.prototype, "table", 2);
__decorateClass([
OneToMany(() => UsrRecordHistory, (history) => history.record)
], UsrRecord.prototype, "history", 2);
UsrRecord = __decorateClass([
Entity4("usr_records")
], UsrRecord);
// src/entities/table.entity.ts
var SysTable = class {
};
__decorateClass([
PrimaryGeneratedColumn5("uuid")
], SysTable.prototype, "id", 2);
__decorateClass([
Column5({ name: "workspace_id", type: "uuid" })
], SysTable.prototype, "workspaceId", 2);
__decorateClass([
Column5({ type: "varchar", length: 255 })
], SysTable.prototype, "name", 2);
__decorateClass([
Column5({ type: "varchar", length: 255 })
], SysTable.prototype, "slug", 2);
__decorateClass([
Column5({ type: "text", nullable: true })
], SysTable.prototype, "description", 2);
__decorateClass([
Column5({ type: "jsonb", default: {} })
], SysTable.prototype, "settings", 2);
__decorateClass([
CreateDateColumn4({ name: "created_at", type: "timestamp with time zone" })
], SysTable.prototype, "createdAt", 2);
__decorateClass([
ManyToOne5(() => SysWorkspace, (workspace) => workspace.tables, { onDelete: "CASCADE" }),
JoinColumn5({ name: "workspace_id" })
], SysTable.prototype, "workspace", 2);
__decorateClass([
OneToMany2(() => SysField, (field) => field.table)
], SysTable.prototype, "fields", 2);
__decorateClass([
OneToMany2(() => SysView, (view) => view.table)
], SysTable.prototype, "views", 2);
__decorateClass([
OneToMany2(() => UsrRecord, (record) => record.table)
], SysTable.prototype, "records", 2);
SysTable = __decorateClass([
Entity5("sys_tables"),
Unique2(["workspaceId", "slug"])
], SysTable);
// src/entities/workspace.entity.ts
var SysWorkspace = class {
};
__decorateClass([
PrimaryGeneratedColumn6("uuid")
], SysWorkspace.prototype, "id", 2);
__decorateClass([
Column6({ type: "varchar", length: 255 })
], SysWorkspace.prototype, "name", 2);
__decorateClass([
Column6({ type: "varchar", length: 255, unique: true })
], SysWorkspace.prototype, "slug", 2);
__decorateClass([
CreateDateColumn5({ name: "created_at", type: "timestamp with time zone" })
], SysWorkspace.prototype, "createdAt", 2);
__decorateClass([
OneToMany3(() => SysTable, (table) => table.workspace)
], SysWorkspace.prototype, "tables", 2);
SysWorkspace = __decorateClass([
Entity6("sys_workspaces")
], SysWorkspace);
// src/entities/index.ts
var DYNAMIC_TABLE_ENTITIES = [
SysWorkspace,
SysTable,
SysField,
SysView,
UsrRecord,
UsrRecordHistory
];
// src/services/workspace.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
var WorkspaceService = class {
constructor(workspaceRepo) {
this.workspaceRepo = workspaceRepo;
}
/**
* Create a new workspace
*/
async create(dto) {
const workspace = this.workspaceRepo.create(dto);
return this.workspaceRepo.save(workspace);
}
/**
* Find all workspaces
*/
async findAll() {
return this.workspaceRepo.find({
order: { createdAt: "DESC" }
});
}
/**
* Find workspace by ID
*/
async findById(id) {
return this.workspaceRepo.findOne({ where: { id } });
}
/**
* Find workspace by slug
*/
async findBySlug(slug) {
return this.workspaceRepo.findOne({ where: { slug } });
}
/**
* Update workspace
*/
async update(id, dto) {
await this.workspaceRepo.update(id, dto);
return this.findById(id);
}
/**
* Delete workspace
*/
async delete(id) {
const result = await this.workspaceRepo.delete(id);
return (result.affected ?? 0) > 0;
}
};
WorkspaceService = __decorateClass([
Injectable(),
__decorateParam(0, InjectRepository(SysWorkspace))
], WorkspaceService);
// src/services/table.service.ts
import { Injectable as Injectable2 } from "@nestjs/common";
import { InjectRepository as InjectRepository2 } from "@nestjs/typeorm";
var TableService = class {
constructor(tableRepo) {
this.tableRepo = tableRepo;
}
/**
* Create a new table
*/
async create(dto) {
const table = this.tableRepo.create(dto);
return this.tableRepo.save(table);
}
/**
* Find all tables in a workspace
*/
async findByWorkspace(workspaceId) {
return this.tableRepo.find({
where: { workspaceId },
order: { createdAt: "DESC" }
});
}
/**
* Find table by ID
*/
async findById(id) {
return this.tableRepo.findOne({
where: { id },
relations: ["fields"]
});
}
/**
* Find table by slug within a workspace
*/
async findBySlug(workspaceId, slug) {
return this.tableRepo.findOne({
where: { workspaceId, slug },
relations: ["fields"]
});
}
/**
* Find table with all relations
*/
async findByIdWithRelations(id) {
return this.tableRepo.findOne({
where: { id },
relations: ["fields", "views"]
});
}
/**
* Update table
*/
async update(id, dto) {
await this.tableRepo.update(id, dto);
return this.findById(id);
}
/**
* Delete table
*/
async delete(id) {
const result = await this.tableRepo.delete(id);
return (result.affected ?? 0) > 0;
}
};
TableService = __decorateClass([
Injectable2(),
__decorateParam(0, InjectRepository2(SysTable))
], TableService);
// src/services/field.service.ts
import { Injectable as Injectable3 } from "@nestjs/common";
import { InjectRepository as InjectRepository3 } from "@nestjs/typeorm";
// src/constants/field-types.constant.ts
var FieldType = /* @__PURE__ */ ((FieldType2) => {
FieldType2["TEXT"] = "text";
FieldType2["LONG_TEXT"] = "long_text";
FieldType2["EMAIL"] = "email";
FieldType2["PHONE"] = "phone";
FieldType2["URL"] = "url";
FieldType2["NUMBER"] = "number";
FieldType2["CURRENCY"] = "currency";
FieldType2["PERCENT"] = "percent";
FieldType2["RATING"] = "rating";
FieldType2["AUTONUMBER"] = "autonumber";
FieldType2["SELECT"] = "select";
FieldType2["MULTI_SELECT"] = "multi_select";
FieldType2["BOOLEAN"] = "boolean";
FieldType2["DATE"] = "date";
FieldType2["DATETIME"] = "datetime";
FieldType2["DURATION"] = "duration";
FieldType2["RELATION"] = "relation";
FieldType2["LOOKUP"] = "lookup";
FieldType2["ROLLUP"] = "rollup";
FieldType2["FORMULA"] = "formula";
FieldType2["CREATED_TIME"] = "created_time";
FieldType2["MODIFIED_TIME"] = "modified_time";
FieldType2["CREATED_BY"] = "created_by";
FieldType2["MODIFIED_BY"] = "modified_by";
FieldType2["ATTACHMENT"] = "attachment";
FieldType2["USER"] = "user";
return FieldType2;
})(FieldType || {});
var COMPUTED_FIELD_TYPES = [
"formula" /* FORMULA */,
"lookup" /* LOOKUP */,
"rollup" /* ROLLUP */,
"created_time" /* CREATED_TIME */,
"modified_time" /* MODIFIED_TIME */,
"created_by" /* CREATED_BY */,
"modified_by" /* MODIFIED_BY */,
"autonumber" /* AUTONUMBER */
];
var FIELD_TYPE_METADATA = {
["text" /* TEXT */]: { label: "V\u0103n b\u1EA3n", icon: "type", isComputed: false },
["long_text" /* LONG_TEXT */]: {
label: "V\u0103n b\u1EA3n d\xE0i",
icon: "align-left",
isComputed: false,
defaultConfig: { enableRichText: false }
},
["email" /* EMAIL */]: { label: "Email", icon: "mail", isComputed: false },
["phone" /* PHONE */]: {
label: "S\u1ED1 \u0111i\u1EC7n tho\u1EA1i",
icon: "phone",
isComputed: false,
defaultConfig: { defaultCountryCode: "+84" }
},
["url" /* URL */]: {
label: "\u0110\u01B0\u1EDDng d\u1EABn",
icon: "link",
isComputed: false,
defaultConfig: { urlType: "any" }
},
["number" /* NUMBER */]: {
label: "S\u1ED1",
icon: "hash",
isComputed: false,
defaultConfig: { precision: 0 }
},
["currency" /* CURRENCY */]: {
label: "Ti\u1EC1n t\u1EC7",
icon: "dollar-sign",
isComputed: false,
defaultConfig: { currencyCode: "VND", currencySymbol: "\u20AB", precision: 0 }
},
["percent" /* PERCENT */]: {
label: "Ph\u1EA7n tr\u0103m",
icon: "percent",
isComputed: false,
defaultConfig: { percentFormat: "whole", precision: 0 }
},
["rating" /* RATING */]: {
label: "\u0110\xE1nh gi\xE1",
icon: "star",
isComputed: false,
defaultConfig: { maxRating: 5, ratingIcon: "star" }
},
["autonumber" /* AUTONUMBER */]: {
label: "S\u1ED1 t\u1EF1 \u0111\u1ED9ng",
icon: "list-ordered",
isComputed: true,
defaultConfig: { startNumber: 1, digitCount: 5 }
},
["select" /* SELECT */]: {
label: "Ch\u1ECDn m\u1ED9t",
icon: "chevron-down",
isComputed: false,
defaultConfig: { options: [] }
},
["multi_select" /* MULTI_SELECT */]: {
label: "Ch\u1ECDn nhi\u1EC1u",
icon: "check-square",
isComputed: false,
defaultConfig: { options: [] }
},
["boolean" /* BOOLEAN */]: { label: "Checkbox", icon: "check", isComputed: false },
["date" /* DATE */]: {
label: "Ng\xE0y",
icon: "calendar",
isComputed: false,
defaultConfig: { dateFormat: "DD/MM/YYYY" }
},
["datetime" /* DATETIME */]: {
label: "Ng\xE0y & Gi\u1EDD",
icon: "clock",
isComputed: false,
defaultConfig: { dateFormat: "DD/MM/YYYY", timeFormat: "24h" }
},
["duration" /* DURATION */]: {
label: "Kho\u1EA3ng th\u1EDDi gian",
icon: "timer",
isComputed: false,
defaultConfig: { durationFormat: "h:mm" }
},
["relation" /* RELATION */]: {
label: "Li\xEAn k\u1EBFt b\u1EA3n ghi",
icon: "link-2",
isComputed: false,
defaultConfig: { allowMultiple: false }
},
["lookup" /* LOOKUP */]: { label: "Tra c\u1EE9u", icon: "search", isComputed: true },
["rollup" /* ROLLUP */]: {
label: "T\u1ED5ng h\u1EE3p",
icon: "sigma",
isComputed: true,
defaultConfig: { rollupFunction: "count" }
},
["formula" /* FORMULA */]: {
label: "C\xF4ng th\u1EE9c",
icon: "function-square",
isComputed: true,
defaultConfig: { outputType: "number" }
},
["created_time" /* CREATED_TIME */]: { label: "Th\u1EDDi gian t\u1EA1o", icon: "clock", isComputed: true },
["modified_time" /* MODIFIED_TIME */]: { label: "Th\u1EDDi gian s\u1EEDa", icon: "clock", isComputed: true },
["created_by" /* CREATED_BY */]: { label: "Ng\u01B0\u1EDDi t\u1EA1o", icon: "user", isComputed: true },
["modified_by" /* MODIFIED_BY */]: { label: "Ng\u01B0\u1EDDi s\u1EEDa", icon: "user", isComputed: true },
["attachment" /* ATTACHMENT */]: {
label: "File \u0111\xEDnh k\xE8m",
icon: "paperclip",
isComputed: false,
defaultConfig: { maxFiles: 10 }
},
["user" /* USER */]: {
label: "Ng\u01B0\u1EDDi d\xF9ng",
icon: "user",
isComputed: false,
defaultConfig: { allowMultipleUsers: false }
}
};
// src/services/field.service.ts
var FieldService = class {
constructor(fieldRepo) {
this.fieldRepo = fieldRepo;
}
/**
* Create a new field
*/
async create(dto) {
const defaultConfig = FIELD_TYPE_METADATA[dto.type]?.defaultConfig || {};
const field = this.fieldRepo.create({
...dto,
config: { ...defaultConfig, ...dto.config }
});
return this.fieldRepo.save(field);
}
/**
* Find all fields of a table
*/
async findByTable(tableId) {
return this.fieldRepo.find({
where: { tableId },
order: { orderIndex: "ASC" }
});
}
/**
* Find field by ID
*/
async findById(id) {
return this.fieldRepo.findOne({ where: { id } });
}
/**
* Find field by keyName within a table
*/
async findByKeyName(tableId, keyName) {
return this.fieldRepo.findOne({ where: { tableId, keyName } });
}
/**
* Update field
*/
async update(id, dto) {
const existing = await this.findById(id);
if (!existing) return null;
const updateData = { ...dto };
if (dto.config) {
updateData.config = { ...existing.config, ...dto.config };
}
await this.fieldRepo.update(id, updateData);
return this.findById(id);
}
/**
* Delete field
*/
async delete(id) {
const result = await this.fieldRepo.delete(id);
return (result.affected ?? 0) > 0;
}
/**
* Reorder fields
*/
async reorder(tableId, fieldIds) {
const updates = fieldIds.map(
(id, index) => this.fieldRepo.update(id, { orderIndex: index })
);
await Promise.all(updates);
}
/**
* Convert entity to interface
*/
toInterface(entity) {
return {
id: entity.id,
tableId: entity.tableId,
name: entity.name,
keyName: entity.keyName,
type: entity.type,
config: entity.config,
isPrimary: entity.isPrimary,
isRequired: entity.isRequired,
orderIndex: entity.orderIndex,
createdAt: entity.createdAt
};
}
};
FieldService = __decorateClass([
Injectable3(),
__decorateParam(0, InjectRepository3(SysField))
], FieldService);
// src/services/view.service.ts
import { Injectable as Injectable4 } from "@nestjs/common";
import { InjectRepository as InjectRepository4 } from "@nestjs/typeorm";
var ViewService = class {
constructor(viewRepo) {
this.viewRepo = viewRepo;
}
/**
* Create a new view
*/
async create(dto) {
if (dto.isDefault) {
await this.viewRepo.update({ tableId: dto.tableId }, { isDefault: false });
}
const view = this.viewRepo.create({
...dto,
type: dto.type || "grid",
config: dto.config || {}
});
return this.viewRepo.save(view);
}
/**
* Find all views of a table
*/
async findByTable(tableId) {
return this.viewRepo.find({
where: { tableId },
order: { isDefault: "DESC", name: "ASC" }
});
}
/**
* Find view by ID
*/
async findById(id) {
return this.viewRepo.findOne({ where: { id } });
}
/**
* Find default view of a table
*/
async findDefault(tableId) {
return this.viewRepo.findOne({
where: { tableId, isDefault: true }
});
}
/**
* Update view
*/
async update(id, dto) {
const existing = await this.findById(id);
if (!existing) return null;
if (dto.isDefault) {
await this.viewRepo.update({ tableId: existing.tableId }, { isDefault: false });
}
const updateData = { ...dto };
if (dto.config) {
updateData.config = { ...existing.config, ...dto.config };
}
await this.viewRepo.update(id, updateData);
return this.findById(id);
}
/**
* Delete view
*/
async delete(id) {
const result = await this.viewRepo.delete(id);
return (result.affected ?? 0) > 0;
}
};
ViewService = __decorateClass([
Injectable4(),
__decorateParam(0, InjectRepository4(SysView))
], ViewService);
// src/services/record.service.ts
import { Injectable as Injectable5 } from "@nestjs/common";
import { InjectRepository as InjectRepository5 } from "@nestjs/typeorm";
var RecordService = class {
constructor(recordRepo, historyRepo, dataSource, recordQueryService, fieldService) {
this.recordRepo = recordRepo;
this.historyRepo = historyRepo;
this.dataSource = dataSource;
this.recordQueryService = recordQueryService;
this.fieldService = fieldService;
}
/**
* Create a new record
*/
async create(dto) {
const fields = await this.fieldService.findByTable(dto.tableId);
const cleanData = this.filterComputedFields(dto.data, fields);
const record = this.recordRepo.create({
tableId: dto.tableId,
data: cleanData,
createdBy: dto.createdBy,
updatedBy: dto.createdBy
});
return this.recordRepo.save(record);
}
/**
* Batch create records
*/
async batchCreate(dto) {
const fields = await this.fieldService.findByTable(dto.tableId);
const records = dto.records.map((item) => {
const cleanData = this.filterComputedFields(item.data, fields);
return this.recordRepo.create({
tableId: dto.tableId,
data: cleanData,
createdBy: dto.createdBy,
updatedBy: dto.createdBy
});
});
return this.recordRepo.save(records);
}
/**
* Find all records with query options
*/
async findAll(tableId, options = {}) {
const fields = await this.fieldService.findByTable(tableId);
const tableFields = fields.map((f) => this.fieldService.toInterface(f));
return this.recordQueryService.getRecords(tableId, tableFields, options);
}
/**
* Find record by ID
*/
async findById(id) {
return this.recordRepo.findOne({ where: { id } });
}
/**
* Find record by ID with computed fields
*/
async findByIdWithComputed(id, tableId) {
const fields = await this.fieldService.findByTable(tableId);
const tableFields = fields.map((f) => this.fieldService.toInterface(f));
return this.recordQueryService.getRecordById(id, tableFields);
}
/**
* Update record
*/
async update(id, dto, trackHistory = true) {
const existing = await this.findById(id);
if (!existing) return null;
const fields = await this.fieldService.findByTable(existing.tableId);
const changes = {};
if (dto.data && trackHistory) {
const cleanData = this.filterComputedFields(dto.data, fields);
Object.keys(cleanData).forEach((key) => {
if (existing.data[key] !== cleanData[key]) {
changes[key] = {
old: existing.data[key],
new: cleanData[key]
};
}
});
}
const newData = dto.data ? { ...existing.data, ...this.filterComputedFields(dto.data, fields) } : existing.data;
await this.recordRepo.update(id, {
data: newData,
updatedBy: dto.updatedBy
});
if (trackHistory && Object.keys(changes).length > 0) {
await this.historyRepo.save({
recordId: id,
changedBy: dto.updatedBy,
changes
});
}
return this.findById(id);
}
/**
* Patch record (partial update)
*/
async patch(id, data, updatedBy) {
return this.update(id, { data, updatedBy });
}
/**
* Delete record
*/
async delete(id) {
const result = await this.recordRepo.delete(id);
return (result.affected ?? 0) > 0;
}
/**
* Batch delete records
*/
async batchDelete(ids) {
const result = await this.recordRepo.delete(ids);
return result.affected ?? 0;
}
/**
* Get record history
*/
async getHistory(recordId) {
return this.historyRepo.find({
where: { recordId },
order: { changedAt: "DESC" }
});
}
/**
* Filter out computed fields from data
*/
filterComputedFields(data, fields) {
const computedKeyNames = fields.filter((f) => COMPUTED_FIELD_TYPES.includes(f.type)).map((f) => f.keyName);
const filtered = {};
Object.keys(data).forEach((key) => {
if (!computedKeyNames.includes(key)) {
filtered[key] = data[key];
}
});
return filtered;
}
};
RecordService = __decorateClass([
Injectable5(),
__decorateParam(0, InjectRepository5(UsrRecord)),
__decorateParam(1, InjectRepository5(UsrRecordHistory))
], RecordService);
// src/services/record-query.service.ts
import { Injectable as Injectable6 } from "@nestjs/common";
import { InjectRepository as InjectRepository6 } from "@nestjs/typeorm";
var RecordQueryService = class {
constructor(recordRepo, formulaService) {
this.recordRepo = recordRepo;
this.formulaService = formulaService;
}
/**
* Get records with dynamic filters, sorting, and pagination
*/
async getRecords(tableId, fields, options = {}) {
const { filters = [], sort, pagination, search } = options;
const query = this.recordRepo.createQueryBuilder("record").where("record.table_id = :tableId", { tableId });
this.addFormulaSelects(query, fields);
this.applyFilters(query, filters, fields);
if (search?.query) {
this.applySearch(query, search.query, search.fields || [], fields);
}
const total = await query.getCount();
this.applySort(query, sort, fields);
const { page, limit, offset } = this.normalizePagination(pagination);
query.skip(offset).take(limit);
const rawResults = await query.getRawMany();
const data = this.mapRawResults(rawResults);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
hasNextPage: page * limit < total,
hasPrevPage: page > 1
}
};
}
/**
* Get single record by ID
*/
async getRecordById(recordId, fields) {
const query = this.recordRepo.createQueryBuilder("record").where("record.id = :recordId", { recordId });
this.addFormulaSelects(query, fields);
const raw = await query.getRawOne();
if (!raw) return null;
return this.mapSingleRawResult(raw);
}
/**
* Add formula fields as computed columns
*/
addFormulaSelects(query, fields) {
fields.forEach((field) => {
if (field.type === "formula" /* FORMULA */ && field.config.formulaExpression) {
const sqlExpression = this.formulaService.parseToSQL(
field.config.formulaExpression
);
query.addSelect(`(${sqlExpression})`, `formula_${field.keyName}`);
}
});
}
/**
* Apply filters to query
*/
applyFilters(query, filters, fields) {
filters.forEach((filter, index) => {
this.applySingleFilter(query, filter, index, fields);
});
}
/**
* Apply single filter
*/
applySingleFilter(query, filter, idx, fields) {
const paramName = `val_${idx}`;
const fieldDef = fields.find((f) => f.keyName === filter.column);
let dbFieldSql = "";
if (fieldDef?.type === "formula" /* FORMULA */ && fieldDef.config.formulaExpression) {
dbFieldSql = `(${this.formulaService.parseToSQL(fieldDef.config.formulaExpression)})`;
} else {
const castType = this.formulaService.getPostgresCastType(
filter.dataType || fieldDef?.type
);
dbFieldSql = `(record.data->>'${filter.column}')${castType}`;
}
const paramObj = { [paramName]: filter.value };
switch (filter.operator) {
case "eq":
query.andWhere(`${dbFieldSql} = :${paramName}`, paramObj);
break;
case "neq":
query.andWhere(`${dbFieldSql} != :${paramName}`, paramObj);
break;
case "gt":
query.andWhere(`${dbFieldSql} > :${paramName}`, paramObj);
break;
case "gte":
query.andWhere(`${dbFieldSql} >= :${paramName}`, paramObj);
break;
case "lt":
query.andWhere(`${dbFieldSql} < :${paramName}`, paramObj);
break;
case "lte":
query.andWhere(`${dbFieldSql} <= :${paramName}`, paramObj);
break;
case "contains":
query.andWhere(`record.data->>'${filter.column}' ILIKE :${paramName}`, {
[paramName]: `%${filter.value}%`
});
break;
case "not_contains":
query.andWhere(`record.data->>'${filter.column}' NOT ILIKE :${paramName}`, {
[paramName]: `%${filter.value}%`
});
break;
case "starts_with":
query.andWhere(`record.data->>'${filter.column}' ILIKE :${paramName}`, {
[paramName]: `${filter.value}%`
});
break;
case "ends_with":
query.andWhere(`record.data->>'${filter.column}' ILIKE :${paramName}`, {
[paramName]: `%${filter.value}`
});
break;
case "in":
query.andWhere(`${dbFieldSql} IN (:...${paramName})`, paramObj);
break;
case "not_in":
query.andWhere(`${dbFieldSql} NOT IN (:...${paramName})`, paramObj);
break;
case "is_empty":
query.andWhere(
`(record.data->>'${filter.column}' IS NULL OR record.data->>'${filter.column}' = '')`
);
break;
case "is_not_empty":
query.andWhere(
`(record.data->>'${filter.column}' IS NOT NULL AND record.data->>'${filter.column}' != '')`
);
break;
case "is_before":
query.andWhere(`(record.data->>'${filter.column}')::timestamp < :${paramName}`, paramObj);
break;
case "is_after":
query.andWhere(`(record.data->>'${filter.column}')::timestamp > :${paramName}`, paramObj);
break;
case "is_on_or_before":
query.andWhere(`(record.data->>'${filter.column}')::timestamp <= :${paramName}`, paramObj);
break;
case "is_on_or_after":
query.andWhere(`(record.data->>'${filter.column}')::timestamp >= :${paramName}`, paramObj);
break;
case "is_within":
if (typeof filter.value === "object") {
const interval = this.buildIntervalExpression(filter.value);
query.andWhere(
`(record.data->>'${filter.column}')::timestamp >= NOW() - INTERVAL '${interval}'`
);
}
break;
}
}
/**
* Apply full-text search
*/
applySearch(query, searchQuery, searchFields, fields) {
const textFields = searchFields.length > 0 ? searchFields : fields.filter(
(f) => ["text" /* TEXT */, "long_text" /* LONG_TEXT */, "email" /* EMAIL */].includes(f.type)
).map((f) => f.keyName);
if (textFields.length === 0) return;
const conditions = textFields.map((field) => `record.data->>'${field}' ILIKE :searchQuery`).join(" OR ");
query.andWhere(`(${conditions})`, { searchQuery: `%${searchQuery}%` });
}
/**
* Apply sorting
*/
applySort(query, sort, fields) {
if (!sort) {
query.orderBy("record.created_at", "DESC");
return;
}
const fieldDef = fields.find((f) => f.keyName === sort.fieldKey);
if (fieldDef?.type === "formula" /* FORMULA */ && fieldDef.config.formulaExpression) {
const sqlExpression = this.formulaService.parseToSQL(fieldDef.config.formulaExpression);
query.orderBy(`(${sqlExpression})`, sort.order);
} else {
const castType = this.formulaService.getPostgresCastType(fieldDef?.type);
query.orderBy(`(record.data->>'${sort.fieldKey}')${castType}`, sort.order);
}
}
/**
* Normalize pagination params
*/
normalizePagination(pagination) {
const page = pagination?.page || 1;
const limit = Math.min(pagination?.limit || 50, 100);
const offset = pagination?.offset ?? (page - 1) * limit;
return { page, limit, offset };
}
/**
* Build interval expression for is_within filter
*/
buildIntervalExpression(value) {
const parts = [];
if (value.days) parts.push(`${value.days} days`);
if (value.weeks) parts.push(`${value.weeks * 7} days`);
if (value.months) parts.push(`${value.months} months`);
if (value.years) parts.push(`${value.years} years`);
return parts.join(" ") || "0 days";
}
/**
* Map raw query results to clean response
*/
mapRawResults(raws) {
return raws.map((row) => this.mapSingleRawResult(row));
}
/**
* Map single raw result
*/
mapSingleRawResult(row) {
const cleanRecord = {
id: row.record_id,
...row.record_data,
createdAt: row.record_created_at,
updatedAt: row.record_updated_at,
createdBy: row.record_created_by,
updatedBy: row.record_updated_by
};
Object.keys(row).forEach((key) => {
if (key.startsWith("formula_")) {
const realKey = key.replace("formula_", "");
const value = row[key];
cleanRecord[realKey] = value !== null ? parseFloat(value) : null;
}
});
return cleanRecord;
}
};
RecordQueryService = __decorateClass([
Injectable6(),
__decorateParam(0, InjectRepository6(UsrRecord))
], RecordQueryService);
// src/services/formula.service.ts
import { Injectable as Injectable7 } from "@nestjs/common";
var FormulaService = class {
/**
* Parse formula expression thành SQL expression
* Input: "{price} * {qty}"
* Output: "COALESCE((record.data->>'price')::numeric, 0) * COALESCE((record.data->>'qty')::numeric, 0)"
*/
parseToSQL(expression, tableAlias = "record") {
return expression.replace(/\{(\w+)\}/g, (_, keyName) => {
return `COALESCE((${tableAlias}.data->>'${keyName}')::numeric, 0)`;
});
}
/**
* Parse formula với type casting dựa trên field type
*/
parseToSQLWithType(expression, fields, tableAlias = "record") {
return expression.replace(/\{(\w+)\}/g, (_, keyName) => {
const field = fields.find((f) => f.keyName === keyName);
const castType = this.getPostgresCastType(field?.type);
const defaultValue = this.getDefaultValue(field?.type);
return `COALESCE((${tableAlias}.data->>'${keyName}')${castType}, ${defaultValue})`;
});
}
/**
* Validate formula expression
*/
validate(expression, availableFields) {
const errors = [];
const fieldPattern = /\{(\w+)\}/g;
let match;
while ((match = fieldPattern.exec(expression)) !== null) {
const fieldName = match[1];
if (!availableFields.includes(fieldName)) {
errors.push(`Field "${fieldName}" does not exist`);
}
}
try {
const testExpr = expression.replace(/\{(\w+)\}/g, "1");
if (testExpr.includes("{{") || testExpr.includes("}}")) {
errors.push("Invalid bracket syntax");
}
} catch {
errors.push("Invalid formula syntax");
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Extract field references from formula
*/
extractFieldReferences(expression) {
const fieldPattern = /\{(\w+)\}/g;
const fields = [];
let match;
while ((match = fieldPattern.exec(expression)) !== null) {
if (!fields.includes(match[1])) {
fields.push(match[1]);
}
}
return fields;
}
/**
* Get PostgreSQL cast type for field type
*/
getPostgresCastType(type) {
switch (type) {
case "number" /* NUMBER */:
case "currency" /* CURRENCY */:
case "percent" /* PERCENT */:
case "rating" /* RATING */:
return "::numeric";
case "boolean" /* BOOLEAN */:
return "::boolean";
case "date" /* DATE */:
case "datetime" /* DATETIME */:
return "::timestamp";
default:
return "";
}
}
/**
* Get default value for field type
*/
getDefaultValue(type) {
switch (type) {
case "number" /* NUMBER */:
case "currency" /* CURRENCY */:
case "percent" /* PERCENT */:
case "rating" /* RATING */:
return "0";
case "boolean" /* BOOLEAN */:
return "false";
case "text" /* TEXT */:
case "long_text" /* LONG_TEXT */:
case "email" /* EMAIL */:
case "phone" /* PHONE */:
case "url" /* URL */:
return "''";
default:
return "NULL";
}
}
/**
* Build SQL expression for computed field
*/
buildComputedFieldSQL(field, tableAlias = "record") {
if (field.type !== "formula" /* FORMULA */) {
return null;
}
const expression = field.config.formulaExpression;
if (!expression) {
return null;
}
return this.parseToSQL(expression, tableAlias);
}
};
FormulaService = __decorateClass([
Injectable7()
], FormulaService);
// src/services/validation.service.ts
import { Injectable as Injectable8 } from "@nestjs/common";
var ValidationService = class {
/**
* Validate record data against field definitions
*/
validate(data, fields) {
const errors = [];
fields.forEach((field) => {
const value = data[field.keyName];
const fieldErrors = this.validateField(value, field);
errors.push(...fieldErrors);
});
return {
valid: errors.length === 0,
errors
};
}
/**
* Validate single field value
*/
validateField(value, field) {
const errors = [];
if (field.isRequired && this.isEmpty(value)) {
errors.push({
field: field.keyName,
message: `${field.name} is required`,
code: "REQUIRED"
});
return errors;
}
if (this.isEmpty(value)) {
return errors;
}
switch (field.type) {
case "email" /* EMAIL */:
if (!this.isValidEmail(value)) {
errors.push({
field: field.keyName,
message: `${field.name} must be a valid email address`,
code: "INVALID_EMAIL"
});
}
break;
case "url" /* URL */:
if (!this.isValidUrl(value, field.config)) {
errors.push({
field: field.keyName,
message: `${field.name} must be a valid URL`,
code: "INVALID_URL"
});
}
break;
case "phone" /* PHONE */:
if (!this.isValidPhone(value)) {
errors.push({
field: field.keyName,
message: `${field.name} must be a valid phone number`,
code: "INVALID_PHONE"
});
}
break;
case "number" /* NUMBER */:
case "currency" /* CURRENCY */:
case "percent" /* PERCENT */:
if (!this.isValidNumber(value)) {
errors.push({
field: field.keyName,
message: `${field.name} must be a valid number`,
code: "INVALID_NUMBER"
});
}
break;
case "rating" /* RATING */:
const maxRating = field.config.maxRating || 5;
if (!this.isValidRating(value, maxRating)) {
errors.push({
field: field.keyName,
message: `${field.name} must be between 1 and ${maxRating}`,
code: "INVALID_RATING"
});
}
break;
case "date" /* DATE */:
case "datetime" /* DATETIME */:
if (!this.isValidDate(value)) {
errors.push({
field: field.keyName,
message: `${field.name} must be a valid date`,
code: "INVALID_DATE"
});
}
break;
case "select" /* SELECT */:
if (!this.isValidSelect(value, field.config)) {
errors.push({
field: field.keyName,
message: `${field.name} must be one of the allowed options`,
code: "INVALID_SELECT"
});
}
break;
case "multi_select" /* MULTI_SELECT */:
if (!this.isValidMultiSelect(value, field.config)) {
errors.push({
field: field.keyName,
message: `${field.name} contains invalid options`,
code: "INVALID_MULTI_SELECT"
});
}
break;
case "boolean" /* BOOLEAN */:
if (typeof value !== "boolean") {
errors.push({
field: field.keyName,
message: `${field.name} must be a boolean`,
code: "INVALID_BOOLEAN"
});
}
break;
case "long_text" /* LONG_TEXT */:
if (field.config.maxLength && String(value).length > field.config.maxLength) {
errors.push({
field: field.keyName,
message: `${field.name} exceeds maximum length of ${field.config.maxLength}`,
code: "MAX_LENGTH_EXCEEDED"
});
}
break;
}
return errors;
}
/**
* Check if value is empty
*/
isEmpty(value) {
return value === void 0 || value === null || value === "";
}
/**
* Validate email format
*/
isValidEmail(value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(String(value));
}
/**
* Validate URL format
*/
isValidUrl(value, config) {
try {
const url = new URL(String(value));
if (config.urlType === "image") {
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname);
}
if (config.urlType === "video") {
return /\.(mp4|webm|ogg|mov)$/i.test(url.pathname) || url.hostname.includes("youtube.com") || url.hostname.includes("vimeo.com");
}
return true;
} catch {
return false;
}
}
/**
* Validate phone number format
*/
isValidPhone(value) {
const phoneRegex = /^[\d\s\-+()]{7,20}$/;
return phoneRegex.test(String(value));
}
/**
* Validate number
*/
isValidNumber(value) {
return !isNaN(Number(value)) && isFinite(Number(value));
}
/**
* Validate rating
*/
isValidRating(value, maxRating) {
const num = Number(value);
return !isNaN(num) && num >= 0 && num <= maxRating;
}
/**
* Validate date
*/
isValidDate(value) {
const date = new Date(value);
return !isNaN(date.getTime());
}
/**
* Validate select value
*/
isValidSelect(value, config) {
if (!config.options || config.options.length === 0) {
return true;
}
return config.options.some((opt) => opt.id === value || opt.label === value);
}
/**
* Validate multi-select values
*/
isValidMultiSelect(value, config) {
if (!Array.isArray(value)) {
return false;
}
if (!config.options || config.options.length === 0) {
return true;
}
return value.every(
(v) => config.options.some((opt) => opt.id === v || opt.label === v)
);
}
};
ValidationService = __decorateClass([
Injectable8()
], ValidationService);
// src/dynamic-table.module.ts
var DYNAMIC_TABLE_OPTIONS = "DYNAMIC_TABLE_OPTIONS";
var SERVICES = [
WorkspaceService,
TableService,
FieldService,
ViewService,
RecordService,
RecordQueryService,
FormulaService,
ValidationService
];
var DynamicTableModule = class {
/**
* Register module with synchronous options
*/
static forRoot(options = {}) {
const optionsProvider = {
provide: DYNAMIC_TABLE_OPTIONS,
useValue: options
};
return {
module: DynamicTableModule,
imports: [
TypeOrmModule.forFeature(DYNAMIC_TABLE_ENTITIES)
],
providers: [optionsProvider, ...SERVICES],
exports: [optionsProvider, ...SERVICES]
};
}
/**
* Register module with asynchronous options
*/
static forRootAsync(options) {
const asyncProviders = this.createAsyncProviders(options);
return {
module: DynamicTableModule,
imports: [
...options.imports || [],
TypeOrmModule.forFeature(DYNAMIC_TABLE_ENTITIES)
],
providers: [...asyncProviders, ...SERVICES],
exports: [DYNAMIC_TABLE_OPTIONS, ...SERVICES]
};
}
/**
* Create async providers
*/
static createAsyncProviders(options) {
if (options.useExisting || options.useFactory) {
return [this.createAsyncOptionsProvider(options)];
}
if (options.useClass) {
return [
this.createAsyncOptionsProvider(options),
{
provide: options.useClass,
useClass: options.useClass
}
];
}
return [this.createAsyncOptionsProvider(options)];
}
/**
* Create async options provider
*/
static createAsyncOptionsProvider(options) {
if (options.useFactory) {
return {
provide: DYNAMIC_TABLE_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || []
};
}
const inject = options.useExisting || options.useClass;
return {
provide: DYNAMIC_TABLE_OPTIONS,
useFactory: async (optionsFactory) => optionsFactory.createDynamicTableOptions(),
inject: inject ? [inject] : []
};
}
};
DynamicTableModule = __decorateClass([
Global(),
Module({})
], DynamicTableModule);
export {
COMPUTED_FIELD_TYPES,
DYNAMIC_TABLE_ENTITIES,
DYNAMIC_TABLE_OPTIONS,
DynamicTableModule,
FIELD_TYPE_METADATA,
FieldService,
FieldType,
FormulaService,
RecordQueryService,
RecordService,
SysField,
SysTable,
SysView,
SysWorkspace,
TableService,
UsrRecord,
UsrRecordHistory,
ValidationService,
ViewService,
WorkspaceService
};
//# sourceMappingURL=index.mjs.map