425 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import parser from 'cron-parser';
import { escapeRegExp } from 'lodash';
import moment from 'moment';
import { DataTypes, Transactionable, ValidationError, ValidationErrorItem } from 'sequelize';
import { BaseColumnFieldOptions, Field, FieldContext, Model } from '@nocobase/database';
import { Registry } from '@nocobase/utils';
export interface Pattern {
validate?(options): string | null;
generate(
this: SequenceField,
instance: Model,
opts: { [key: string]: any },
options: Transactionable,
): Promise<string> | string;
batchGenerate(
this: SequenceField,
instances: Model[],
values: string[],
opts: { [key: string]: any },
options: Transactionable,
): Promise<void> | void;
getLength(options): number;
getMatcher(options): string;
update?(
this: SequenceField,
instance: Model,
value: string,
options,
transactionable: Transactionable,
): Promise<void>;
}
export const sequencePatterns = new Registry<Pattern>();
sequencePatterns.register('string', {
validate(options) {
if (!options?.value) {
return 'options.value should be configured as a non-empty string';
}
return null;
},
generate(instance, options) {
return options.value;
},
batchGenerate(instances, values, options) {
instances.forEach((instance, i) => {
values[i] = options.value;
});
},
getLength(options) {
return options.value.length;
},
getMatcher(options) {
return escapeRegExp(options.value);
},
});
sequencePatterns.register('integer', {
// validate(options) {
// if (!options?.key) {
// return 'options.key should be configured as an integer';
// }
// return null;
// },
async generate(this: SequenceField, instance: Model, options, { transaction }) {
const recordTime = <Date>instance.get('createdAt') ?? new Date();
const { digits = 1, start = 0, base = 10, cycle, key } = options;
const { repository: SeqRepo, model: SeqModel } = this.database.getCollection('sequences');
const lastSeq = (await SeqRepo.findOne({
filter: {
collection: this.collection.name,
field: this.name,
key,
},
transaction,
})) || SeqModel.build({
collection: this.collection.name,
field: this.name,
key,
});
let next = start;
if (lastSeq.get('current') != null) {
next = Math.max(lastSeq.get('current') + 1, start);
const max = Math.pow(base, digits) - 1;
if (next > max) {
next = start;
}
// cycle as cron string
if (cycle) {
const interval = parser.parseExpression(cycle, { currentDate: <Date>lastSeq.get('lastGeneratedAt') });
const nextTime = interval.next();
if (recordTime.getTime() >= nextTime.getTime()) {
next = start;
}
}
}
lastSeq.set({
current: next,
lastGeneratedAt: recordTime,
});
await lastSeq.save({ transaction });
return next.toString(base).padStart(digits, '0');
},
getLength({ digits = 1 } = {}) {
return digits;
},
getMatcher(options = {}) {
const { digits = 1, base = 10 } = options;
const chars = '0123456789abcdefghijklmnopqrstuvwxyz'.slice(0, base);
return `[${chars}]{${digits}}`;
},
async batchGenerate(instances, values, options, { transaction }) {
const { name, patterns } = this.options;
const { digits = 1, start = 0, base = 10, cycle, key } = options;
const { repository: SeqRepo, model: SeqModel } = this.database.getCollection('sequences');
const lastSeq = (await SeqRepo.findOne({
filter: {
collection: this.collection.name,
field: this.name,
key,
},
transaction,
})) || SeqModel.build({
collection: this.collection.name,
field: this.name,
key,
});
instances.forEach((instance, i) => {
const recordTime = <Date>instance.get('createdAt') ?? new Date();
const value = instance.get(name);
if (value != null && this.options.inputable) {
const matcher = this.match(value);
// 如果匹配到了,需要检查是否要更新 current 值
if (matcher) {
const patternIndex = patterns.indexOf(options);
const number = Number.parseInt(matcher[patternIndex + 1], base);
// 如果当前值大于 lastSeq.current则更新 lastSeq.current
if (lastSeq.get('current') == null) {
lastSeq.set({
current: number,
lastGeneratedAt: recordTime,
});
} else {
if (number > lastSeq.get('current')) {
lastSeq.set({
current: number,
lastGeneratedAt: recordTime,
});
}
}
}
// 否则交给 validate 检查是否要求 match如果要求则相应报错
} else {
// 自动生成
let next = start;
if (lastSeq.get('current') != null) {
next = Math.max(lastSeq.get('current') + 1, start);
const max = Math.pow(base, digits) - 1;
if (next > max) {
next = start;
}
// cycle as cron string
if (cycle) {
const interval = parser.parseExpression(cycle, { currentDate: <Date>lastSeq.get('lastGeneratedAt') });
const nextTime = interval.next();
if (recordTime.getTime() >= nextTime.getTime()) {
next = start;
}
}
}
lastSeq.set({
current: next,
lastGeneratedAt: recordTime,
});
values[i] = next.toString(base).padStart(digits, '0');
}
});
await lastSeq.save({ transaction });
},
async update(instance, value, options, { transaction }) {
const recordTime = <Date>instance.get('createdAt') ?? new Date();
const { digits = 1, start = 0, base = 10, cycle, key } = options;
const SeqRepo = this.database.getRepository('sequences');
const lastSeq = await SeqRepo.findOne({
filter: {
collection: this.collection.name,
field: this.name,
key,
},
transaction,
});
const current = Number.parseInt(value, base);
if (!lastSeq) {
return SeqRepo.create({
values: {
collection: this.collection.name,
field: this.name,
key,
current,
lastGeneratedAt: recordTime,
},
transaction,
});
}
if (lastSeq.get('current') == null) {
return lastSeq.update(
{
current,
lastGeneratedAt: recordTime,
},
{ transaction },
);
}
if (cycle) {
const interval = parser.parseExpression(cycle, { currentDate: <Date>lastSeq.get('lastGeneratedAt') });
const nextTime = interval.next();
if (recordTime.getTime() >= nextTime.getTime()) {
lastSeq.set({
current,
lastGeneratedAt: recordTime,
});
} else {
if (current > lastSeq.get('current')) {
lastSeq.set({
current,
lastGeneratedAt: recordTime,
});
}
}
} else {
if (current > lastSeq.get('current')) {
lastSeq.set({
current,
lastGeneratedAt: recordTime,
});
}
}
return lastSeq.save({ transaction });
},
});
sequencePatterns.register('date', {
generate(this: SequenceField, instance, options) {
return moment(instance.get(options?.field ?? 'createdAt')).format(options?.format ?? 'YYYYMMDD');
},
batchGenerate(instances, values, options) {
const { field, inputable } = options;
instances.forEach((instance, i) => {
if (!inputable || instance.get(field ?? 'createdAt') == null) {
values[i] = sequencePatterns.get('date').generate.call(this, instance, options);
}
});
},
getLength(options) {
return options.format?.length ?? 8;
},
getMatcher(options = {}) {
return `.{${options?.format?.length ?? 8}}`;
},
});
interface PatternConfig {
type: string;
title?: string;
options?: any;
}
export interface SequenceFieldOptions extends BaseColumnFieldOptions {
type: 'sequence';
patterns: PatternConfig[];
}
export class SequenceField extends Field {
matcher: RegExp;
get dataType() {
return DataTypes.STRING;
}
constructor(options: SequenceFieldOptions, context: FieldContext) {
super(options, context);
if (!options.patterns || !options.patterns.length) {
throw new Error('at least one pattern should be defined for sequence type');
}
options.patterns.forEach((pattern) => {
const P = sequencePatterns.get(pattern.type);
if (!P) {
throw new Error(`pattern type ${pattern.type} is not registered`);
}
if (P.validate) {
const error = P.validate(pattern.options);
if (error) {
throw new Error(error);
}
}
});
const patterns = options.patterns.map(({ type, options }) => sequencePatterns.get(type).getMatcher(options));
this.matcher = new RegExp(`^${patterns.map((p) => `(${p})`).join('')}$`, 'i');
}
validate = (instance: Model) => {
const { name, inputable, match } = this.options;
const value = instance.get(name);
if (value != null && inputable && match && !this.match(value)) {
throw new ValidationError('sequence pattern not match', [
new ValidationErrorItem(
`input value of ${name} field not match the sequence pattern (${this.matcher.toString()}) which is required`,
'validation error', // NOTE: type should only be this which in sequelize enum set
name,
value,
instance,
'sequence_pattern_not_match',
name,
[],
),
]);
}
};
setValue = async (instance: Model, options) => {
if (options.skipIndividualHooks?.has(`${this.collection.name}.beforeCreate.${this.name}`)) {
return;
}
const { name, patterns, inputable } = this.options;
const value = instance.get(name);
if (value != null && inputable) {
return this.update(instance, options);
}
const results = await patterns.reduce(
(promise, p) =>
promise.then(async (result) => {
const item = await sequencePatterns.get(p.type).generate.call(this, instance, p.options, options);
return result.concat(item);
}),
Promise.resolve([]),
);
instance.set(name, results.join(''));
};
setGroupValue = async (instances: Model[], options) => {
if (!instances.length) {
return;
}
if (!options.skipIndividualHooks) {
options.skipIndividualHooks = new Set();
}
options.skipIndividualHooks.add(`${this.collection.name}.beforeCreate.${this.name}`);
const { name, patterns, inputable } = this.options;
const array = Array(patterns.length).fill(null).map(() => Array(instances.length));
await patterns.reduce((promise, p, i) => promise.then(() =>
sequencePatterns.get(p.type).batchGenerate.call(this, instances, array[i], p.options ?? {}, options)),
Promise.resolve());
instances.forEach((instance, i) => {
const value = instance.get(name);
if (!inputable || value == null) {
instance.set(this.name, array.map((a) => a[i]).join(''));
}
});
};
cleanHook = (_, options) => {
options.skipIndividualHooks.delete(`${this.collection.name}.beforeCreate.${this.name}`);
}
match(value) {
return typeof value === 'string' ? value.match(this.matcher) : null;
}
async update(instance: Model, options) {
const { name, patterns } = this.options;
const matched = this.match(instance.get(name));
if (matched) {
await matched
.slice(1)
.map((_, i) => sequencePatterns.get(patterns[i].type).update)
.reduce(
(promise, update, i) =>
promise.then(() => {
if (!update) {
return;
}
return update.call(this, instance, matched[i + 1], patterns[i].options, options);
}),
Promise.resolve(),
);
}
}
bind() {
super.bind();
this.on('beforeValidate', this.validate);
this.on('beforeCreate', this.setValue);
this.on('beforeBulkCreate', this.setGroupValue);
this.on('afterBulkCreate', this.cleanHook);
}
unbind() {
super.unbind();
this.off('beforeValidate', this.validate);
this.off('beforeCreate', this.setValue);
this.off('beforeBulkCreate', this.setGroupValue);
this.off('afterBulkCreate', this.cleanHook);
}
}