初始化项目
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 对象变更审计(1.5.16)。
|
||||
*
|
||||
* 内存环形审计:记录结构变更/授权/脚本执行等操作,支持按时间倒序读取与导出为 CSV。
|
||||
* 上层在每次执行 DDL 前调用 record;持久化由 1.3.7 存储层负责(后续接入)。
|
||||
*/
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
at: number;
|
||||
actor: string;
|
||||
action: string;
|
||||
target: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export class AuditLog {
|
||||
private readonly capacity: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly entries: AuditEntry[] = [];
|
||||
private nextId: number = 1;
|
||||
|
||||
constructor(capacity: number, clock?: () => number) {
|
||||
if (capacity < 1) {
|
||||
throw new Error('capacity must be >= 1');
|
||||
}
|
||||
this.capacity = capacity;
|
||||
this.clock = clock === undefined ? () => Date.now() : clock;
|
||||
}
|
||||
|
||||
/** 记录一条审计;返回带 id 与时间的条目。 */
|
||||
record(actor: string, action: string, target: string, detail: string): AuditEntry {
|
||||
const entry: AuditEntry = {
|
||||
id: this.nextId,
|
||||
at: this.clock(),
|
||||
actor: actor,
|
||||
action: action,
|
||||
target: target,
|
||||
detail: detail
|
||||
};
|
||||
this.nextId++;
|
||||
this.entries.push(entry);
|
||||
while (this.entries.length > this.capacity) {
|
||||
this.entries.shift();
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** 按时间倒序返回最近 limit 条(limit<=0 返回全部)。 */
|
||||
recent(limit: number): AuditEntry[] {
|
||||
const start: number = limit > 0 && this.entries.length > limit ? this.entries.length - limit : 0;
|
||||
const out: AuditEntry[] = [];
|
||||
for (let i = this.entries.length - 1; i >= start; i--) {
|
||||
out.push(this.entries[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.entries.length;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.length = 0;
|
||||
}
|
||||
|
||||
/** 导出为 CSV(字段内的双引号会转义)。 */
|
||||
toCsv(): string {
|
||||
const lines: string[] = ['id,at,actor,action,target,detail'];
|
||||
for (let i = 0; i < this.entries.length; i++) {
|
||||
const e: AuditEntry = this.entries[i];
|
||||
lines.push(e.id + ',' + e.at + ',' + AuditLog.escape(e.actor) + ',' + AuditLog.escape(e.action) +
|
||||
',' + AuditLog.escape(e.target) + ',' + AuditLog.escape(e.detail));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private static escape(text: string): string {
|
||||
if (text.indexOf(',') >= 0 || text.indexOf('"') >= 0 || text.indexOf('\n') >= 0) {
|
||||
return '"' + text.replace(new RegExp('"', 'g'), '""') + '"';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 分组与聚合(1.7.4)。
|
||||
*
|
||||
* 生成聚合查询的 SELECT/GROUP BY 片段,并提供按方言的时间提取表达式。
|
||||
* 纯逻辑,供数据浏览界面的「分组统计」面板使用。
|
||||
*/
|
||||
import { SqlDialect } from '../schema/DdlGenerator';
|
||||
|
||||
export enum AggFunc {
|
||||
COUNT = 0,
|
||||
SUM = 1,
|
||||
AVG = 2,
|
||||
MIN = 3,
|
||||
MAX = 4
|
||||
}
|
||||
|
||||
export interface AggSpec {
|
||||
func: AggFunc;
|
||||
/** 列名;COUNT(*) 用 '*'。 */
|
||||
column: string;
|
||||
/** 别名;为空则不生成 AS。 */
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export interface GroupSpec {
|
||||
groupBy: string[];
|
||||
aggregates: AggSpec[];
|
||||
}
|
||||
|
||||
export enum TimePart {
|
||||
YEAR = 0,
|
||||
MONTH = 1,
|
||||
DAY = 2,
|
||||
HOUR = 3
|
||||
}
|
||||
|
||||
export class AggregationBuilder {
|
||||
/** 生成 SELECT ... GROUP BY ... 片段(不含 FROM)。 */
|
||||
static selectClause(spec: GroupSpec): string {
|
||||
const items: string[] = [];
|
||||
for (let i = 0; i < spec.groupBy.length; i++) {
|
||||
const column: string = spec.groupBy[i].trim();
|
||||
if (column.length > 0) {
|
||||
items.push(column);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < spec.aggregates.length; i++) {
|
||||
items.push(AggregationBuilder.aggregateSql(spec.aggregates[i]));
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const selectPart: string = 'SELECT ' + items.join(', ');
|
||||
const groupColumns: string[] = [];
|
||||
for (let i = 0; i < spec.groupBy.length; i++) {
|
||||
if (spec.groupBy[i].trim().length > 0) {
|
||||
groupColumns.push(spec.groupBy[i].trim());
|
||||
}
|
||||
}
|
||||
if (groupColumns.length === 0) {
|
||||
return selectPart;
|
||||
}
|
||||
return selectPart + ' GROUP BY ' + groupColumns.join(', ');
|
||||
}
|
||||
|
||||
static aggregateSql(agg: AggSpec): string {
|
||||
const column: string = agg.column.trim().length === 0 ? '*' : agg.column.trim();
|
||||
const name: string = AggregationBuilder.funcName(agg.func);
|
||||
const alias: string = agg.alias.trim();
|
||||
return name + '(' + column + ')' + (alias.length === 0 ? '' : ' AS ' + alias);
|
||||
}
|
||||
|
||||
/** 时间提取表达式(按方言)。 */
|
||||
static timeExtractSql(part: TimePart, column: string, dialect: SqlDialect): string {
|
||||
const col: string = column.trim();
|
||||
const unit: string = AggregationBuilder.partName(part);
|
||||
switch (dialect) {
|
||||
case SqlDialect.MYSQL:
|
||||
return 'EXTRACT(' + unit + ' FROM ' + col + ')';
|
||||
case SqlDialect.POSTGRESQL:
|
||||
return 'EXTRACT(' + unit + ' FROM ' + col + ')';
|
||||
case SqlDialect.ORACLE:
|
||||
return 'EXTRACT(' + unit + ' FROM ' + col + ')';
|
||||
case SqlDialect.SQLITE:
|
||||
return 'strftime(\'' + AggregationBuilder.strftimeFormat(part) + '\', ' + col + ')';
|
||||
case SqlDialect.SQLSERVER:
|
||||
return 'DATEPART(' + unit.toLowerCase() + ', ' + col + ')';
|
||||
default:
|
||||
return 'EXTRACT(' + unit + ' FROM ' + col + ')';
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验分组规格。 */
|
||||
static validate(spec: GroupSpec): string[] {
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < spec.aggregates.length; i++) {
|
||||
const agg: AggSpec = spec.aggregates[i];
|
||||
if (agg.func !== AggFunc.COUNT && agg.column.trim().length === 0) {
|
||||
errors.push('aggregate ' + AggregationBuilder.funcName(agg.func) + ' requires a column');
|
||||
}
|
||||
}
|
||||
if (spec.groupBy.length === 0 && spec.aggregates.length === 0) {
|
||||
errors.push('group spec is empty');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static funcName(func: AggFunc): string {
|
||||
switch (func) {
|
||||
case AggFunc.COUNT:
|
||||
return 'COUNT';
|
||||
case AggFunc.SUM:
|
||||
return 'SUM';
|
||||
case AggFunc.AVG:
|
||||
return 'AVG';
|
||||
case AggFunc.MIN:
|
||||
return 'MIN';
|
||||
default:
|
||||
return 'MAX';
|
||||
}
|
||||
}
|
||||
|
||||
private static partName(part: TimePart): string {
|
||||
switch (part) {
|
||||
case TimePart.YEAR:
|
||||
return 'YEAR';
|
||||
case TimePart.MONTH:
|
||||
return 'MONTH';
|
||||
case TimePart.DAY:
|
||||
return 'DAY';
|
||||
case TimePart.HOUR:
|
||||
return 'HOUR';
|
||||
default:
|
||||
return 'YEAR';
|
||||
}
|
||||
}
|
||||
|
||||
private static strftimeFormat(part: TimePart): string {
|
||||
switch (part) {
|
||||
case TimePart.YEAR:
|
||||
return '%Y';
|
||||
case TimePart.MONTH:
|
||||
return '%m';
|
||||
case TimePart.DAY:
|
||||
return '%d';
|
||||
case TimePart.HOUR:
|
||||
return '%H';
|
||||
default:
|
||||
return '%Y';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 多行选择与批量修改(1.7.8)。
|
||||
*
|
||||
* 把「对某列统一赋值」展开为逐行变更,交给 TableEditor 记录;
|
||||
* 展开时读取每行旧值以保留可回滚信息。
|
||||
*/
|
||||
import { CellChange } from './TableEditor';
|
||||
|
||||
export class BatchModifier {
|
||||
/**
|
||||
* 对选定行的某列批量赋值。
|
||||
* @param rows 行号列表(会去重并升序)
|
||||
* @param column 列名
|
||||
* @param newValue 新值
|
||||
* @param oldValueOf 读取旧值的回调
|
||||
*/
|
||||
static applyToRows(rows: number[], column: string, newValue: string,
|
||||
oldValueOf: (row: number) => string): CellChange[] {
|
||||
const unique: number[] = BatchModifier.normalizeRows(rows);
|
||||
const out: CellChange[] = [];
|
||||
for (let i = 0; i < unique.length; i++) {
|
||||
out.push({
|
||||
rowIndex: unique[i],
|
||||
column: column,
|
||||
oldValue: oldValueOf(unique[i]),
|
||||
newValue: newValue
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对选定行的多列批量赋值(列到值的映射)。
|
||||
* @param values 列名 → 新值
|
||||
*/
|
||||
static applyMultiple(rows: number[], values: Map<string, string>,
|
||||
oldValueOf: (row: number, column: string) => string): CellChange[] {
|
||||
const unique: number[] = BatchModifier.normalizeRows(rows);
|
||||
const out: CellChange[] = [];
|
||||
for (let i = 0; i < unique.length; i++) {
|
||||
values.forEach((value: string, column: string) => {
|
||||
out.push({
|
||||
rowIndex: unique[i],
|
||||
column: column,
|
||||
oldValue: oldValueOf(unique[i], column),
|
||||
newValue: value
|
||||
});
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 行号去重并升序(负数忽略)。 */
|
||||
static normalizeRows(rows: number[]): number[] {
|
||||
const seen: Set<number> = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (rows[i] >= 0 && !seen.has(rows[i])) {
|
||||
seen.add(rows[i]);
|
||||
out.push(rows[i]);
|
||||
}
|
||||
}
|
||||
out.sort((a: number, b: number) => a - b);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 分隔文本编解码(1.7.9,1.8.2 复用)。
|
||||
*
|
||||
* TSV 与 CSV 共用:字段含分隔符、引号或换行时用双引号包裹,内部引号双写;
|
||||
* 解析支持引号包裹与双写转义,兼容 CRLF。
|
||||
*
|
||||
* 纯逻辑:不涉及文件与编码(编码 BOM 处理属 1.8.11)。
|
||||
*/
|
||||
export class DelimitedCodec {
|
||||
/** 序列化为分隔文本;行之间用 \n,末尾不带换行。 */
|
||||
static serialize(rows: string[][], delimiter: string): string {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const cells: string[] = [];
|
||||
for (let j = 0; j < rows[i].length; j++) {
|
||||
cells.push(DelimitedCodec.escape(rows[i][j], delimiter));
|
||||
}
|
||||
lines.push(cells.join(delimiter));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 解析分隔文本为二维数组;空文本返回空数组。 */
|
||||
static parse(text: string, delimiter: string): string[][] {
|
||||
const normalized: string = text.replace(new RegExp('\r\n', 'g'), '\n').replace(new RegExp('\r', 'g'), '\n');
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell: string = '';
|
||||
let i: number = 0;
|
||||
let inQuotes: boolean = false;
|
||||
while (i < normalized.length) {
|
||||
const ch: string = normalized.charAt(i);
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (normalized.charAt(i + 1) === '"') {
|
||||
cell += '"';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
inQuotes = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
cell += ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' && cell.length === 0) {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === delimiter) {
|
||||
row.push(cell);
|
||||
cell = '';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = '';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
cell += ch;
|
||||
i++;
|
||||
}
|
||||
if (cell.length > 0 || row.length > 0) {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
static toTsv(rows: string[][]): string {
|
||||
return DelimitedCodec.serialize(rows, '\t');
|
||||
}
|
||||
|
||||
static parseTsv(text: string): string[][] {
|
||||
return DelimitedCodec.parse(text, '\t');
|
||||
}
|
||||
|
||||
static toCsv(rows: string[][]): string {
|
||||
return DelimitedCodec.serialize(rows, ',');
|
||||
}
|
||||
|
||||
static parseCsv(text: string): string[][] {
|
||||
return DelimitedCodec.parse(text, ',');
|
||||
}
|
||||
|
||||
private static escape(value: string, delimiter: string): string {
|
||||
const needsQuote: boolean = value.indexOf(delimiter) >= 0 || value.indexOf('"') >= 0 ||
|
||||
value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0;
|
||||
if (!needsQuote) {
|
||||
return value;
|
||||
}
|
||||
return '"' + value.replace(new RegExp('"', 'g'), '""') + '"';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 字段映射(1.7.5,1.8.9 复用)。
|
||||
*
|
||||
* 把源数据列映射到目标列,支持简单转换(trim/upper/lower)。
|
||||
* 编辑器侧用于「批量字段映射」;导入向导(1.8.1/1.8.9)复用同一模型。
|
||||
*/
|
||||
export interface FieldMapping {
|
||||
source: string;
|
||||
target: string;
|
||||
/** none / trim / upper / lower */
|
||||
transform: string;
|
||||
}
|
||||
|
||||
export class FieldMapper {
|
||||
/** 校验映射;返回错误列表。 */
|
||||
static validate(mappings: FieldMapping[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const sources: Set<string> = new Set<string>();
|
||||
const targets: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < mappings.length; i++) {
|
||||
const mapping: FieldMapping = mappings[i];
|
||||
if (mapping.source.trim().length === 0) {
|
||||
errors.push('source column is required at index ' + i);
|
||||
continue;
|
||||
}
|
||||
if (mapping.target.trim().length === 0) {
|
||||
errors.push('target column is required at index ' + i);
|
||||
}
|
||||
if (sources.has(mapping.source.toLowerCase())) {
|
||||
errors.push('duplicate source: ' + mapping.source);
|
||||
}
|
||||
sources.add(mapping.source.toLowerCase());
|
||||
if (targets.has(mapping.target.toLowerCase())) {
|
||||
errors.push('duplicate target: ' + mapping.target);
|
||||
}
|
||||
targets.add(mapping.target.toLowerCase());
|
||||
if (!FieldMapper.isKnownTransform(mapping.transform)) {
|
||||
errors.push('unknown transform: ' + mapping.transform);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 映射一行数据;未映射的源列被忽略,未出现的映射目标留空。 */
|
||||
static mapRow(row: Map<string, string>, mappings: FieldMapping[]): Map<string, string> {
|
||||
const out: Map<string, string> = new Map<string, string>();
|
||||
for (let i = 0; i < mappings.length; i++) {
|
||||
const mapping: FieldMapping = mappings[i];
|
||||
const sourceKey: string = FieldMapper.lookupKey(row, mapping.source);
|
||||
const raw: string = sourceKey.length === 0 ? '' : (row.get(sourceKey) as string);
|
||||
out.set(mapping.target, FieldMapper.transform(raw, mapping.transform));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 批量映射。 */
|
||||
static mapRows(rows: Map<string, string>[], mappings: FieldMapping[]): Map<string, string>[] {
|
||||
const out: Map<string, string>[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
out.push(FieldMapper.mapRow(rows[i], mappings));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按名称(大小写不敏感)自动建议映射。 */
|
||||
static suggest(sourceColumns: string[], targetColumns: string[]): FieldMapping[] {
|
||||
const out: FieldMapping[] = [];
|
||||
const usedTargets: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < sourceColumns.length; i++) {
|
||||
const source: string = sourceColumns[i].trim();
|
||||
if (source.length === 0) {
|
||||
continue;
|
||||
}
|
||||
for (let j = 0; j < targetColumns.length; j++) {
|
||||
const target: string = targetColumns[j].trim();
|
||||
if (target.length === 0 || usedTargets.has(target.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
if (source.toLowerCase() === target.toLowerCase()) {
|
||||
out.push({ source: source, target: target, transform: 'none' });
|
||||
usedTargets.add(target.toLowerCase());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static transform(value: string, transform: string): string {
|
||||
switch (transform.trim().toLowerCase()) {
|
||||
case 'trim':
|
||||
return value.trim();
|
||||
case 'upper':
|
||||
return value.toUpperCase();
|
||||
case 'lower':
|
||||
return value.toLowerCase();
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private static isKnownTransform(transform: string): boolean {
|
||||
const key: string = transform.trim().toLowerCase();
|
||||
return key === '' || key === 'none' || key === 'trim' || key === 'upper' || key === 'lower';
|
||||
}
|
||||
|
||||
private static lookupKey(row: Map<string, string>, source: string): string {
|
||||
if (row.has(source)) {
|
||||
return source;
|
||||
}
|
||||
const lower: string = source.toLowerCase();
|
||||
let found: string = '';
|
||||
row.forEach((value: string, key: string) => {
|
||||
if (found.length === 0 && key.toLowerCase() === lower) {
|
||||
found = key;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 筛选与排序条件构建(1.7.3)。
|
||||
*
|
||||
* 把界面上的筛选条件(分组嵌套 AND/OR)与排序规则翻译为 SQL 片段。
|
||||
* 值一律按字面量转义(单引号双写、拒绝 NUL),避免条件注入。
|
||||
*
|
||||
* 说明:不做列名合法性校验(列名来自元数据);如需支持表达式列,由调用方自行保证。
|
||||
*/
|
||||
export enum FilterOperator {
|
||||
EQ = 0,
|
||||
NE = 1,
|
||||
GT = 2,
|
||||
GE = 3,
|
||||
LT = 4,
|
||||
LE = 5,
|
||||
LIKE = 6,
|
||||
IN = 7,
|
||||
IS_NULL = 8,
|
||||
IS_NOT_NULL = 9
|
||||
}
|
||||
|
||||
export enum FilterLogic {
|
||||
AND = 0,
|
||||
OR = 1
|
||||
}
|
||||
|
||||
export interface FilterCondition {
|
||||
column: string;
|
||||
op: FilterOperator;
|
||||
/** 值;IN 时用逗号分隔多个值,IS NULL 时忽略。 */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FilterGroup {
|
||||
logic: FilterLogic;
|
||||
conditions: FilterCondition[];
|
||||
children: FilterGroup[];
|
||||
}
|
||||
|
||||
export interface SortSpec {
|
||||
column: string;
|
||||
/** true 为升序。 */
|
||||
ascending: boolean;
|
||||
}
|
||||
|
||||
export class FilterBuilder {
|
||||
/** 构建 WHERE 片段;无有效条件时返回空字符串。 */
|
||||
static toSql(group: FilterGroup): string {
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < group.conditions.length; i++) {
|
||||
const condition: FilterCondition = group.conditions[i];
|
||||
if (!FilterBuilder.isValid(condition)) {
|
||||
continue;
|
||||
}
|
||||
parts.push(FilterBuilder.conditionSql(condition));
|
||||
}
|
||||
for (let i = 0; i < group.children.length; i++) {
|
||||
const child: string = FilterBuilder.toSql(group.children[i]);
|
||||
if (child.length > 0) {
|
||||
parts.push('(' + child + ')');
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const joiner: string = group.logic === FilterLogic.AND ? ' AND ' : ' OR ';
|
||||
return parts.join(joiner);
|
||||
}
|
||||
|
||||
/** 完整 WHERE 子句(含前缀);无有效条件返回空字符串。 */
|
||||
static whereClause(group: FilterGroup): string {
|
||||
const body: string = FilterBuilder.toSql(group);
|
||||
return body.length === 0 ? '' : 'WHERE ' + body;
|
||||
}
|
||||
|
||||
/** 排序子句;无有效排序返回空字符串。 */
|
||||
static orderByClause(sorts: SortSpec[]): string {
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < sorts.length; i++) {
|
||||
const column: string = sorts[i].column.trim();
|
||||
if (column.length === 0) {
|
||||
continue;
|
||||
}
|
||||
parts.push(column + (sorts[i].ascending ? ' ASC' : ' DESC'));
|
||||
}
|
||||
return parts.length === 0 ? '' : 'ORDER BY ' + parts.join(', ');
|
||||
}
|
||||
|
||||
/** 条件校验。 */
|
||||
static validate(condition: FilterCondition): string[] {
|
||||
const errors: string[] = [];
|
||||
if (condition.column.trim().length === 0) {
|
||||
errors.push('column is required');
|
||||
}
|
||||
if (condition.op !== FilterOperator.IS_NULL && condition.op !== FilterOperator.IS_NOT_NULL &&
|
||||
condition.op !== FilterOperator.IN && condition.value.length === 0) {
|
||||
errors.push('value is required');
|
||||
}
|
||||
if (condition.op === FilterOperator.IN && FilterBuilder.splitValues(condition.value).length === 0) {
|
||||
errors.push('IN requires at least one value');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static isValid(condition: FilterCondition): boolean {
|
||||
return FilterBuilder.validate(condition).length === 0;
|
||||
}
|
||||
|
||||
private static conditionSql(condition: FilterCondition): string {
|
||||
const column: string = condition.column.trim();
|
||||
switch (condition.op) {
|
||||
case FilterOperator.EQ:
|
||||
return column + ' = ' + FilterBuilder.literal(condition.value);
|
||||
case FilterOperator.NE:
|
||||
return column + ' <> ' + FilterBuilder.literal(condition.value);
|
||||
case FilterOperator.GT:
|
||||
return column + ' > ' + FilterBuilder.literal(condition.value);
|
||||
case FilterOperator.GE:
|
||||
return column + ' >= ' + FilterBuilder.literal(condition.value);
|
||||
case FilterOperator.LT:
|
||||
return column + ' < ' + FilterBuilder.literal(condition.value);
|
||||
case FilterOperator.LE:
|
||||
return column + ' <= ' + FilterBuilder.literal(condition.value);
|
||||
case FilterOperator.LIKE:
|
||||
return column + ' LIKE ' + FilterBuilder.literal(condition.value);
|
||||
case FilterOperator.IN: {
|
||||
const values: string[] = FilterBuilder.splitValues(condition.value);
|
||||
const quoted: string[] = [];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
quoted.push(FilterBuilder.literal(values[i]));
|
||||
}
|
||||
return column + ' IN (' + quoted.join(', ') + ')';
|
||||
}
|
||||
case FilterOperator.IS_NULL:
|
||||
return column + ' IS NULL';
|
||||
default:
|
||||
return column + ' IS NOT NULL';
|
||||
}
|
||||
}
|
||||
|
||||
/** IN 列表拆分(支持逗号分隔,去空白,忽略空项)。 */
|
||||
static splitValues(text: string): string[] {
|
||||
const raw: string[] = text.split(',');
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const value: string = raw[i].trim();
|
||||
if (value.length > 0) {
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static literal(value: string): string {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) === 0) {
|
||||
throw new Error('filter value contains NUL character');
|
||||
}
|
||||
}
|
||||
return '\'' + value.replace(new RegExp('\'', 'g'), '\'\'') + '\'';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 表单视图模型(1.7.2)。
|
||||
*
|
||||
* 单行记录的表单编辑:字段定义(可编辑/只读/必填),值的脏检测与回滚,
|
||||
* 提交时产出 CellChange 交给 TableEditor 生成 UPDATE。
|
||||
*/
|
||||
import { CellChange } from './TableEditor';
|
||||
|
||||
export interface FieldSpec {
|
||||
column: string;
|
||||
label: string;
|
||||
editable: boolean;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export class FormViewModel {
|
||||
private readonly fields: FieldSpec[];
|
||||
private original: Map<string, string> = new Map<string, string>();
|
||||
private current: Map<string, string> = new Map<string, string>();
|
||||
|
||||
constructor(fields: FieldSpec[]) {
|
||||
this.fields = fields.slice();
|
||||
}
|
||||
|
||||
fields_(): FieldSpec[] {
|
||||
return this.fields.slice();
|
||||
}
|
||||
|
||||
/** 载入一行数据(重置脏状态)。 */
|
||||
load(values: Map<string, string>): void {
|
||||
this.original = new Map<string, string>();
|
||||
this.current = new Map<string, string>();
|
||||
for (let i = 0; i < this.fields.length; i++) {
|
||||
const column: string = this.fields[i].column;
|
||||
const value: string | undefined = values.get(column);
|
||||
this.original.set(column, value === undefined ? '' : value);
|
||||
this.current.set(column, value === undefined ? '' : value);
|
||||
}
|
||||
}
|
||||
|
||||
get(column: string): string {
|
||||
const value: string | undefined = this.current.get(column);
|
||||
return value === undefined ? '' : value;
|
||||
}
|
||||
|
||||
/** 修改值;只读列或未知列忽略。 */
|
||||
set(column: string, value: string): boolean {
|
||||
const field: FieldSpec | undefined = this.fieldOf(column);
|
||||
if (field === undefined || !field.editable) {
|
||||
return false;
|
||||
}
|
||||
this.current.set(column, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
isEditable(column: string): boolean {
|
||||
const field: FieldSpec | undefined = this.fieldOf(column);
|
||||
return field !== undefined && field.editable;
|
||||
}
|
||||
|
||||
/** 脏字段(值发生过变化)。 */
|
||||
dirtyFields(): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < this.fields.length; i++) {
|
||||
const column: string = this.fields[i].column;
|
||||
if (this.get(column) !== this.originalOf(column)) {
|
||||
out.push(column);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
isDirty(): boolean {
|
||||
return this.dirtyFields().length > 0;
|
||||
}
|
||||
|
||||
/** 回滚到载入时的值。 */
|
||||
revert(): void {
|
||||
this.current = new Map<string, string>();
|
||||
this.original.forEach((value: string, column: string) => {
|
||||
this.current.set(column, value);
|
||||
});
|
||||
}
|
||||
|
||||
/** 必填校验;返回错误列与消息。 */
|
||||
validate(): string[] {
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < this.fields.length; i++) {
|
||||
const field: FieldSpec = this.fields[i];
|
||||
if (field.required && this.get(field.column).trim().length === 0) {
|
||||
errors.push(field.column + ' is required');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 产出变更(供 TableEditor 提交);必填不通过时返回空数组。 */
|
||||
changes(): CellChange[] {
|
||||
if (this.validate().length > 0) {
|
||||
return [];
|
||||
}
|
||||
const out: CellChange[] = [];
|
||||
const dirty: string[] = this.dirtyFields();
|
||||
for (let i = 0; i < dirty.length; i++) {
|
||||
out.push({
|
||||
rowIndex: 0,
|
||||
column: dirty[i],
|
||||
oldValue: this.originalOf(dirty[i]),
|
||||
newValue: this.get(dirty[i])
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private originalOf(column: string): string {
|
||||
const value: string | undefined = this.original.get(column);
|
||||
return value === undefined ? '' : value;
|
||||
}
|
||||
|
||||
private fieldOf(column: string): FieldSpec | undefined {
|
||||
for (let i = 0; i < this.fields.length; i++) {
|
||||
if (this.fields[i].column === column) {
|
||||
return this.fields[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 数据网格内核(1.7.1)。
|
||||
*
|
||||
* 虚拟化网格的状态与行为模型(不含 ArkUI 渲染):
|
||||
* - 列定义与行数;
|
||||
* - 可视行窗口(按滚动位置与行高计算);
|
||||
* - 选择(单选/范围/多选);
|
||||
* - 编辑态(当前编辑单元格与草稿值);
|
||||
* - 选区复制为 TSV。
|
||||
*
|
||||
* 与 1.2.10 的数据网格内核设计一致;UI 层只负责把可见行渲染出来。
|
||||
*/
|
||||
export interface GridColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface VisibleRange {
|
||||
first: number;
|
||||
last: number;
|
||||
}
|
||||
|
||||
export class GridModel {
|
||||
private readonly columns: GridColumn[];
|
||||
private readonly rowCount: number;
|
||||
private readonly selected: Set<number> = new Set<number>();
|
||||
private editRow: number = -1;
|
||||
private editColumn: number = -1;
|
||||
private editText: string = '';
|
||||
|
||||
constructor(columns: GridColumn[], rowCount: number) {
|
||||
this.columns = columns;
|
||||
this.rowCount = rowCount < 0 ? 0 : rowCount;
|
||||
}
|
||||
|
||||
columnCount(): number {
|
||||
return this.columns.length;
|
||||
}
|
||||
|
||||
rows(): number {
|
||||
return this.rowCount;
|
||||
}
|
||||
|
||||
columnAt(index: number): GridColumn | undefined {
|
||||
return index >= 0 && index < this.columns.length ? this.columns[index] : undefined;
|
||||
}
|
||||
|
||||
/** 可视行窗口:首行与末行(含),越界裁剪。 */
|
||||
visibleRange(scrollTop: number, viewportHeight: number, rowHeight: number): VisibleRange {
|
||||
if (rowHeight <= 0 || this.rowCount === 0) {
|
||||
return { first: 0, last: -1 };
|
||||
}
|
||||
const firstRaw: number = Math.floor(Math.max(0, scrollTop) / rowHeight);
|
||||
const visibleCount: number = Math.ceil(Math.max(0, viewportHeight) / rowHeight);
|
||||
const first: number = firstRaw >= this.rowCount ? this.rowCount - 1 : firstRaw;
|
||||
const last: number = Math.min(this.rowCount - 1, first + visibleCount - 1);
|
||||
return { first: first, last: last };
|
||||
}
|
||||
|
||||
/** 单选。 */
|
||||
select(row: number): void {
|
||||
if (row < 0 || row >= this.rowCount) {
|
||||
return;
|
||||
}
|
||||
this.selected.clear();
|
||||
this.selected.add(row);
|
||||
}
|
||||
|
||||
/** 追加/取消选择(多选)。 */
|
||||
toggle(row: number): void {
|
||||
if (row < 0 || row >= this.rowCount) {
|
||||
return;
|
||||
}
|
||||
if (this.selected.has(row)) {
|
||||
this.selected.delete(row);
|
||||
} else {
|
||||
this.selected.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** 范围选择(含首尾)。 */
|
||||
selectRange(start: number, end: number): void {
|
||||
if (this.rowCount === 0) {
|
||||
return;
|
||||
}
|
||||
const lower: number = Math.max(0, Math.min(start, end));
|
||||
const upper: number = Math.min(this.rowCount - 1, Math.max(start, end));
|
||||
this.selected.clear();
|
||||
for (let row = lower; row <= upper; row++) {
|
||||
this.selected.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selected.clear();
|
||||
}
|
||||
|
||||
isSelected(row: number): boolean {
|
||||
return this.selected.has(row);
|
||||
}
|
||||
|
||||
selectedRows(): number[] {
|
||||
const out: number[] = [];
|
||||
this.selected.forEach((row: number) => {
|
||||
out.push(row);
|
||||
});
|
||||
out.sort((a: number, b: number) => a - b);
|
||||
return out;
|
||||
}
|
||||
|
||||
selectionSize(): number {
|
||||
return this.selected.size;
|
||||
}
|
||||
|
||||
/** 全部选中。 */
|
||||
selectAll(): void {
|
||||
this.selected.clear();
|
||||
for (let row = 0; row < this.rowCount; row++) {
|
||||
this.selected.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 编辑态 ----
|
||||
|
||||
beginEdit(row: number, column: number, initialText: string): void {
|
||||
if (row < 0 || row >= this.rowCount || column < 0 || column >= this.columns.length) {
|
||||
return;
|
||||
}
|
||||
this.editRow = row;
|
||||
this.editColumn = column;
|
||||
this.editText = initialText;
|
||||
}
|
||||
|
||||
isEditing(): boolean {
|
||||
return this.editRow >= 0;
|
||||
}
|
||||
|
||||
editingRow(): number {
|
||||
return this.editRow;
|
||||
}
|
||||
|
||||
editingColumn(): number {
|
||||
return this.editColumn;
|
||||
}
|
||||
|
||||
editingText(): string {
|
||||
return this.editText;
|
||||
}
|
||||
|
||||
updateEditText(text: string): void {
|
||||
if (this.editRow >= 0) {
|
||||
this.editText = text;
|
||||
}
|
||||
}
|
||||
|
||||
/** 结束编辑,返回 {row, column, text};未在编辑时返回 undefined。 */
|
||||
endEdit(): EditResult | undefined {
|
||||
if (this.editRow < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const result: EditResult = { row: this.editRow, column: this.editColumn, text: this.editText };
|
||||
this.editRow = -1;
|
||||
this.editColumn = -1;
|
||||
this.editText = '';
|
||||
return result;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editRow = -1;
|
||||
this.editColumn = -1;
|
||||
this.editText = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 选区复制为 TSV(选中行按行号升序,列按定义顺序)。
|
||||
* 行内容由调用方提供(getRow 回调),避免内核持有全量数据。
|
||||
*/
|
||||
copySelection(getRow: (row: number) => string[]): string {
|
||||
const rows: number[] = this.selectedRows();
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const cells: string[] = getRow(rows[i]);
|
||||
lines.push(cells.join('\t'));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export interface EditResult {
|
||||
row: number;
|
||||
column: number;
|
||||
text: string;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 快捷键与键位映射(1.7.12)。
|
||||
*
|
||||
* 统一键位规范:修饰键按 Ctrl+Alt+Shift 顺序规范化,按键大写;
|
||||
* 平台(PC/平板外接键盘)可分别注册;同一组合冲突时后注册者覆盖先注册者。
|
||||
*/
|
||||
export enum InputDevice {
|
||||
KEYBOARD = 0,
|
||||
MOUSE = 1,
|
||||
TOUCH = 2
|
||||
}
|
||||
|
||||
export enum KeyboardPlatform {
|
||||
PC = 0,
|
||||
TABLET = 1,
|
||||
BOTH = 2
|
||||
}
|
||||
|
||||
export interface KeyBinding {
|
||||
/** 动作标识(与命令系统一致)。 */
|
||||
action: string;
|
||||
/** 规范化组合键,如 Ctrl+Enter。 */
|
||||
combo: string;
|
||||
platform: KeyboardPlatform;
|
||||
/** 界面展示文案。 */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export class KeymapRegistry {
|
||||
private readonly bindings: KeyBinding[] = [];
|
||||
|
||||
constructor() {
|
||||
const defaults: KeyBinding[] = KeymapRegistry.defaults();
|
||||
for (let i = 0; i < defaults.length; i++) {
|
||||
this.bindings.push(defaults[i]);
|
||||
}
|
||||
}
|
||||
|
||||
static defaults(): KeyBinding[] {
|
||||
return [
|
||||
{ action: 'executeAll', combo: 'Ctrl+Enter', platform: KeyboardPlatform.BOTH, label: '执行全部' },
|
||||
{ action: 'executeSelection', combo: 'Ctrl+Shift+Enter', platform: KeyboardPlatform.BOTH, label: '执行选中' },
|
||||
{ action: 'newTab', combo: 'Ctrl+T', platform: KeyboardPlatform.BOTH, label: '新建标签' },
|
||||
{ action: 'closeTab', combo: 'Ctrl+W', platform: KeyboardPlatform.BOTH, label: '关闭标签' },
|
||||
{ action: 'formatSql', combo: 'Ctrl+Shift+F', platform: KeyboardPlatform.BOTH, label: '格式化' },
|
||||
{ action: 'find', combo: 'Ctrl+F', platform: KeyboardPlatform.BOTH, label: '查找' },
|
||||
{ action: 'cancel', combo: 'Esc', platform: KeyboardPlatform.BOTH, label: '取消执行' },
|
||||
{ action: 'copySelection', combo: 'Ctrl+C', platform: KeyboardPlatform.PC, label: '复制选区' },
|
||||
{ action: 'paste', combo: 'Ctrl+V', platform: KeyboardPlatform.PC, label: '粘贴' },
|
||||
{ action: 'deleteRows', combo: 'Delete', platform: KeyboardPlatform.PC, label: '删除行' },
|
||||
{ action: 'contextMenu', combo: 'Shift+F10', platform: KeyboardPlatform.PC, label: '上下文菜单' },
|
||||
{ action: 'selectAll', combo: 'Ctrl+A', platform: KeyboardPlatform.BOTH, label: '全选' }
|
||||
];
|
||||
}
|
||||
|
||||
/** 规范化组合键:修饰键顺序固定,字母键大写。 */
|
||||
static normalize(combo: string): string {
|
||||
const parts: string[] = combo.split('+');
|
||||
let ctrl: boolean = false;
|
||||
let alt: boolean = false;
|
||||
let shift: boolean = false;
|
||||
const rest: string[] = [];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const token: string = parts[i].trim();
|
||||
const lower: string = token.toLowerCase();
|
||||
if (lower === 'ctrl' || lower === 'control') {
|
||||
ctrl = true;
|
||||
} else if (lower === 'alt') {
|
||||
alt = true;
|
||||
} else if (lower === 'shift') {
|
||||
shift = true;
|
||||
} else if (lower.length > 0) {
|
||||
rest.push(token.length === 1 ? token.toUpperCase() : KeymapRegistry.capitalize(lower));
|
||||
}
|
||||
}
|
||||
const out: string[] = [];
|
||||
if (ctrl) {
|
||||
out.push('Ctrl');
|
||||
}
|
||||
if (alt) {
|
||||
out.push('Alt');
|
||||
}
|
||||
if (shift) {
|
||||
out.push('Shift');
|
||||
}
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
out.push(rest[i]);
|
||||
}
|
||||
return out.join('+');
|
||||
}
|
||||
|
||||
/** 注册(覆盖同平台同组合)。 */
|
||||
register(binding: KeyBinding): void {
|
||||
const combo: string = KeymapRegistry.normalize(binding.combo);
|
||||
for (let i = 0; i < this.bindings.length; i++) {
|
||||
if (this.bindings[i].platform === binding.platform && this.bindings[i].combo === combo) {
|
||||
this.bindings.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.bindings.push({
|
||||
action: binding.action,
|
||||
combo: combo,
|
||||
platform: binding.platform,
|
||||
label: binding.label
|
||||
});
|
||||
}
|
||||
|
||||
/** 按组合键解析动作;未命中返回 undefined。 */
|
||||
resolve(combo: string): string | undefined {
|
||||
const normalized: string = KeymapRegistry.normalize(combo);
|
||||
for (let i = 0; i < this.bindings.length; i++) {
|
||||
if (this.bindings[i].combo === normalized) {
|
||||
return this.bindings[i].action;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 某平台的可用绑定(BOTH 始终包含)。 */
|
||||
forPlatform(platform: KeyboardPlatform): KeyBinding[] {
|
||||
const out: KeyBinding[] = [];
|
||||
for (let i = 0; i < this.bindings.length; i++) {
|
||||
if (this.bindings[i].platform === platform || this.bindings[i].platform === KeyboardPlatform.BOTH) {
|
||||
out.push(this.bindings[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
all(): KeyBinding[] {
|
||||
return this.bindings.slice();
|
||||
}
|
||||
|
||||
private static capitalize(text: string): string {
|
||||
if (text.length === 0) {
|
||||
return text;
|
||||
}
|
||||
const first: string = text.charAt(0).toUpperCase();
|
||||
return first + text.substring(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 大字段(LOB)分块缓冲(1.7.7)。
|
||||
*
|
||||
* 大文本/二进制按固定大小分块加载与编辑:
|
||||
* - 只请求可视范围内的块,避免一次性读取整列;
|
||||
* - 记录已加载块,read 拼接时若跨未加载块则抛错(提示先加载)。
|
||||
*/
|
||||
export class LobBuffer {
|
||||
private readonly totalSize: number;
|
||||
private readonly chunkSize: number;
|
||||
private readonly loaded: Map<number, string> = new Map<number, string>();
|
||||
|
||||
constructor(totalSize: number, chunkSize: number) {
|
||||
if (chunkSize < 1) {
|
||||
throw new Error('chunkSize must be >= 1');
|
||||
}
|
||||
this.totalSize = totalSize < 0 ? 0 : totalSize;
|
||||
this.chunkSize = chunkSize;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.totalSize;
|
||||
}
|
||||
|
||||
chunkCount(): number {
|
||||
return Math.ceil(this.totalSize / this.chunkSize);
|
||||
}
|
||||
|
||||
chunkLength(index: number): number {
|
||||
if (index < 0 || index >= this.chunkCount()) {
|
||||
return 0;
|
||||
}
|
||||
const start: number = index * this.chunkSize;
|
||||
const remaining: number = this.totalSize - start;
|
||||
return remaining < this.chunkSize ? remaining : this.chunkSize;
|
||||
}
|
||||
|
||||
/** 覆盖 [offset, offset+length) 需要的块号。 */
|
||||
chunksFor(offset: number, length: number): number[] {
|
||||
if (this.totalSize === 0 || length <= 0) {
|
||||
return [];
|
||||
}
|
||||
const start: number = offset < 0 ? 0 : offset;
|
||||
const end: number = Math.min(this.totalSize, start + length);
|
||||
if (end <= start) {
|
||||
return [];
|
||||
}
|
||||
const first: number = Math.floor(start / this.chunkSize);
|
||||
const last: number = Math.floor((end - 1) / this.chunkSize);
|
||||
const out: number[] = [];
|
||||
for (let index = first; index <= last; index++) {
|
||||
out.push(index);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
setChunk(index: number, data: string): void {
|
||||
if (index < 0 || index >= this.chunkCount()) {
|
||||
return;
|
||||
}
|
||||
this.loaded.set(index, data);
|
||||
}
|
||||
|
||||
isLoaded(index: number): boolean {
|
||||
return this.loaded.has(index);
|
||||
}
|
||||
|
||||
loadedChunks(): number {
|
||||
return this.loaded.size;
|
||||
}
|
||||
|
||||
loadedBytes(): number {
|
||||
let bytes: number = 0;
|
||||
this.loaded.forEach((data: string, index: number) => {
|
||||
bytes += data.length;
|
||||
});
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** 是否需要加载(覆盖范围内有未加载块)。 */
|
||||
needsLoad(offset: number, length: number): boolean {
|
||||
const chunks: number[] = this.chunksFor(offset, length);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
if (!this.loaded.has(chunks[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 读取区间;跨未加载块时抛错。 */
|
||||
read(offset: number, length: number): string {
|
||||
const start: number = offset < 0 ? 0 : offset;
|
||||
const end: number = Math.min(this.totalSize, start + length);
|
||||
if (end <= start) {
|
||||
return '';
|
||||
}
|
||||
const chunks: number[] = this.chunksFor(start, end - start);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
if (!this.loaded.has(chunks[i])) {
|
||||
throw new Error('chunk ' + chunks[i] + ' not loaded');
|
||||
}
|
||||
}
|
||||
let out: string = '';
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
out += this.loaded.get(chunks[i]) as string;
|
||||
}
|
||||
const sliceStart: number = start - chunks[0] * this.chunkSize;
|
||||
const sliceEnd: number = sliceStart + (end - start);
|
||||
return out.substring(sliceStart, sliceEnd);
|
||||
}
|
||||
|
||||
/** 全部块已加载。 */
|
||||
isComplete(): boolean {
|
||||
return this.chunkCount() === 0 || this.loaded.size === this.chunkCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 单元格渲染器注册表(1.7.10)。
|
||||
*
|
||||
* 列类型 → 渲染器;支持自定义渲染器按优先级覆盖默认映射。
|
||||
* 渲染器只描述「用哪种视图」,实际绘制由 UI 层按 kind 分发。
|
||||
*/
|
||||
export interface RendererSpec {
|
||||
/** 渲染器标识,如 number/boolean/json/binary/date/text/link/color。 */
|
||||
kind: string;
|
||||
/** 显示名。 */
|
||||
label: string;
|
||||
/** 优先级:数值大者优先(自定义高于默认)。 */
|
||||
priority: number;
|
||||
/** 适用的列类型(小写),如 int、varchar、json、blob、date。 */
|
||||
columnTypes: string[];
|
||||
}
|
||||
|
||||
export class RendererRegistry {
|
||||
private readonly specs: RendererSpec[] = [];
|
||||
|
||||
constructor() {
|
||||
const defaults: RendererSpec[] = [
|
||||
{ kind: 'number', label: '数字', priority: 0, columnTypes: ['int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real'] },
|
||||
{ kind: 'boolean', label: '布尔', priority: 0, columnTypes: ['boolean', 'bool', 'tinyint(1)'] },
|
||||
{ kind: 'json', label: 'JSON', priority: 0, columnTypes: ['json', 'jsonb'] },
|
||||
{ kind: 'binary', label: '二进制', priority: 0, columnTypes: ['blob', 'bytea', 'varbinary', 'binary'] },
|
||||
{ kind: 'date', label: '日期', priority: 0, columnTypes: ['date', 'datetime', 'timestamp', 'time'] },
|
||||
{ kind: 'link', label: '链接', priority: 0, columnTypes: ['url', 'link'] },
|
||||
{ kind: 'text', label: '文本', priority: -1, columnTypes: ['char', 'varchar', 'text', 'clob', 'nvarchar'] }
|
||||
];
|
||||
for (let i = 0; i < defaults.length; i++) {
|
||||
this.specs.push(defaults[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册自定义渲染器;同 kind 覆盖。 */
|
||||
register(spec: RendererSpec): void {
|
||||
for (let i = 0; i < this.specs.length; i++) {
|
||||
if (this.specs[i].kind === spec.kind) {
|
||||
this.specs.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.specs.push(spec);
|
||||
}
|
||||
|
||||
/** 按列类型解析渲染器;未命中返回 text。 */
|
||||
resolve(columnType: string): string {
|
||||
const type: string = columnType.trim().toLowerCase();
|
||||
let best: RendererSpec | undefined = undefined;
|
||||
for (let i = 0; i < this.specs.length; i++) {
|
||||
const spec: RendererSpec = this.specs[i];
|
||||
if (spec.columnTypes.indexOf(type) < 0) {
|
||||
continue;
|
||||
}
|
||||
if (best === undefined || spec.priority > best.priority) {
|
||||
best = spec;
|
||||
}
|
||||
}
|
||||
return best === undefined ? 'text' : best.kind;
|
||||
}
|
||||
|
||||
/** 全部候选(按优先级降序)。 */
|
||||
candidates(columnType: string): RendererSpec[] {
|
||||
const type: string = columnType.trim().toLowerCase();
|
||||
const out: RendererSpec[] = [];
|
||||
for (let i = 0; i < this.specs.length; i++) {
|
||||
if (this.specs[i].columnTypes.indexOf(type) >= 0) {
|
||||
out.push(this.specs[i]);
|
||||
}
|
||||
}
|
||||
out.sort((a: RendererSpec, b: RendererSpec) => b.priority - a.priority);
|
||||
return out;
|
||||
}
|
||||
|
||||
list(): RendererSpec[] {
|
||||
return this.specs.slice();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 行内编辑与事务化提交(1.7.6)。
|
||||
*
|
||||
* 编辑模型:
|
||||
* - 单元格修改先进入「待提交变更集」,按行聚合;
|
||||
* - 提交时按主键生成 UPDATE 语句;主键缺失或变更集为空时拒绝提交(避免全表更新);
|
||||
* - 支持按行回滚与全部回滚。
|
||||
*
|
||||
* 安全约束(显式):
|
||||
* - 没有主键值的行不允许提交,validate() 给出错误;
|
||||
* - 值按字面量转义并拒绝 NUL;
|
||||
* - 只更新发生变更的列。
|
||||
*/
|
||||
import { TableDef } from '../schema/TableModel';
|
||||
import { DdlGenerator, SqlDialect } from '../schema/DdlGenerator';
|
||||
|
||||
export interface CellChange {
|
||||
rowIndex: number;
|
||||
column: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}
|
||||
|
||||
export interface KeyPart {
|
||||
column: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface CommitResult {
|
||||
statements: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class TableEditor {
|
||||
private readonly changes: Map<number, Map<string, CellChange>> = new Map<number, Map<string, CellChange>>();
|
||||
private readonly rowKeys: Map<number, KeyPart[]> = new Map<number, KeyPart[]>();
|
||||
|
||||
/** 记录单元格修改;同一行同一列重复修改保留最初的 oldValue。 */
|
||||
setCell(rowIndex: number, column: string, oldValue: string, newValue: string): void {
|
||||
let rowChanges: Map<string, CellChange> | undefined = this.changes.get(rowIndex);
|
||||
if (rowChanges === undefined) {
|
||||
rowChanges = new Map<string, CellChange>();
|
||||
this.changes.set(rowIndex, rowChanges);
|
||||
}
|
||||
const existing: CellChange | undefined = rowChanges.get(column);
|
||||
const originalOld: string = existing === undefined ? oldValue : existing.oldValue;
|
||||
if (originalOld === newValue) {
|
||||
rowChanges.delete(column);
|
||||
} else {
|
||||
rowChanges.set(column, {
|
||||
rowIndex: rowIndex,
|
||||
column: column,
|
||||
oldValue: originalOld,
|
||||
newValue: newValue
|
||||
});
|
||||
}
|
||||
if (rowChanges.size === 0) {
|
||||
this.changes.delete(rowIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录行的主键值(提交 UPDATE 的条件)。 */
|
||||
setRowKey(rowIndex: number, keys: KeyPart[]): void {
|
||||
this.rowKeys.set(rowIndex, keys.slice());
|
||||
}
|
||||
|
||||
/** 某行的变更(列名升序,保证输出稳定)。 */
|
||||
changesFor(rowIndex: number): CellChange[] {
|
||||
const rowChanges: Map<string, CellChange> | undefined = this.changes.get(rowIndex);
|
||||
if (rowChanges === undefined) {
|
||||
return [];
|
||||
}
|
||||
const out: CellChange[] = [];
|
||||
rowChanges.forEach((change: CellChange) => {
|
||||
out.push(change);
|
||||
});
|
||||
out.sort((a: CellChange, b: CellChange) => a.column.localeCompare(b.column));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 全部变更(按行号升序)。 */
|
||||
allChanges(): CellChange[] {
|
||||
const rows: number[] = this.dirtyRows();
|
||||
const out: CellChange[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const changes: CellChange[] = this.changesFor(rows[i]);
|
||||
for (let j = 0; j < changes.length; j++) {
|
||||
out.push(changes[j]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
dirtyRows(): number[] {
|
||||
const out: number[] = [];
|
||||
this.changes.forEach((value: Map<string, CellChange>, row: number) => {
|
||||
out.push(row);
|
||||
});
|
||||
out.sort((a: number, b: number) => a - b);
|
||||
return out;
|
||||
}
|
||||
|
||||
isDirty(rowIndex: number): boolean {
|
||||
return this.changes.has(rowIndex);
|
||||
}
|
||||
|
||||
changeCount(): number {
|
||||
let count: number = 0;
|
||||
this.changes.forEach((rowChanges: Map<string, CellChange>) => {
|
||||
count += rowChanges.size;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
/** 回滚某行的全部修改。 */
|
||||
revertRow(rowIndex: number): void {
|
||||
this.changes.delete(rowIndex);
|
||||
}
|
||||
|
||||
/** 回滚全部修改(主键信息保留,便于重新编辑)。 */
|
||||
revertAll(): void {
|
||||
this.changes.clear();
|
||||
}
|
||||
|
||||
/** 校验是否可以提交;返回错误列表。 */
|
||||
validate(): string[] {
|
||||
const errors: string[] = [];
|
||||
const rows: number[] = this.dirtyRows();
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const keys: KeyPart[] | undefined = this.rowKeys.get(rows[i]);
|
||||
if (keys === undefined || keys.length === 0) {
|
||||
errors.push('row ' + rows[i] + ' has no primary key; refusing to generate UPDATE');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 生成 UPDATE 语句(仅变更列,主键作为条件);有错误时 statements 为空。 */
|
||||
commit(table: TableDef, dialect: SqlDialect): CommitResult {
|
||||
const errors: string[] = this.validate();
|
||||
if (errors.length > 0) {
|
||||
return { statements: [], errors: errors };
|
||||
}
|
||||
if (this.changeCount() === 0) {
|
||||
return { statements: [], errors: [] };
|
||||
}
|
||||
const tableName: string = TableEditor.tableName(table, dialect);
|
||||
const statements: string[] = [];
|
||||
const rows: number[] = this.dirtyRows();
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const changes: CellChange[] = this.changesFor(rows[i]);
|
||||
if (changes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const assignments: string[] = [];
|
||||
for (let j = 0; j < changes.length; j++) {
|
||||
assignments.push(DdlGenerator.quote(changes[j].column, dialect) + ' = ' +
|
||||
TableEditor.literal(changes[j].newValue));
|
||||
}
|
||||
const keys: KeyPart[] = this.rowKeys.get(rows[i]) as KeyPart[];
|
||||
const conditions: string[] = [];
|
||||
for (let j = 0; j < keys.length; j++) {
|
||||
conditions.push(DdlGenerator.quote(keys[j].column, dialect) + ' = ' +
|
||||
TableEditor.literal(keys[j].value));
|
||||
}
|
||||
statements.push('UPDATE ' + tableName + ' SET ' + assignments.join(', ') +
|
||||
' WHERE ' + conditions.join(' AND ') + ';');
|
||||
}
|
||||
return { statements: statements, errors: [] };
|
||||
}
|
||||
|
||||
private static tableName(table: TableDef, dialect: SqlDialect): string {
|
||||
return DdlGenerator.qualifiedName(table.schema, table.name, dialect);
|
||||
}
|
||||
|
||||
private static literal(value: string): string {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) === 0) {
|
||||
throw new Error('value contains NUL character');
|
||||
}
|
||||
}
|
||||
return '\'' + value.replace(new RegExp('\'', 'g'), '\'\'') + '\'';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 数据校验与冲突提示(1.7.11)。
|
||||
*
|
||||
* 两层:
|
||||
* - RowValidator:按列规则(必填/长度/正则)校验单元格与整行;
|
||||
* - ConflictDetector:乐观并发——提交前比对「读取时版本」与「当前库中版本」,
|
||||
* 不一致即判定冲突,UI 提示用户重新加载或覆盖。
|
||||
*/
|
||||
export interface ColumnRule {
|
||||
column: string;
|
||||
required: boolean;
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
/** 正则;为空表示不校验。 */
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export interface ValidationIssue {
|
||||
column: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class RowValidator {
|
||||
/** 校验单元格,返回错误消息(空数组通过)。 */
|
||||
static validateCell(rule: ColumnRule, value: string): string[] {
|
||||
const errors: string[] = [];
|
||||
const text: string = value === undefined || value === null ? '' : value;
|
||||
if (rule.required && text.trim().length === 0) {
|
||||
errors.push(rule.column + ' is required');
|
||||
return errors;
|
||||
}
|
||||
if (text.length > 0 && rule.maxLength > 0 && text.length > rule.maxLength) {
|
||||
errors.push(rule.column + ' exceeds max length ' + rule.maxLength);
|
||||
}
|
||||
if (text.length > 0 && rule.minLength > 0 && text.length < rule.minLength) {
|
||||
errors.push(rule.column + ' is shorter than min length ' + rule.minLength);
|
||||
}
|
||||
if (text.length > 0 && rule.pattern.length > 0 && !new RegExp(rule.pattern).test(text)) {
|
||||
errors.push(rule.column + ' does not match pattern ' + rule.pattern);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 校验整行(values: 列 → 值)。 */
|
||||
static validateRow(values: Map<string, string>, rules: ColumnRule[]): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule: ColumnRule = rules[i];
|
||||
const value: string | undefined = values.get(rule.column);
|
||||
const errors: string[] = RowValidator.validateCell(rule, value === undefined ? '' : value);
|
||||
for (let j = 0; j < errors.length; j++) {
|
||||
issues.push({ column: rule.column, message: errors[j] });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** 便捷:无错误为 true。 */
|
||||
static isValid(values: Map<string, string>, rules: ColumnRule[]): boolean {
|
||||
return RowValidator.validateRow(values, rules).length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConflictInfo {
|
||||
conflict: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class ConflictDetector {
|
||||
/**
|
||||
* 乐观并发检测:读取时版本与提交时版本不同即冲突。
|
||||
* 版本可以是时间戳、行版本号或 updated_at 字符串。
|
||||
*/
|
||||
static detect(fetchedVersion: string, currentVersion: string): ConflictInfo {
|
||||
if (fetchedVersion === currentVersion) {
|
||||
return { conflict: false, reason: '' };
|
||||
}
|
||||
return {
|
||||
conflict: true,
|
||||
reason: 'row changed since load: fetched=' + fetchedVersion + ', current=' + currentVersion
|
||||
};
|
||||
}
|
||||
|
||||
/** 批量检测(行号 → {fetched, current})。 */
|
||||
static detectBatch(pairs: Map<number, string[]>): Map<number, ConflictInfo> {
|
||||
const out: Map<number, ConflictInfo> = new Map<number, ConflictInfo>();
|
||||
pairs.forEach((versions: string[], row: number) => {
|
||||
if (versions.length >= 2) {
|
||||
out.set(row, ConflictDetector.detect(versions[0], versions[1]));
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 批量导出任务队列(1.8.10)。
|
||||
*
|
||||
* 多表/多格式导出编排:任务按加入顺序执行,记录进度与错误;
|
||||
* 支持取消与汇总统计。实际 I/O 由调用方执行后回写状态。
|
||||
*/
|
||||
export enum TaskState {
|
||||
PENDING = 0,
|
||||
RUNNING = 1,
|
||||
SUCCEEDED = 2,
|
||||
FAILED = 3,
|
||||
CANCELLED = 4
|
||||
}
|
||||
|
||||
export interface BatchTask {
|
||||
id: string;
|
||||
label: string;
|
||||
state: TaskState;
|
||||
/** 0–100。 */
|
||||
progress: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface BatchSummary {
|
||||
total: number;
|
||||
pending: number;
|
||||
running: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
cancelled: number;
|
||||
}
|
||||
|
||||
export class BatchQueue {
|
||||
private readonly tasks: BatchTask[] = [];
|
||||
private nextId: number = 1;
|
||||
|
||||
add(label: string): BatchTask {
|
||||
const task: BatchTask = {
|
||||
id: 'task-' + this.nextId,
|
||||
label: label.trim(),
|
||||
state: TaskState.PENDING,
|
||||
progress: 0,
|
||||
error: ''
|
||||
};
|
||||
this.nextId++;
|
||||
this.tasks.push(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
list(): BatchTask[] {
|
||||
return this.tasks.slice();
|
||||
}
|
||||
|
||||
find(id: string): BatchTask | undefined {
|
||||
for (let i = 0; i < this.tasks.length; i++) {
|
||||
if (this.tasks[i].id === id) {
|
||||
return this.tasks[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
start(id: string): boolean {
|
||||
const task: BatchTask | undefined = this.find(id);
|
||||
if (task === undefined || task.state !== TaskState.PENDING) {
|
||||
return false;
|
||||
}
|
||||
task.state = TaskState.RUNNING;
|
||||
task.progress = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
updateProgress(id: string, progress: number): void {
|
||||
const task: BatchTask | undefined = this.find(id);
|
||||
if (task === undefined || task.state !== TaskState.RUNNING) {
|
||||
return;
|
||||
}
|
||||
task.progress = progress < 0 ? 0 : (progress > 100 ? 100 : progress);
|
||||
}
|
||||
|
||||
succeed(id: string): boolean {
|
||||
const task: BatchTask | undefined = this.find(id);
|
||||
if (task === undefined) {
|
||||
return false;
|
||||
}
|
||||
task.state = TaskState.SUCCEEDED;
|
||||
task.progress = 100;
|
||||
task.error = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
fail(id: string, error: string): boolean {
|
||||
const task: BatchTask | undefined = this.find(id);
|
||||
if (task === undefined) {
|
||||
return false;
|
||||
}
|
||||
task.state = TaskState.FAILED;
|
||||
task.error = error;
|
||||
return true;
|
||||
}
|
||||
|
||||
cancel(id: string): boolean {
|
||||
const task: BatchTask | undefined = this.find(id);
|
||||
if (task === undefined || task.state === TaskState.SUCCEEDED || task.state === TaskState.FAILED) {
|
||||
return false;
|
||||
}
|
||||
task.state = TaskState.CANCELLED;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 下一个待执行任务。 */
|
||||
next(): BatchTask | undefined {
|
||||
for (let i = 0; i < this.tasks.length; i++) {
|
||||
if (this.tasks[i].state === TaskState.PENDING) {
|
||||
return this.tasks[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 全部任务进入终态。 */
|
||||
allFinished(): boolean {
|
||||
for (let i = 0; i < this.tasks.length; i++) {
|
||||
const state: TaskState = this.tasks[i].state;
|
||||
if (state === TaskState.PENDING || state === TaskState.RUNNING) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
summary(): BatchSummary {
|
||||
const out: BatchSummary = { total: this.tasks.length, pending: 0, running: 0, succeeded: 0, failed: 0, cancelled: 0 };
|
||||
for (let i = 0; i < this.tasks.length; i++) {
|
||||
switch (this.tasks[i].state) {
|
||||
case TaskState.PENDING:
|
||||
out.pending++;
|
||||
break;
|
||||
case TaskState.RUNNING:
|
||||
out.running++;
|
||||
break;
|
||||
case TaskState.SUCCEEDED:
|
||||
out.succeeded++;
|
||||
break;
|
||||
case TaskState.FAILED:
|
||||
out.failed++;
|
||||
break;
|
||||
default:
|
||||
out.cancelled++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* CSV / Text 导入导出(1.8.2)。
|
||||
*
|
||||
* 在 DelimitedCodec 之上补齐导入导出语义:
|
||||
* - 导出:可选表头、可强制全字段加引号;
|
||||
* - 导入:可选首行表头、列数一致性校验(带行号),返回表头与数据行。
|
||||
*/
|
||||
import { DelimitedCodec } from '../grid/DelimitedCodec';
|
||||
|
||||
export interface CsvOptions {
|
||||
/** 分隔符,默认逗号;文本格式可用 \t。 */
|
||||
delimiter: string;
|
||||
/** 首行是否为表头。 */
|
||||
hasHeader: boolean;
|
||||
/** 导出时所有字段加引号。 */
|
||||
quoteAll: boolean;
|
||||
}
|
||||
|
||||
export interface CsvImportResult {
|
||||
header: string[];
|
||||
rows: string[][];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class CsvHandler {
|
||||
static defaultOptions(): CsvOptions {
|
||||
return { delimiter: ',', hasHeader: true, quoteAll: false };
|
||||
}
|
||||
|
||||
/** 导出为 CSV/文本。 */
|
||||
static exportRows(rows: string[][], options: CsvOptions): string {
|
||||
const delimiter: string = options.delimiter;
|
||||
if (!options.quoteAll) {
|
||||
return DelimitedCodec.serialize(rows, delimiter);
|
||||
}
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const cells: string[] = [];
|
||||
for (let j = 0; j < rows[i].length; j++) {
|
||||
cells.push('"' + rows[i][j].replace(new RegExp('"', 'g'), '""') + '"');
|
||||
}
|
||||
lines.push(cells.join(delimiter));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 导入并校验列数。 */
|
||||
static importText(text: string, options: CsvOptions): CsvImportResult {
|
||||
const parsed: string[][] = DelimitedCodec.parse(text, options.delimiter);
|
||||
const header: string[] = [];
|
||||
const rows: string[][] = [];
|
||||
const errors: string[] = [];
|
||||
let expected: number = -1;
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const line: string[] = parsed[i];
|
||||
if (i === 0 && options.hasHeader) {
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
header.push(line[j].trim());
|
||||
}
|
||||
expected = header.length;
|
||||
continue;
|
||||
}
|
||||
if (expected < 0) {
|
||||
expected = line.length;
|
||||
}
|
||||
if (line.length !== expected) {
|
||||
errors.push('line ' + (i + 1) + ': expected ' + expected + ' columns, got ' + line.length);
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
if (options.hasHeader && header.length === 0) {
|
||||
errors.push('header row is empty');
|
||||
}
|
||||
return { header: header, rows: rows, errors: errors };
|
||||
}
|
||||
|
||||
/** 转成「列名 → 值」的行映射(需有表头)。 */
|
||||
static toMaps(result: CsvImportResult): Map<string, string>[] {
|
||||
const out: Map<string, string>[] = [];
|
||||
for (let i = 0; i < result.rows.length; i++) {
|
||||
const map: Map<string, string> = new Map<string, string>();
|
||||
for (let j = 0; j < result.header.length; j++) {
|
||||
const value: string | undefined = result.rows[i][j];
|
||||
map.set(result.header[j], value === undefined ? '' : value);
|
||||
}
|
||||
out.push(map);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 格式插件注册表(1.8.7)。
|
||||
*
|
||||
* 内置格式(csv/tsv/json/xml/sql/xlsx/html/pdf)与自定义格式统一注册;
|
||||
* 按 id 或扩展名查找,供向导第 1 步与导出菜单使用。
|
||||
*
|
||||
* 说明:注册表只描述能力(能否导入/导出、扩展名、MIME),
|
||||
* 实际编解码由对应 Handler 实现,注册表不做 I/O。
|
||||
*/
|
||||
export interface FormatSpec {
|
||||
id: string;
|
||||
label: string;
|
||||
extensions: string[];
|
||||
mimeType: string;
|
||||
supportsImport: boolean;
|
||||
supportsExport: boolean;
|
||||
}
|
||||
|
||||
export class FormatRegistry {
|
||||
private readonly specs: FormatSpec[] = [];
|
||||
|
||||
constructor() {
|
||||
const builtins: FormatSpec[] = [
|
||||
{ id: 'csv', label: 'CSV', extensions: ['csv'], mimeType: 'text/csv', supportsImport: true, supportsExport: true },
|
||||
{ id: 'tsv', label: 'TSV / 文本', extensions: ['tsv', 'txt'], mimeType: 'text/tab-separated-values', supportsImport: true, supportsExport: true },
|
||||
{ id: 'json', label: 'JSON', extensions: ['json'], mimeType: 'application/json', supportsImport: true, supportsExport: true },
|
||||
{ id: 'xml', label: 'XML', extensions: ['xml'], mimeType: 'application/xml', supportsImport: true, supportsExport: true },
|
||||
{ id: 'sql', label: 'SQL 脚本', extensions: ['sql'], mimeType: 'text/x-sql', supportsImport: false, supportsExport: true },
|
||||
{ id: 'xlsx', label: 'Excel', extensions: ['xlsx'], mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', supportsImport: true, supportsExport: true },
|
||||
{ id: 'html', label: 'HTML', extensions: ['html', 'htm'], mimeType: 'text/html', supportsImport: false, supportsExport: true },
|
||||
{ id: 'pdf', label: 'PDF', extensions: ['pdf'], mimeType: 'application/pdf', supportsImport: false, supportsExport: true }
|
||||
];
|
||||
for (let i = 0; i < builtins.length; i++) {
|
||||
this.specs.push(builtins[i]);
|
||||
}
|
||||
}
|
||||
|
||||
register(spec: FormatSpec): void {
|
||||
for (let i = 0; i < this.specs.length; i++) {
|
||||
if (this.specs[i].id === spec.id) {
|
||||
this.specs.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.specs.push(spec);
|
||||
}
|
||||
|
||||
find(id: string): FormatSpec | undefined {
|
||||
const key: string = id.trim().toLowerCase();
|
||||
for (let i = 0; i < this.specs.length; i++) {
|
||||
if (this.specs[i].id === key) {
|
||||
return this.specs[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 按扩展名查找(默认带点,可省略)。 */
|
||||
forExtension(ext: string): FormatSpec | undefined {
|
||||
let key: string = ext.trim().toLowerCase();
|
||||
if (key.length > 0 && key.charAt(0) === '.') {
|
||||
key = key.substring(1);
|
||||
}
|
||||
for (let i = 0; i < this.specs.length; i++) {
|
||||
if (this.specs[i].extensions.indexOf(key) >= 0) {
|
||||
return this.specs[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
list(): FormatSpec[] {
|
||||
return this.specs.slice();
|
||||
}
|
||||
|
||||
importable(): FormatSpec[] {
|
||||
return this.specs.filter((spec: FormatSpec) => spec.supportsImport);
|
||||
}
|
||||
|
||||
exportable(): FormatSpec[] {
|
||||
return this.specs.filter((spec: FormatSpec) => spec.supportsExport);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* HTML 导出(1.8.8)。
|
||||
*
|
||||
* 把结果集渲染为可独立打开的 HTML 文档(含基础样式与表格)。
|
||||
* 所有单元格内容做 HTML 转义,避免导出文件里的脚本被浏览器执行。
|
||||
*/
|
||||
export interface HtmlOptions {
|
||||
title: string;
|
||||
/** 是否输出表头行。 */
|
||||
withHeader: boolean;
|
||||
/** 附加说明(如导出时间、连接名),可空。 */
|
||||
footer: string;
|
||||
}
|
||||
|
||||
export class HtmlExporter {
|
||||
static defaultOptions(): HtmlOptions {
|
||||
return { title: '查询结果', withHeader: true, footer: '' };
|
||||
}
|
||||
|
||||
/** 仅表格片段。 */
|
||||
static toTable(header: string[], rows: string[][]): string {
|
||||
const parts: string[] = [];
|
||||
parts.push('<table>');
|
||||
if (header.length > 0) {
|
||||
parts.push(' <thead><tr>');
|
||||
for (let i = 0; i < header.length; i++) {
|
||||
parts.push(' <th>' + HtmlExporter.escape(header[i]) + '</th>');
|
||||
}
|
||||
parts.push(' </tr></thead>');
|
||||
}
|
||||
parts.push(' <tbody>');
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
parts.push(' <tr>');
|
||||
for (let j = 0; j < rows[i].length; j++) {
|
||||
parts.push(' <td>' + HtmlExporter.escape(rows[i][j]) + '</td>');
|
||||
}
|
||||
parts.push(' </tr>');
|
||||
}
|
||||
parts.push(' </tbody>');
|
||||
parts.push('</table>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/** 完整 HTML 文档。 */
|
||||
static toDocument(header: string[], rows: string[][], options: HtmlOptions): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('<!DOCTYPE html>');
|
||||
lines.push('<html lang="zh-CN">');
|
||||
lines.push('<head>');
|
||||
lines.push(' <meta charset="UTF-8">');
|
||||
lines.push(' <title>' + HtmlExporter.escape(options.title) + '</title>');
|
||||
lines.push(' <style>');
|
||||
lines.push(' body{font-family:-apple-system,"HarmonyOS Sans SC",sans-serif;margin:24px;color:#1f2329}');
|
||||
lines.push(' h1{font-size:18px;margin:0 0 16px}');
|
||||
lines.push(' table{border-collapse:collapse;font-size:13px}');
|
||||
lines.push(' th,td{border:1px solid #c9cdd4;padding:4px 8px;text-align:left}');
|
||||
lines.push(' th{background:#f2f3f5;font-weight:600}');
|
||||
lines.push(' footer{color:#86909c;font-size:12px;margin-top:12px}');
|
||||
lines.push(' </style>');
|
||||
lines.push('</head>');
|
||||
lines.push('<body>');
|
||||
lines.push(' <h1>' + HtmlExporter.escape(options.title) + '</h1>');
|
||||
lines.push(HtmlExporter.toTable(options.withHeader ? header : [], rows));
|
||||
if (options.footer.length > 0) {
|
||||
lines.push(' <footer>' + HtmlExporter.escape(options.footer) + '</footer>');
|
||||
}
|
||||
lines.push('</body>');
|
||||
lines.push('</html>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
static escape(text: string): string {
|
||||
return text.replace(new RegExp('&', 'g'), '&')
|
||||
.replace(new RegExp('<', 'g'), '<')
|
||||
.replace(new RegExp('>', 'g'), '>')
|
||||
.replace(new RegExp('"', 'g'), '"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* JSON 导入导出(1.8.5)。
|
||||
*
|
||||
* 导出行对象数组:[{"id":"1","name":"alice"}, …]。
|
||||
* 导入要求为对象数组;缺列按空值补齐,并检查列集合一致性(不一致记为警告)。
|
||||
* 解析失败返回错误而非抛异常。
|
||||
*/
|
||||
export interface JsonParseResult {
|
||||
header: string[];
|
||||
rows: string[][];
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class JsonCodec {
|
||||
static serialize(header: string[], rows: string[][]): string {
|
||||
const objects: Map<string, string>[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const obj: Map<string, string> = new Map<string, string>();
|
||||
for (let j = 0; j < header.length; j++) {
|
||||
const value: string | undefined = rows[i][j];
|
||||
obj.set(header[j], value === undefined ? '' : value);
|
||||
}
|
||||
objects.push(obj);
|
||||
}
|
||||
const plain: Record<string, string>[] = [];
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
const record: Record<string, string> = {};
|
||||
objects[i].forEach((value: string, key: string) => {
|
||||
record[key] = value;
|
||||
});
|
||||
plain.push(record);
|
||||
}
|
||||
return JSON.stringify(plain, null, 2);
|
||||
}
|
||||
|
||||
static parse(text: string): JsonParseResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let parsed: Object | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(text) as Object;
|
||||
} catch (e) {
|
||||
const message: string = e instanceof Error ? e.message : String(e);
|
||||
return { header: [], rows: [], errors: ['invalid JSON: ' + message], warnings: [] };
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { header: [], rows: [], errors: ['top-level JSON value must be an array'], warnings: [] };
|
||||
}
|
||||
const array: Object[] = parsed as Object[];
|
||||
if (array.length === 0) {
|
||||
return { header: [], rows: [], errors: [], warnings: ['empty array'] };
|
||||
}
|
||||
const header: string[] = [];
|
||||
const first: Object = array[0];
|
||||
if (first === null || Array.isArray(first) || typeof first !== 'object') {
|
||||
return { header: [], rows: [], errors: ['array items must be objects'], warnings: [] };
|
||||
}
|
||||
const firstRecord: Record<string, Object> = first as Record<string, Object>;
|
||||
for (const key in firstRecord) {
|
||||
header.push(key);
|
||||
}
|
||||
const rows: string[][] = [];
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
const item: Object = array[i];
|
||||
if (item === null || Array.isArray(item) || typeof item !== 'object') {
|
||||
errors.push('item ' + i + ' is not an object');
|
||||
continue;
|
||||
}
|
||||
const record: Record<string, Object> = item as Record<string, Object>;
|
||||
const row: string[] = [];
|
||||
for (let j = 0; j < header.length; j++) {
|
||||
const value: Object = record[header[j]];
|
||||
if (value === undefined) {
|
||||
warnings.push('item ' + i + ' missing key ' + header[j]);
|
||||
row.push('');
|
||||
} else {
|
||||
row.push(JsonCodec.stringifyValue(value));
|
||||
}
|
||||
}
|
||||
for (const key in record) {
|
||||
if (header.indexOf(key) < 0) {
|
||||
warnings.push('item ' + i + ' has extra key ' + key + ' not in header');
|
||||
}
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return { header: header, rows: rows, errors: errors, warnings: warnings };
|
||||
}
|
||||
|
||||
private static stringifyValue(value: Object): string {
|
||||
if (value === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value as string;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 工作表模型(1.8.3)。
|
||||
*
|
||||
* Excel/多工作表导出的中间表示:工作表集合、行数据、列宽与名称校验。
|
||||
* 字节级 xlsx 编解码由外部库承担(见 DBM-IO-001 评估),本模型只做数据组织与校验,
|
||||
* 避免把第三方库耦合进 data 层。
|
||||
*/
|
||||
export interface SheetData {
|
||||
name: string;
|
||||
header: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export class SpreadsheetModel {
|
||||
private readonly sheets: SheetData[] = [];
|
||||
|
||||
addSheet(name: string, header: string[], rows: string[][]): SheetData {
|
||||
const sheet: SheetData = { name: name.trim(), header: header.slice(), rows: rows };
|
||||
this.sheets.push(sheet);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
sheets_(): SheetData[] {
|
||||
return this.sheets.slice();
|
||||
}
|
||||
|
||||
sheet(name: string): SheetData | undefined {
|
||||
for (let i = 0; i < this.sheets.length; i++) {
|
||||
if (this.sheets[i].name === name) {
|
||||
return this.sheets[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
totalRows(): number {
|
||||
let count: number = 0;
|
||||
for (let i = 0; i < this.sheets.length; i++) {
|
||||
count += this.sheets[i].rows.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** 校验:名称非空、唯一、不超过 31 字符(Excel 限制)、列宽一致。 */
|
||||
validate(): string[] {
|
||||
const errors: string[] = [];
|
||||
const names: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < this.sheets.length; i++) {
|
||||
const sheet: SheetData = this.sheets[i];
|
||||
const key: string = sheet.name.toLowerCase();
|
||||
if (sheet.name.length === 0) {
|
||||
errors.push('sheet ' + i + ' has empty name');
|
||||
}
|
||||
if (sheet.name.length > 31) {
|
||||
errors.push('sheet ' + sheet.name + ' exceeds 31 characters');
|
||||
}
|
||||
if (names.has(key)) {
|
||||
errors.push('duplicate sheet: ' + sheet.name);
|
||||
}
|
||||
names.add(key);
|
||||
for (let r = 0; r < sheet.rows.length; r++) {
|
||||
if (sheet.header.length > 0 && sheet.rows[r].length !== sheet.header.length) {
|
||||
errors.push('sheet ' + sheet.name + ' row ' + r + ' column count mismatch');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* SQL 脚本导出(1.8.6)。
|
||||
*
|
||||
* 生成建表脚本与 INSERT 数据脚本:
|
||||
* - 建表复用 DdlGenerator(含索引);
|
||||
* - INSERT 支持按行数批量合并(多行 VALUES),值转义、拒绝 NUL。
|
||||
*/
|
||||
import { TableDef } from '../schema/TableModel';
|
||||
import { DdlGenerator, SqlDialect } from '../schema/DdlGenerator';
|
||||
|
||||
export class SqlExporter {
|
||||
/** 生成 INSERT 语句;rows 按列顺序给出。 */
|
||||
static insertStatements(table: TableDef, rows: string[][], dialect: SqlDialect, batchSize: number): string[] {
|
||||
const tableName: string = DdlGenerator.qualifiedName(table.schema, table.name, dialect);
|
||||
const columns: string[] = [];
|
||||
for (let i = 0; i < table.columns.length; i++) {
|
||||
columns.push(DdlGenerator.quote(table.columns[i].name, dialect));
|
||||
}
|
||||
const columnList: string = columns.join(', ');
|
||||
const size: number = batchSize > 0 ? batchSize : 1;
|
||||
const out: string[] = [];
|
||||
for (let start = 0; start < rows.length; start += size) {
|
||||
const tuples: string[] = [];
|
||||
for (let i = start; i < rows.length && i < start + size; i++) {
|
||||
const row: string[] = rows[i];
|
||||
if (row.length !== table.columns.length) {
|
||||
throw new Error('row ' + i + ' has ' + row.length + ' values, expected ' + table.columns.length);
|
||||
}
|
||||
const values: string[] = [];
|
||||
for (let j = 0; j < row.length; j++) {
|
||||
values.push(SqlExporter.literal(row[j]));
|
||||
}
|
||||
tuples.push('(' + values.join(', ') + ')');
|
||||
}
|
||||
out.push('INSERT INTO ' + tableName + ' (' + columnList + ') VALUES ' + tuples.join(', ') + ';');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 完整脚本:建表 + 可选数据。 */
|
||||
static script(table: TableDef, rows: string[][], dialect: SqlDialect, includeStructure: boolean,
|
||||
includeData: boolean, batchSize: number): string {
|
||||
const parts: string[] = [];
|
||||
if (includeStructure) {
|
||||
parts.push('-- ' + table.name);
|
||||
parts.push(DdlGenerator.generate(table, dialect));
|
||||
}
|
||||
if (includeData && rows.length > 0) {
|
||||
const inserts: string[] = SqlExporter.insertStatements(table, rows, dialect, batchSize);
|
||||
for (let i = 0; i < inserts.length; i++) {
|
||||
parts.push(inserts[i]);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
private static literal(value: string): string {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) === 0) {
|
||||
throw new Error('value contains NUL character');
|
||||
}
|
||||
}
|
||||
return '\'' + value.replace(new RegExp('\'', 'g'), '\'\'') + '\'';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 编码、换行与 BOM 处理(1.8.11)。
|
||||
*
|
||||
* - detectBom:按字节前缀识别 UTF-8 / UTF-16LE / UTF-16BE;
|
||||
* - stripBomText:去除字符串开头的 BOM 字符;
|
||||
* - detectEol:识别 \r\n / \n / \r;
|
||||
* - normalizeEol:统一换行;
|
||||
* - 分隔符建议:按首行出现次数推断逗号/制表符/分号。
|
||||
*
|
||||
* 编码解码本身依赖平台 API(TextDecoder 等),本模块只做判定与文本处理。
|
||||
*/
|
||||
export class TextEncoding {
|
||||
/** 字节级 BOM 识别。 */
|
||||
static detectBom(bytes: number[]): string | undefined {
|
||||
if (bytes.length >= 3 && (bytes[0] & 0xFF) === 0xEF && (bytes[1] & 0xFF) === 0xBB && (bytes[2] & 0xFF) === 0xBF) {
|
||||
return 'utf-8';
|
||||
}
|
||||
if (bytes.length >= 2 && (bytes[0] & 0xFF) === 0xFF && (bytes[1] & 0xFF) === 0xFE) {
|
||||
return 'utf-16le';
|
||||
}
|
||||
if (bytes.length >= 2 && (bytes[0] & 0xFF) === 0xFE && (bytes[1] & 0xFF) === 0xFF) {
|
||||
return 'utf-16be';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 去除文本开头的 BOM 字符(U+FEFF)。 */
|
||||
static stripBomText(text: string): string {
|
||||
if (text.length > 0 && text.charCodeAt(0) === 0xFEFF) {
|
||||
return text.substring(1);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 识别换行风格:CRLF / LF / CR;无换行返回 \n。 */
|
||||
static detectEol(text: string): string {
|
||||
if (text.indexOf('\r\n') >= 0) {
|
||||
return '\r\n';
|
||||
}
|
||||
if (text.indexOf('\n') >= 0) {
|
||||
return '\n';
|
||||
}
|
||||
if (text.indexOf('\r') >= 0) {
|
||||
return '\r';
|
||||
}
|
||||
return '\n';
|
||||
}
|
||||
|
||||
/** 统一换行风格。 */
|
||||
static normalizeEol(text: string, eol: string): string {
|
||||
const unified: string = text.replace(new RegExp('\r\n', 'g'), '\n').replace(new RegExp('\r', 'g'), '\n');
|
||||
return unified.replace(new RegExp('\n', 'g'), eol);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断分隔符:统计首行中逗号、制表符、分号的出现次数,取最多者;全为 0 返回逗号。
|
||||
*/
|
||||
static suggestDelimiter(text: string): string {
|
||||
const firstLine: string = text.split('\n')[0];
|
||||
const candidates: string[] = [',', '\t', ';'];
|
||||
let best: string = ',';
|
||||
let bestCount: number = 0;
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
let count: number = 0;
|
||||
for (let j = 0; j < firstLine.length; j++) {
|
||||
if (firstLine.charAt(j) === candidates[i]) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > bestCount) {
|
||||
best = candidates[i];
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 类型转换(1.8.9)。
|
||||
*
|
||||
* 导入时把文本转换为目标列类型的规范文本:
|
||||
* - INTEGER:可选符号 + 数字;
|
||||
* - DECIMAL:可选符号 + 数字 + 可选小数;
|
||||
* - BOOLEAN:true/false/1/0/yes/no/是/否 → true/false;
|
||||
* - DATE:YYYY-MM-DD(接受 / 与 . 分隔);
|
||||
* - TIMESTAMP:YYYY-MM-DD HH:MM:SS(接受 T 分隔)。
|
||||
*
|
||||
* 空值统一返回空字符串(是否允许空由列约束另行校验)。
|
||||
*/
|
||||
export enum TargetType {
|
||||
STRING = 0,
|
||||
INTEGER = 1,
|
||||
DECIMAL = 2,
|
||||
BOOLEAN = 3,
|
||||
DATE = 4,
|
||||
TIMESTAMP = 5
|
||||
}
|
||||
|
||||
export interface ConvertResult {
|
||||
ok: boolean;
|
||||
value: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export class TypeConverter {
|
||||
static convert(value: string, target: TargetType): ConvertResult {
|
||||
const text: string = value === undefined || value === null ? '' : value.trim();
|
||||
if (text.length === 0) {
|
||||
return { ok: true, value: '', error: '' };
|
||||
}
|
||||
switch (target) {
|
||||
case TargetType.INTEGER:
|
||||
return TypeConverter.asInteger(text);
|
||||
case TargetType.DECIMAL:
|
||||
return TypeConverter.asDecimal(text);
|
||||
case TargetType.BOOLEAN:
|
||||
return TypeConverter.asBoolean(text);
|
||||
case TargetType.DATE:
|
||||
return TypeConverter.asDate(text);
|
||||
case TargetType.TIMESTAMP:
|
||||
return TypeConverter.asTimestamp(text);
|
||||
default:
|
||||
return { ok: true, value: text, error: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 按列类型批量转换,返回错误列表(带列序号)。 */
|
||||
static convertRow(values: string[], types: TargetType[]): { values: string[]; errors: string[] } {
|
||||
const out: string[] = [];
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const type: TargetType = i < types.length ? types[i] : TargetType.STRING;
|
||||
const result: ConvertResult = TypeConverter.convert(values[i], type);
|
||||
out.push(result.value);
|
||||
if (!result.ok) {
|
||||
errors.push('column ' + i + ': ' + result.error);
|
||||
}
|
||||
}
|
||||
return { values: out, errors: errors };
|
||||
}
|
||||
|
||||
private static asInteger(text: string): ConvertResult {
|
||||
let start: number = 0;
|
||||
if (text.charAt(0) === '-' || text.charAt(0) === '+') {
|
||||
start = 1;
|
||||
}
|
||||
if (start >= text.length) {
|
||||
return { ok: false, value: text, error: 'not an integer' };
|
||||
}
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const ch: string = text.charAt(i);
|
||||
if (ch < '0' || ch > '9') {
|
||||
return { ok: false, value: text, error: 'not an integer' };
|
||||
}
|
||||
}
|
||||
return { ok: true, value: text, error: '' };
|
||||
}
|
||||
|
||||
private static asDecimal(text: string): ConvertResult {
|
||||
let start: number = 0;
|
||||
if (text.charAt(0) === '-' || text.charAt(0) === '+') {
|
||||
start = 1;
|
||||
}
|
||||
let dots: number = 0;
|
||||
let digits: number = 0;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const ch: string = text.charAt(i);
|
||||
if (ch === '.') {
|
||||
dots++;
|
||||
if (dots > 1) {
|
||||
return { ok: false, value: text, error: 'not a decimal' };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch < '0' || ch > '9') {
|
||||
return { ok: false, value: text, error: 'not a decimal' };
|
||||
}
|
||||
digits++;
|
||||
}
|
||||
if (digits === 0) {
|
||||
return { ok: false, value: text, error: 'not a decimal' };
|
||||
}
|
||||
return { ok: true, value: text, error: '' };
|
||||
}
|
||||
|
||||
private static asBoolean(text: string): ConvertResult {
|
||||
const lower: string = text.toLowerCase();
|
||||
if (lower === 'true' || lower === '1' || lower === 'yes' || lower === 'y' || lower === '是') {
|
||||
return { ok: true, value: 'true', error: '' };
|
||||
}
|
||||
if (lower === 'false' || lower === '0' || lower === 'no' || lower === 'n' || lower === '否') {
|
||||
return { ok: true, value: 'false', error: '' };
|
||||
}
|
||||
return { ok: false, value: text, error: 'not a boolean' };
|
||||
}
|
||||
|
||||
private static asDate(text: string): ConvertResult {
|
||||
const normalized: string = text.replace(new RegExp('/', 'g'), '-').replace(new RegExp('\\.', 'g'), '-');
|
||||
const parts: string[] = normalized.split('-');
|
||||
if (parts.length !== 3) {
|
||||
return { ok: false, value: text, error: 'expected YYYY-MM-DD' };
|
||||
}
|
||||
if (parts[0].length !== 4 || parts[1].length !== 2 || parts[2].length !== 2) {
|
||||
return { ok: false, value: text, error: 'expected YYYY-MM-DD' };
|
||||
}
|
||||
if (!TypeConverter.allDigits(parts[0]) || !TypeConverter.allDigits(parts[1]) || !TypeConverter.allDigits(parts[2])) {
|
||||
return { ok: false, value: text, error: 'expected YYYY-MM-DD' };
|
||||
}
|
||||
const month: number = parseInt(parts[1], 10);
|
||||
const day: number = parseInt(parts[2], 10);
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
return { ok: false, value: text, error: 'date out of range' };
|
||||
}
|
||||
return { ok: true, value: parts[0] + '-' + parts[1] + '-' + parts[2], error: '' };
|
||||
}
|
||||
|
||||
private static asTimestamp(text: string): ConvertResult {
|
||||
const normalized: string = text.replace(new RegExp('T', 'g'), ' ');
|
||||
const spaceIndex: number = normalized.indexOf(' ');
|
||||
if (spaceIndex < 0) {
|
||||
const dateOnly: ConvertResult = TypeConverter.asDate(normalized);
|
||||
return dateOnly.ok
|
||||
? { ok: true, value: dateOnly.value + ' 00:00:00', error: '' }
|
||||
: { ok: false, value: text, error: 'expected YYYY-MM-DD HH:MM:SS' };
|
||||
}
|
||||
const datePart: ConvertResult = TypeConverter.asDate(normalized.substring(0, spaceIndex));
|
||||
if (!datePart.ok) {
|
||||
return { ok: false, value: text, error: 'expected YYYY-MM-DD HH:MM:SS' };
|
||||
}
|
||||
const timePart: string = normalized.substring(spaceIndex + 1).trim();
|
||||
const timeParts: string[] = timePart.split(':');
|
||||
if (timeParts.length < 2 || timeParts.length > 3) {
|
||||
return { ok: false, value: text, error: 'expected HH:MM[:SS]' };
|
||||
}
|
||||
const hour: string = timeParts[0];
|
||||
const minute: string = timeParts[1];
|
||||
const second: string = timeParts.length === 3 ? timeParts[2] : '00';
|
||||
if (!TypeConverter.allDigits(hour) || !TypeConverter.allDigits(minute) || !TypeConverter.allDigits(second)) {
|
||||
return { ok: false, value: text, error: 'expected HH:MM[:SS]' };
|
||||
}
|
||||
const h: number = parseInt(hour, 10);
|
||||
const m: number = parseInt(minute, 10);
|
||||
const s: number = parseInt(second, 10);
|
||||
if (h > 23 || m > 59 || s > 59) {
|
||||
return { ok: false, value: text, error: 'time out of range' };
|
||||
}
|
||||
return { ok: true, value: datePart.value + ' ' + TypeConverter.pad(hour) + ':' + TypeConverter.pad(minute) + ':' + TypeConverter.pad(second), error: '' };
|
||||
}
|
||||
|
||||
private static allDigits(text: string): boolean {
|
||||
if (text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch: string = text.charAt(i);
|
||||
if (ch < '0' || ch > '9') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static pad(text: string): string {
|
||||
return text.length < 2 ? '0' + text : text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 导入导出向导(1.8.1)。
|
||||
*
|
||||
* 步骤:数据源 → 目标 → 字段映射 → 选项 → 预览 → 执行 → 完成。
|
||||
* 每一步有校验门槛,未通过不允许进入下一步。
|
||||
*
|
||||
* 说明:向导只管理状态与校验,实际读写与执行由各格式模块(1.8.2–1.8.11)承担。
|
||||
*/
|
||||
export enum WizardStep {
|
||||
SOURCE = 0,
|
||||
TARGET = 1,
|
||||
MAPPING = 2,
|
||||
OPTIONS = 3,
|
||||
PREVIEW = 4,
|
||||
EXECUTE = 5,
|
||||
DONE = 6
|
||||
}
|
||||
|
||||
export const WIZARD_STEP_NAMES: string[] = [
|
||||
'数据源', '目标', '字段映射', '选项', '预览', '执行', '完成'
|
||||
];
|
||||
|
||||
export class WizardModel {
|
||||
private step: WizardStep = WizardStep.SOURCE;
|
||||
private readonly data: Map<string, string> = new Map<string, string>();
|
||||
|
||||
/** 当前步骤。 */
|
||||
current(): WizardStep {
|
||||
return this.step;
|
||||
}
|
||||
|
||||
currentName(): string {
|
||||
return WIZARD_STEP_NAMES[this.step];
|
||||
}
|
||||
|
||||
stepCount(): number {
|
||||
return WIZARD_STEP_NAMES.length;
|
||||
}
|
||||
|
||||
/** 进度百分比(0–100)。 */
|
||||
progress(): number {
|
||||
return Math.round((this.step / (WIZARD_STEP_NAMES.length - 1)) * 100);
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
|
||||
get(key: string): string {
|
||||
const value: string | undefined = this.data.get(key);
|
||||
return value === undefined ? '' : value;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.data.has(key);
|
||||
}
|
||||
|
||||
/** 当前步骤的校验错误;空数组表示可通过。 */
|
||||
validateCurrent(): string[] {
|
||||
const errors: string[] = [];
|
||||
switch (this.step) {
|
||||
case WizardStep.SOURCE:
|
||||
if (this.get('sourceType').length === 0) {
|
||||
errors.push('请选择数据源类型(文件或表)');
|
||||
}
|
||||
if (this.get('sourcePath').length === 0) {
|
||||
errors.push('请指定源文件或源表');
|
||||
}
|
||||
break;
|
||||
case WizardStep.TARGET:
|
||||
if (this.get('targetTable').length === 0) {
|
||||
errors.push('请指定目标表');
|
||||
}
|
||||
if (this.get('targetMode').length === 0) {
|
||||
errors.push('请选择写入模式(追加/覆盖)');
|
||||
}
|
||||
break;
|
||||
case WizardStep.MAPPING:
|
||||
if (this.get('mappingCount') === '' || this.get('mappingCount') === '0') {
|
||||
errors.push('请至少配置一个字段映射');
|
||||
}
|
||||
break;
|
||||
case WizardStep.OPTIONS:
|
||||
if (this.get('delimiter').length === 0 && this.get('format').length === 0) {
|
||||
errors.push('请确认格式与分隔符');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 进入下一步;校验失败返回 false 且错误可从 lastErrors 读取。 */
|
||||
next(): boolean {
|
||||
this.lastErrors = this.validateCurrent();
|
||||
if (this.lastErrors.length > 0) {
|
||||
return false;
|
||||
}
|
||||
if (this.step < WizardStep.DONE) {
|
||||
this.step = (this.step + 1) as WizardStep;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 返回上一步。 */
|
||||
back(): void {
|
||||
if (this.step > WizardStep.SOURCE) {
|
||||
this.step = (this.step - 1) as WizardStep;
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转到指定步骤(仅允许回跳到已完成步骤,或前进一步)。 */
|
||||
goTo(step: WizardStep): boolean {
|
||||
if (step < WizardStep.SOURCE || step > WizardStep.DONE) {
|
||||
return false;
|
||||
}
|
||||
if (step > this.step) {
|
||||
return false;
|
||||
}
|
||||
this.step = step;
|
||||
return true;
|
||||
}
|
||||
|
||||
isDone(): boolean {
|
||||
return this.step === WizardStep.DONE;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.step = WizardStep.SOURCE;
|
||||
this.data.clear();
|
||||
this.lastErrors = [];
|
||||
}
|
||||
|
||||
private lastErrors: string[] = [];
|
||||
|
||||
lastValidationErrors(): string[] {
|
||||
return this.lastErrors.slice();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* XML 导入导出(1.8.4)。
|
||||
*
|
||||
* 格式(固定、可往返):
|
||||
* <?xml version="1.0" encoding="UTF-8"?>
|
||||
* <rows>
|
||||
* <row><col name="id">1</col><col name="name">alice</col></row>
|
||||
* </rows>
|
||||
*
|
||||
* 只解析本模块产出的结构,不实现通用 XML 解析器(避免实体/命名空间/DTD 攻击面)。
|
||||
* 解析失败返回错误而非抛异常。
|
||||
*/
|
||||
export interface XmlParseResult {
|
||||
header: string[];
|
||||
rows: string[][];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class XmlCodec {
|
||||
/** 序列化;值做 XML 实体转义。 */
|
||||
static serialize(header: string[], rows: string[][], rootName: string): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
lines.push('<' + rootName + '>');
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
let line: string = ' <row>';
|
||||
for (let j = 0; j < header.length; j++) {
|
||||
const value: string | undefined = rows[i][j];
|
||||
line += '<col name="' + XmlCodec.escape(header[j]) + '">' +
|
||||
XmlCodec.escape(value === undefined ? '' : value) + '</col>';
|
||||
}
|
||||
line += '</row>';
|
||||
lines.push(line);
|
||||
}
|
||||
lines.push('</' + rootName + '>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 解析本模块格式;错误以文本列表返回。 */
|
||||
static parse(text: string): XmlParseResult {
|
||||
const errors: string[] = [];
|
||||
const header: string[] = [];
|
||||
const rows: string[][] = [];
|
||||
const rowPattern: RegExp = new RegExp('<row>([\\s\\S]*?)</row>', 'g');
|
||||
const colPattern: RegExp = new RegExp('<col name="([^"]*)">([\\s\\S]*?)</col>', 'g');
|
||||
let rowMatch: RegExpExecArray | null = rowPattern.exec(text);
|
||||
if (rowMatch === null) {
|
||||
if (text.indexOf('<rows') < 0) {
|
||||
errors.push('missing <rows> root element');
|
||||
}
|
||||
}
|
||||
while (rowMatch !== null) {
|
||||
const body: string = rowMatch[1];
|
||||
const row: string[] = [];
|
||||
let colMatch: RegExpExecArray | null = colPattern.exec(body);
|
||||
const names: string[] = [];
|
||||
while (colMatch !== null) {
|
||||
names.push(XmlCodec.unescape(colMatch[1]));
|
||||
row.push(XmlCodec.unescape(colMatch[2]));
|
||||
colMatch = colPattern.exec(body);
|
||||
}
|
||||
if (header.length === 0) {
|
||||
for (let k = 0; k < names.length; k++) {
|
||||
header.push(names[k]);
|
||||
}
|
||||
}
|
||||
rows.push(row);
|
||||
rowMatch = rowPattern.exec(text);
|
||||
}
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push('no <row> elements found');
|
||||
}
|
||||
return { header: header, rows: rows, errors: errors };
|
||||
}
|
||||
|
||||
static escape(text: string): string {
|
||||
return text.replace(new RegExp('&', 'g'), '&')
|
||||
.replace(new RegExp('<', 'g'), '<')
|
||||
.replace(new RegExp('>', 'g'), '>')
|
||||
.replace(new RegExp('"', 'g'), '"')
|
||||
.replace(new RegExp('\'', 'g'), ''');
|
||||
}
|
||||
|
||||
static unescape(text: string): string {
|
||||
return text.replace(new RegExp('<', 'g'), '<')
|
||||
.replace(new RegExp('>', 'g'), '>')
|
||||
.replace(new RegExp('"', 'g'), '"')
|
||||
.replace(new RegExp(''', 'g'), '\'')
|
||||
.replace(new RegExp('&', 'g'), '&');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 元数据缓存(1.5.14)。
|
||||
*
|
||||
* 按「连接 + 模式 + 对象类型」分键缓存元数据,带 TTL 与容量上限;
|
||||
* 时钟可注入,便于离线测试过期与刷新。
|
||||
*
|
||||
* 失效策略:
|
||||
* - invalidate(key):精确失效(结构变更后单对象);
|
||||
* - invalidatePrefix(prefix):按连接或模式批量失效(断开连接/刷新模式)。
|
||||
*/
|
||||
import { ObjectType } from '../metadata/Objects';
|
||||
|
||||
interface CacheEntry {
|
||||
value: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** 缓存键构造。 */
|
||||
export class CacheKeys {
|
||||
static schema(connId: string, schema: string): string {
|
||||
return 'conn:' + connId + ':schema:' + schema;
|
||||
}
|
||||
|
||||
static objects(connId: string, schema: string, type: ObjectType): string {
|
||||
return 'conn:' + connId + ':objects:' + schema + ':' + type;
|
||||
}
|
||||
|
||||
static connectionPrefix(connId: string): string {
|
||||
return 'conn:' + connId + ':';
|
||||
}
|
||||
}
|
||||
|
||||
export class MetadataCache {
|
||||
private readonly ttlMs: number;
|
||||
private readonly capacity: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly entries: Map<string, CacheEntry> = new Map<string, CacheEntry>();
|
||||
private order: string[] = [];
|
||||
|
||||
constructor(ttlMs: number, capacity: number, clock?: () => number) {
|
||||
if (ttlMs <= 0) {
|
||||
throw new Error('ttlMs must be > 0');
|
||||
}
|
||||
if (capacity < 1) {
|
||||
throw new Error('capacity must be >= 1');
|
||||
}
|
||||
this.ttlMs = ttlMs;
|
||||
this.capacity = capacity;
|
||||
this.clock = clock === undefined ? () => Date.now() : clock;
|
||||
}
|
||||
|
||||
get(key: string): string | undefined {
|
||||
const entry: CacheEntry | undefined = this.entries.get(key);
|
||||
if (entry === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (entry.expiresAt <= this.clock()) {
|
||||
this.entries.delete(key);
|
||||
this.removeFromOrder(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
put(key: string, value: string): void {
|
||||
this.entries.set(key, { value: value, expiresAt: this.clock() + this.ttlMs });
|
||||
this.removeFromOrder(key);
|
||||
this.order.push(key);
|
||||
while (this.order.length > this.capacity) {
|
||||
const oldest: string | undefined = this.order.shift();
|
||||
if (oldest !== undefined) {
|
||||
this.entries.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(key: string): void {
|
||||
this.entries.delete(key);
|
||||
this.removeFromOrder(key);
|
||||
}
|
||||
|
||||
invalidatePrefix(prefix: string): number {
|
||||
let removed: number = 0;
|
||||
const keys: string[] = this.order.slice();
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (keys[i].indexOf(prefix) === 0) {
|
||||
this.entries.delete(keys[i]);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
this.order = this.order.filter((key: string) => key.indexOf(prefix) !== 0);
|
||||
return removed;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.order.length;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
this.order = [];
|
||||
}
|
||||
|
||||
private removeFromOrder(key: string): void {
|
||||
const idx: number = this.order.indexOf(key);
|
||||
if (idx >= 0) {
|
||||
this.order.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 对象树模型(1.5.1)。
|
||||
*
|
||||
* 把扁平的 DbObject 列表构建为可导航的树,并提供:
|
||||
* - 根节点与子节点枚举;
|
||||
* - 按名称/模式搜索(大小写不敏感);
|
||||
* - 按类型过滤;
|
||||
* - 从对象回溯祖先路径;
|
||||
* - 类型计数(供对象树分组显示)。
|
||||
*
|
||||
* 纯逻辑,不依赖 UI 与平台 API。
|
||||
*/
|
||||
import { DbObject, ObjectType } from './Objects';
|
||||
|
||||
export class ObjectTreeModel {
|
||||
private readonly byId: Map<string, DbObject> = new Map<string, DbObject>();
|
||||
private readonly childrenIndex: Map<string, string[]> = new Map<string, string[]>();
|
||||
private readonly rootIds: string[] = [];
|
||||
|
||||
constructor(objects: DbObject[]) {
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
this.byId.set(objects[i].id, objects[i]);
|
||||
}
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
const obj: DbObject = objects[i];
|
||||
if (obj.parentId.length === 0) {
|
||||
this.rootIds.push(obj.id);
|
||||
} else {
|
||||
const list: string[] | undefined = this.childrenIndex.get(obj.parentId);
|
||||
if (list === undefined) {
|
||||
this.childrenIndex.set(obj.parentId, [obj.id]);
|
||||
} else {
|
||||
list.push(obj.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 根对象(通常是模式)。 */
|
||||
roots(): DbObject[] {
|
||||
const out: DbObject[] = [];
|
||||
for (let i = 0; i < this.rootIds.length; i++) {
|
||||
const obj = this.byId.get(this.rootIds[i]);
|
||||
if (obj !== undefined) {
|
||||
out.push(obj);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 直接子对象。 */
|
||||
children(parentId: string): DbObject[] {
|
||||
const ids: string[] | undefined = this.childrenIndex.get(parentId);
|
||||
if (ids === undefined) {
|
||||
return [];
|
||||
}
|
||||
const out: DbObject[] = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const obj = this.byId.get(ids[i]);
|
||||
if (obj !== undefined) {
|
||||
out.push(obj);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
find(id: string): DbObject | undefined {
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.byId.size;
|
||||
}
|
||||
|
||||
/** 从对象回溯到根(含自身,根在前)。 */
|
||||
pathOf(id: string): DbObject[] {
|
||||
const out: DbObject[] = [];
|
||||
let current: DbObject | undefined = this.byId.get(id);
|
||||
while (current !== undefined) {
|
||||
out.unshift(current);
|
||||
if (current.parentId.length === 0) {
|
||||
break;
|
||||
}
|
||||
current = this.byId.get(current.parentId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按关键字搜索名称或模式(大小写不敏感),可选类型过滤。 */
|
||||
search(keyword: string, types?: ObjectType[]): DbObject[] {
|
||||
const needle: string = keyword.trim().toLowerCase();
|
||||
const out: DbObject[] = [];
|
||||
if (needle.length === 0) {
|
||||
return out;
|
||||
}
|
||||
this.byId.forEach((obj: DbObject) => {
|
||||
const hit: boolean = obj.name.toLowerCase().indexOf(needle) >= 0 ||
|
||||
obj.schema.toLowerCase().indexOf(needle) >= 0;
|
||||
if (!hit) {
|
||||
return;
|
||||
}
|
||||
if (types !== undefined && types.length > 0 && types.indexOf(obj.type) < 0) {
|
||||
return;
|
||||
}
|
||||
out.push(obj);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按类型统计。 */
|
||||
countOf(type: ObjectType): number {
|
||||
let count: number = 0;
|
||||
this.byId.forEach((obj: DbObject) => {
|
||||
if (obj.type === type) {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 数据库对象模型(1.5.1)。
|
||||
*
|
||||
* 与引擎无关的内部表示:引擎驱动把各自的元数据转换为 DbObject 列表,
|
||||
* 上层(对象树、搜索、DDL 生成)只依赖本模型。
|
||||
*/
|
||||
export enum ObjectType {
|
||||
SCHEMA = 0,
|
||||
TABLE = 1,
|
||||
VIEW = 2,
|
||||
INDEX = 3,
|
||||
PROCEDURE = 4,
|
||||
FUNCTION = 5,
|
||||
TRIGGER = 6,
|
||||
SEQUENCE = 7,
|
||||
TYPE = 8,
|
||||
SYNONYM = 9
|
||||
}
|
||||
|
||||
export interface DbObject {
|
||||
/** 全库唯一 id,建议用 schema + type + name 组合。 */
|
||||
id: string;
|
||||
name: string;
|
||||
type: ObjectType;
|
||||
schema: string;
|
||||
/** 父对象 id;根对象为空字符串。 */
|
||||
parentId: string;
|
||||
}
|
||||
|
||||
/** 对象类型显示名。 */
|
||||
export function objectTypeLabel(type: ObjectType): string {
|
||||
switch (type) {
|
||||
case ObjectType.SCHEMA:
|
||||
return '模式';
|
||||
case ObjectType.TABLE:
|
||||
return '表';
|
||||
case ObjectType.VIEW:
|
||||
return '视图';
|
||||
case ObjectType.INDEX:
|
||||
return '索引';
|
||||
case ObjectType.PROCEDURE:
|
||||
return '存储过程';
|
||||
case ObjectType.FUNCTION:
|
||||
return '函数';
|
||||
case ObjectType.TRIGGER:
|
||||
return '触发器';
|
||||
case ObjectType.SEQUENCE:
|
||||
return '序列';
|
||||
case ObjectType.TYPE:
|
||||
return '类型';
|
||||
case ObjectType.SYNONYM:
|
||||
return '同义词';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 对象搜索辅助(1.5.12)。
|
||||
*
|
||||
* 在 ObjectTreeModel.search 之外补充:关键词高亮区间、按类型过滤、按名称排序。
|
||||
* 纯逻辑,便于 UI 直接消费(高亮时按区间渲染)。
|
||||
*/
|
||||
import { DbObject, ObjectType } from './Objects';
|
||||
|
||||
export class SearchUtil {
|
||||
/**
|
||||
* 关键词在文本中出现的所有区间(左闭右开,大小写不敏感)。
|
||||
* keyword 为空或无命中时返回空数组。
|
||||
*/
|
||||
static highlightRanges(text: string, keyword: string): number[][] {
|
||||
const needle: string = keyword.trim().toLowerCase();
|
||||
const out: number[][] = [];
|
||||
if (needle.length === 0) {
|
||||
return out;
|
||||
}
|
||||
const haystack: string = text.toLowerCase();
|
||||
let from: number = 0;
|
||||
while (from <= haystack.length - needle.length) {
|
||||
const idx: number = haystack.indexOf(needle, from);
|
||||
if (idx < 0) {
|
||||
break;
|
||||
}
|
||||
out.push([idx, idx + needle.length]);
|
||||
from = idx + needle.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按类型过滤;types 为空表示不过滤。 */
|
||||
static filterByTypes(objects: DbObject[], types: ObjectType[]): DbObject[] {
|
||||
if (types.length === 0) {
|
||||
return objects.slice();
|
||||
}
|
||||
const out: DbObject[] = [];
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
if (types.indexOf(objects[i].type) >= 0) {
|
||||
out.push(objects[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按名称排序(大小写不敏感)。 */
|
||||
static sortByName(objects: DbObject[], ascending: boolean): DbObject[] {
|
||||
const copy: DbObject[] = objects.slice();
|
||||
copy.sort((a: DbObject, b: DbObject) => {
|
||||
const left: string = a.name.toLowerCase();
|
||||
const right: string = b.name.toLowerCase();
|
||||
if (left < right) {
|
||||
return ascending ? -1 : 1;
|
||||
}
|
||||
if (left > right) {
|
||||
return ascending ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 对象批量操作(1.5.13):重命名、复制、删除的 DDL 生成与执行排序。
|
||||
*
|
||||
* 只覆盖表与视图;索引删除由 IndexModel.dropSql 处理(需要所属表);
|
||||
* 存储过程等对象的改名在各引擎差异过大,返回空字符串由 UI 提示手工处理。
|
||||
*
|
||||
* 执行顺序建议:先复制(可能依赖源对象)→ 再重命名 → 最后删除,
|
||||
* 通过 orderForExecution 显式给出,避免「先删后复制」这类自毁顺序。
|
||||
*/
|
||||
import { ObjectType } from '../metadata/Objects';
|
||||
import { DdlGenerator, SqlDialect } from './DdlGenerator';
|
||||
|
||||
export enum BatchOpKind {
|
||||
COPY = 0,
|
||||
RENAME = 1,
|
||||
DROP = 2
|
||||
}
|
||||
|
||||
export interface BatchOp {
|
||||
kind: BatchOpKind;
|
||||
type: ObjectType;
|
||||
schema: string;
|
||||
name: string;
|
||||
/** 重命名/复制时的目标名。 */
|
||||
newName: string;
|
||||
}
|
||||
|
||||
export class BatchOps {
|
||||
/** 重命名表/视图。 */
|
||||
static renameSql(op: BatchOp, dialect: SqlDialect): string {
|
||||
if (op.newName.trim().length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (op.type !== ObjectType.TABLE && op.type !== ObjectType.VIEW) {
|
||||
return '';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLSERVER) {
|
||||
// sp_rename 需完整对象路径与引号处理,交由驱动层拼装
|
||||
return '';
|
||||
}
|
||||
if (dialect === SqlDialect.MYSQL) {
|
||||
if (op.type === ObjectType.TABLE) {
|
||||
return 'RENAME TABLE ' + BatchOps.qualified(op.schema, op.name, dialect) +
|
||||
' TO ' + BatchOps.qualified(op.schema, op.newName, dialect) + ';';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const keyword: string = op.type === ObjectType.TABLE ? 'TABLE' : 'VIEW';
|
||||
return 'ALTER ' + keyword + ' ' + BatchOps.qualified(op.schema, op.name, dialect) +
|
||||
' RENAME TO ' + DdlGenerator.quote(op.newName, dialect) + ';';
|
||||
}
|
||||
|
||||
/** 删除表/视图。 */
|
||||
static dropSql(op: BatchOp, dialect: SqlDialect): string {
|
||||
if (op.type === ObjectType.TABLE) {
|
||||
return 'DROP TABLE ' + BatchOps.qualified(op.schema, op.name, dialect) + ';';
|
||||
}
|
||||
if (op.type === ObjectType.VIEW) {
|
||||
return 'DROP VIEW ' + BatchOps.qualified(op.schema, op.name, dialect) + ';';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 复制表:CTAS(SQL Server 用 SELECT INTO)。 */
|
||||
static copySql(op: BatchOp, dialect: SqlDialect): string {
|
||||
if (op.newName.trim().length === 0 || op.type !== ObjectType.TABLE) {
|
||||
return '';
|
||||
}
|
||||
const source: string = BatchOps.qualified(op.schema, op.name, dialect);
|
||||
const target: string = BatchOps.qualified(op.schema, op.newName, dialect);
|
||||
if (dialect === SqlDialect.SQLSERVER) {
|
||||
return 'SELECT * INTO ' + target + ' FROM ' + source + ';';
|
||||
}
|
||||
return 'CREATE TABLE ' + target + ' AS SELECT * FROM ' + source + ';';
|
||||
}
|
||||
|
||||
/** 生成单个操作对应的语句。 */
|
||||
static statementOf(op: BatchOp, dialect: SqlDialect): string {
|
||||
switch (op.kind) {
|
||||
case BatchOpKind.COPY:
|
||||
return BatchOps.copySql(op, dialect);
|
||||
case BatchOpKind.RENAME:
|
||||
return BatchOps.renameSql(op, dialect);
|
||||
case BatchOpKind.DROP:
|
||||
return BatchOps.dropSql(op, dialect);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 按「复制 → 重命名 → 删除」排序,保持组内原顺序。 */
|
||||
static orderForExecution(ops: BatchOp[]): BatchOp[] {
|
||||
const copies: BatchOp[] = [];
|
||||
const renames: BatchOp[] = [];
|
||||
const drops: BatchOp[] = [];
|
||||
for (let i = 0; i < ops.length; i++) {
|
||||
switch (ops[i].kind) {
|
||||
case BatchOpKind.COPY:
|
||||
copies.push(ops[i]);
|
||||
break;
|
||||
case BatchOpKind.RENAME:
|
||||
renames.push(ops[i]);
|
||||
break;
|
||||
default:
|
||||
drops.push(ops[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return copies.concat(renames).concat(drops);
|
||||
}
|
||||
|
||||
/** 批量生成语句(自动排序并过滤空语句)。 */
|
||||
static plan(ops: BatchOp[], dialect: SqlDialect): string[] {
|
||||
const ordered: BatchOp[] = BatchOps.orderForExecution(ops);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const sql: string = BatchOps.statementOf(ordered[i], dialect);
|
||||
if (sql.length > 0) {
|
||||
out.push(sql);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static qualified(schema: string, name: string, dialect: SqlDialect): string {
|
||||
return DdlGenerator.qualifiedName(schema, name, dialect);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 约束设计辅助(1.5.4 / 1.5.5)。
|
||||
*
|
||||
* 覆盖外键与检查约束的校验、命名冲突检测,以及外键的增删语句。
|
||||
* 建表时的约束内联由 DdlGenerator 负责;本类只处理单独的增删与校验。
|
||||
*/
|
||||
import {
|
||||
CheckConstraint,
|
||||
ColumnDef,
|
||||
ForeignKeyDef,
|
||||
TableDef
|
||||
} from './TableModel';
|
||||
import { DdlGenerator, SqlDialect } from './DdlGenerator';
|
||||
|
||||
export class ConstraintValidator {
|
||||
/** 约束名冲突(索引/外键/检查约束统一比较,大小写不敏感)。 */
|
||||
static duplicateNames(table: TableDef): string[] {
|
||||
const seen: Set<string> = new Set<string>();
|
||||
const dup: Set<string> = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < table.indexes.length; i++) {
|
||||
ConstraintValidator.account(table.indexes[i].name, seen, dup, out);
|
||||
}
|
||||
for (let i = 0; i < table.foreignKeys.length; i++) {
|
||||
ConstraintValidator.account(table.foreignKeys[i].name, seen, dup, out);
|
||||
}
|
||||
const checks: CheckConstraint[] = table.checks === undefined ? [] : table.checks;
|
||||
for (let i = 0; i < checks.length; i++) {
|
||||
ConstraintValidator.account(checks[i].name, seen, dup, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 外键校验(空名、空目标表、列数不匹配、引用未知列)。 */
|
||||
static validateForeignKeys(table: TableDef): string[] {
|
||||
const errors: string[] = [];
|
||||
const columns: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < table.columns.length; i++) {
|
||||
columns.add(table.columns[i].name.toLowerCase());
|
||||
}
|
||||
for (let i = 0; i < table.foreignKeys.length; i++) {
|
||||
const fk: ForeignKeyDef = table.foreignKeys[i];
|
||||
if (fk.name.trim().length === 0) {
|
||||
errors.push('foreign key name is required at index ' + i);
|
||||
}
|
||||
if (fk.refTable.trim().length === 0) {
|
||||
errors.push('foreign key ' + fk.name + ' has no referenced table');
|
||||
}
|
||||
if (fk.columns.length === 0) {
|
||||
errors.push('foreign key ' + fk.name + ' has no columns');
|
||||
}
|
||||
if (fk.columns.length !== fk.refColumns.length) {
|
||||
errors.push('foreign key ' + fk.name + ' column count mismatch');
|
||||
}
|
||||
for (let j = 0; j < fk.columns.length; j++) {
|
||||
if (!columns.has(fk.columns[j].toLowerCase())) {
|
||||
errors.push('foreign key ' + fk.name + ' references unknown column: ' + fk.columns[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 检查约束校验(空名、空表达式)。 */
|
||||
static validateChecks(checks: CheckConstraint[]): string[] {
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < checks.length; i++) {
|
||||
if (checks[i].name.trim().length === 0) {
|
||||
errors.push('check constraint name is required at index ' + i);
|
||||
}
|
||||
if (checks[i].expression.trim().length === 0) {
|
||||
errors.push('check constraint ' + checks[i].name + ' has empty expression');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static account(name: string, seen: Set<string>, dup: Set<string>, out: string[]): void {
|
||||
const key: string = name.trim().toLowerCase();
|
||||
if (key.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (seen.has(key)) {
|
||||
if (!dup.has(key)) {
|
||||
out.push(name);
|
||||
dup.add(key);
|
||||
}
|
||||
} else {
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 外键增删语句。 */
|
||||
export class ConstraintDdl {
|
||||
/** 添加外键;SQLite 不支持独立 ADD CONSTRAINT,返回空字符串。 */
|
||||
static addForeignKeySql(table: TableDef, fk: ForeignKeyDef, dialect: SqlDialect): string {
|
||||
if (dialect === SqlDialect.SQLITE) {
|
||||
return '';
|
||||
}
|
||||
const tableName: string = ConstraintDdl.tableName(table, dialect);
|
||||
const body: string = DdlGenerator.foreignKeySql(fk, dialect);
|
||||
return 'ALTER TABLE ' + tableName + ' ADD ' + body + ';';
|
||||
}
|
||||
|
||||
/** 删除外键;SQLite 返回空字符串。 */
|
||||
static dropForeignKeySql(table: TableDef, name: string, dialect: SqlDialect): string {
|
||||
if (dialect === SqlDialect.SQLITE) {
|
||||
return '';
|
||||
}
|
||||
const tableName: string = ConstraintDdl.tableName(table, dialect);
|
||||
if (dialect === SqlDialect.MYSQL) {
|
||||
return 'ALTER TABLE ' + tableName + ' DROP FOREIGN KEY ' + DdlGenerator.quote(name, dialect) + ';';
|
||||
}
|
||||
return 'ALTER TABLE ' + tableName + ' DROP CONSTRAINT ' + DdlGenerator.quote(name, dialect) + ';';
|
||||
}
|
||||
|
||||
private static tableName(table: TableDef, dialect: SqlDialect): string {
|
||||
if (table.schema.length === 0) {
|
||||
return DdlGenerator.quote(table.name, dialect);
|
||||
}
|
||||
return DdlGenerator.quote(table.schema, dialect) + '.' + DdlGenerator.quote(table.name, dialect);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* DDL 生成器(1.5.2)。
|
||||
*
|
||||
* 按方言输出 CREATE TABLE、索引与约束语句。纯逻辑,可离线测试。
|
||||
* 支持 MySQL / PostgreSQL / SQLite / SQL Server / Oracle 的标识符引用与常用类型映射;
|
||||
* 未覆盖的高级类型按基础映射降级,后续在 1.5.11(DDL 生成与预览)中扩展。
|
||||
*/
|
||||
import { CheckConstraint, ColumnDef, ColumnType, ForeignKeyDef, IndexDef, TableDef } from './TableModel';
|
||||
|
||||
export enum SqlDialect {
|
||||
MYSQL = 0,
|
||||
POSTGRESQL = 1,
|
||||
SQLITE = 2,
|
||||
SQLSERVER = 3,
|
||||
ORACLE = 4
|
||||
}
|
||||
|
||||
export class DdlGenerator {
|
||||
/** 标识符引用。 */
|
||||
static quote(name: string, dialect: SqlDialect): string {
|
||||
switch (dialect) {
|
||||
case SqlDialect.MYSQL:
|
||||
return '`' + name.replace(new RegExp('`', 'g'), '``') + '`';
|
||||
case SqlDialect.SQLSERVER:
|
||||
return '[' + name.replace(new RegExp('\\]', 'g'), ']]') + ']';
|
||||
default:
|
||||
return '"' + name.replace(new RegExp('"', 'g'), '""') + '"';
|
||||
}
|
||||
}
|
||||
|
||||
/** 列类型映射。 */
|
||||
static typeSql(column: ColumnDef, dialect: SqlDialect): string {
|
||||
switch (column.type) {
|
||||
case ColumnType.INT:
|
||||
return dialect === SqlDialect.ORACLE ? 'NUMBER(10)' : 'INT';
|
||||
case ColumnType.BIGINT:
|
||||
return dialect === SqlDialect.ORACLE ? 'NUMBER(19)' : 'BIGINT';
|
||||
case ColumnType.VARCHAR: {
|
||||
const len: number = column.length > 0 ? column.length : 255;
|
||||
if (dialect === SqlDialect.ORACLE) {
|
||||
return 'VARCHAR2(' + len + ')';
|
||||
}
|
||||
return 'VARCHAR(' + len + ')';
|
||||
}
|
||||
case ColumnType.TEXT:
|
||||
if (dialect === SqlDialect.SQLSERVER) {
|
||||
return 'NVARCHAR(MAX)';
|
||||
}
|
||||
if (dialect === SqlDialect.ORACLE) {
|
||||
return 'CLOB';
|
||||
}
|
||||
return 'TEXT';
|
||||
case ColumnType.DECIMAL: {
|
||||
const precision: number = column.precision > 0 ? column.precision : 18;
|
||||
const scale: number = column.scale >= 0 ? column.scale : 0;
|
||||
if (dialect === SqlDialect.ORACLE) {
|
||||
return 'NUMBER(' + precision + ',' + scale + ')';
|
||||
}
|
||||
return 'DECIMAL(' + precision + ',' + scale + ')';
|
||||
}
|
||||
case ColumnType.BOOLEAN:
|
||||
if (dialect === SqlDialect.MYSQL) {
|
||||
return 'TINYINT(1)';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLITE) {
|
||||
return 'INTEGER';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLSERVER) {
|
||||
return 'BIT';
|
||||
}
|
||||
if (dialect === SqlDialect.ORACLE) {
|
||||
return 'NUMBER(1)';
|
||||
}
|
||||
return 'BOOLEAN';
|
||||
case ColumnType.DATE:
|
||||
return 'DATE';
|
||||
case ColumnType.TIMESTAMP:
|
||||
if (dialect === SqlDialect.MYSQL) {
|
||||
return 'DATETIME';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLITE) {
|
||||
return 'TEXT';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLSERVER) {
|
||||
return 'DATETIME2';
|
||||
}
|
||||
return 'TIMESTAMP';
|
||||
case ColumnType.BLOB:
|
||||
if (dialect === SqlDialect.POSTGRESQL) {
|
||||
return 'BYTEA';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLSERVER) {
|
||||
return 'VARBINARY(MAX)';
|
||||
}
|
||||
return 'BLOB';
|
||||
case ColumnType.JSON:
|
||||
if (dialect === SqlDialect.MYSQL) {
|
||||
return 'JSON';
|
||||
}
|
||||
if (dialect === SqlDialect.POSTGRESQL) {
|
||||
return 'JSONB';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLITE) {
|
||||
return 'TEXT';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLSERVER) {
|
||||
return 'NVARCHAR(MAX)';
|
||||
}
|
||||
return 'CLOB';
|
||||
default:
|
||||
return 'TEXT';
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认值表达式。 */
|
||||
static defaultSql(column: ColumnDef): string {
|
||||
if (column.defaultValue.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const numeric: boolean = column.type === ColumnType.INT || column.type === ColumnType.BIGINT ||
|
||||
column.type === ColumnType.DECIMAL || column.type === ColumnType.BOOLEAN;
|
||||
if (numeric && column.defaultValue.toLowerCase() !== 'null') {
|
||||
return ' DEFAULT ' + column.defaultValue;
|
||||
}
|
||||
return ' DEFAULT \'' + column.defaultValue.replace(new RegExp('\'', 'g'), '\'\'') + '\'';
|
||||
}
|
||||
|
||||
/** 列定义。 */
|
||||
static columnSql(column: ColumnDef, dialect: SqlDialect): string {
|
||||
let sql: string = DdlGenerator.quote(column.name, dialect) + ' ' + DdlGenerator.typeSql(column, dialect);
|
||||
sql += column.nullable ? ' NULL' : ' NOT NULL';
|
||||
sql += DdlGenerator.defaultSql(column);
|
||||
return sql;
|
||||
}
|
||||
|
||||
/** 完整 DDL:建表 + 索引。 */
|
||||
static generate(table: TableDef, dialect: SqlDialect): string {
|
||||
const tableName: string = DdlGenerator.qualifiedName(table.schema, table.name, dialect);
|
||||
const parts: string[] = [];
|
||||
const pkColumns: string[] = [];
|
||||
for (let i = 0; i < table.columns.length; i++) {
|
||||
if (table.columns[i].primaryKey) {
|
||||
pkColumns.push(table.columns[i].name);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < table.columns.length; i++) {
|
||||
parts.push(DdlGenerator.columnSql(table.columns[i], dialect));
|
||||
}
|
||||
if (pkColumns.length > 0) {
|
||||
parts.push('PRIMARY KEY (' + DdlGenerator.columnList(pkColumns, dialect) + ')');
|
||||
}
|
||||
for (let i = 0; i < table.foreignKeys.length; i++) {
|
||||
parts.push(DdlGenerator.foreignKeySql(table.foreignKeys[i], dialect));
|
||||
}
|
||||
const checks: CheckConstraint[] = table.checks === undefined ? [] : table.checks;
|
||||
for (let i = 0; i < checks.length; i++) {
|
||||
parts.push('CONSTRAINT ' + DdlGenerator.quote(checks[i].name, dialect) +
|
||||
' CHECK (' + checks[i].expression + ')');
|
||||
}
|
||||
const statements: string[] = [];
|
||||
statements.push('CREATE TABLE ' + tableName + ' (\n ' + parts.join(',\n ') + '\n);');
|
||||
for (let i = 0; i < table.indexes.length; i++) {
|
||||
statements.push(DdlGenerator.indexSql(table, table.indexes[i], dialect));
|
||||
}
|
||||
return statements.join('\n');
|
||||
}
|
||||
|
||||
/** 索引语句。 */
|
||||
static indexSql(table: TableDef, index: IndexDef, dialect: SqlDialect): string {
|
||||
const tableName: string = DdlGenerator.qualifiedName(table.schema, table.name, dialect);
|
||||
const unique: string = index.unique ? 'UNIQUE ' : '';
|
||||
return 'CREATE ' + unique + 'INDEX ' + DdlGenerator.quote(index.name, dialect) + ' ON ' + tableName +
|
||||
' (' + DdlGenerator.columnList(index.columns, dialect) + ');';
|
||||
}
|
||||
|
||||
/** 外键约束片段。 */
|
||||
static foreignKeySql(fk: ForeignKeyDef, dialect: SqlDialect): string {
|
||||
return 'CONSTRAINT ' + DdlGenerator.quote(fk.name, dialect) + ' FOREIGN KEY (' +
|
||||
DdlGenerator.columnList(fk.columns, dialect) + ') REFERENCES ' +
|
||||
DdlGenerator.quote(fk.refTable, dialect) + ' (' + DdlGenerator.columnList(fk.refColumns, dialect) + ')';
|
||||
}
|
||||
|
||||
private static columnList(columns: string[], dialect: SqlDialect): string {
|
||||
const quoted: string[] = [];
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
quoted.push(DdlGenerator.quote(columns[i], dialect));
|
||||
}
|
||||
return quoted.join(', ');
|
||||
}
|
||||
|
||||
/** 限定名:有 schema 时输出 schema.name。 */
|
||||
static qualifiedName(schema: string, name: string, dialect: SqlDialect): string {
|
||||
if (schema.length === 0) {
|
||||
return DdlGenerator.quote(name, dialect);
|
||||
}
|
||||
return DdlGenerator.quote(schema, dialect) + '.' + DdlGenerator.quote(name, dialect);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 索引设计辅助(1.5.3)。
|
||||
*
|
||||
* 提供索引签名、重复索引检测、命名建议与建/删索引语句。
|
||||
* 建索引语句复用 DdlGenerator;删索引按方言差异单独处理。
|
||||
*/
|
||||
import { IndexDef, TableDef } from './TableModel';
|
||||
import { DdlGenerator, SqlDialect } from './DdlGenerator';
|
||||
|
||||
export class IndexModel {
|
||||
/** 索引签名:列顺序敏感,唯一性参与签名。 */
|
||||
static signatureOf(index: IndexDef): string {
|
||||
const cols: string[] = [];
|
||||
for (let i = 0; i < index.columns.length; i++) {
|
||||
cols.push(index.columns[i].trim().toLowerCase());
|
||||
}
|
||||
return cols.join(',') + '|' + (index.unique ? 'u' : 'n');
|
||||
}
|
||||
|
||||
/** 找出重复的索引签名(同一组列 + 同一唯一性)。 */
|
||||
static duplicates(indexes: IndexDef[]): string[] {
|
||||
const seen: Set<string> = new Set<string>();
|
||||
const reported: Set<string> = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < indexes.length; i++) {
|
||||
const sig: string = IndexModel.signatureOf(indexes[i]);
|
||||
if (seen.has(sig)) {
|
||||
if (!reported.has(sig)) {
|
||||
out.push(sig);
|
||||
reported.add(sig);
|
||||
}
|
||||
} else {
|
||||
seen.add(sig);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 命名建议:idx_<table>_<columns>[_u],非字母数字转下划线。 */
|
||||
static suggestName(table: string, columns: string[], unique: boolean): string {
|
||||
const parts: string[] = [];
|
||||
parts.push('idx');
|
||||
parts.push(IndexModel.slug(table));
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
parts.push(IndexModel.slug(columns[i]));
|
||||
}
|
||||
let name: string = parts.join('_');
|
||||
if (unique) {
|
||||
name += '_u';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 建索引语句(委托 DdlGenerator)。 */
|
||||
static createSql(table: TableDef, index: IndexDef, dialect: SqlDialect): string {
|
||||
return DdlGenerator.indexSql(table, index, dialect);
|
||||
}
|
||||
|
||||
/** 删索引语句:MySQL/SQL Server 需要表名,其余只需索引名。 */
|
||||
static dropSql(table: TableDef, indexName: string, dialect: SqlDialect): string {
|
||||
const quotedIndex: string = DdlGenerator.quote(indexName, dialect);
|
||||
if (dialect === SqlDialect.MYSQL) {
|
||||
const tableName: string = table.schema.length > 0
|
||||
? DdlGenerator.quote(table.schema, dialect) + '.' + DdlGenerator.quote(table.name, dialect)
|
||||
: DdlGenerator.quote(table.name, dialect);
|
||||
return 'DROP INDEX ' + quotedIndex + ' ON ' + tableName + ';';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLSERVER) {
|
||||
const tableName: string = table.schema.length > 0
|
||||
? DdlGenerator.quote(table.schema, dialect) + '.' + DdlGenerator.quote(table.name, dialect)
|
||||
: DdlGenerator.quote(table.name, dialect);
|
||||
return 'DROP INDEX ' + quotedIndex + ' ON ' + tableName + ';';
|
||||
}
|
||||
return 'DROP INDEX ' + quotedIndex + ';';
|
||||
}
|
||||
|
||||
private static slug(text: string): string {
|
||||
const lower: string = text.trim().toLowerCase();
|
||||
let out: string = '';
|
||||
for (let i = 0; i < lower.length; i++) {
|
||||
const ch: string = lower.charAt(i);
|
||||
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
|
||||
out += ch;
|
||||
} else if (out.length > 0 && out.charAt(out.length - 1) !== '_') {
|
||||
out += '_';
|
||||
}
|
||||
}
|
||||
while (out.length > 0 && out.charAt(out.length - 1) === '_') {
|
||||
out = out.substring(0, out.length - 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 其余对象 DDL(1.5.9):序列、同义词、枚举类型。
|
||||
*
|
||||
* 各引擎支持情况不同:序列在 MySQL/SQLite 无原生支持;同义词仅 Oracle;
|
||||
* 枚举类型此处只生成 PostgreSQL 的 CREATE TYPE ... AS ENUM。
|
||||
* 不支持时返回空字符串,由 UI 按能力矩阵隐藏入口。
|
||||
*/
|
||||
import { DdlGenerator, SqlDialect } from './DdlGenerator';
|
||||
|
||||
export interface SequenceDef {
|
||||
schema: string;
|
||||
name: string;
|
||||
start: number;
|
||||
increment: number;
|
||||
cycle: boolean;
|
||||
}
|
||||
|
||||
export class MiscObjectDdl {
|
||||
/** 创建序列;MySQL/SQLite 返回空字符串。 */
|
||||
static createSequenceSql(def: SequenceDef, dialect: SqlDialect): string {
|
||||
if (dialect === SqlDialect.MYSQL || dialect === SqlDialect.SQLITE) {
|
||||
return '';
|
||||
}
|
||||
const name: string = DdlGenerator.qualifiedName(def.schema, def.name, dialect);
|
||||
const cycle: string = def.cycle ? ' CYCLE' : ' NOCYCLE';
|
||||
return 'CREATE SEQUENCE ' + name + ' START WITH ' + def.start +
|
||||
' INCREMENT BY ' + def.increment + cycle + ';';
|
||||
}
|
||||
|
||||
/** 创建同义词;仅 Oracle 支持。 */
|
||||
static createSynonymSql(synonym: string, target: string, schema: string, dialect: SqlDialect): string {
|
||||
if (dialect !== SqlDialect.ORACLE) {
|
||||
return '';
|
||||
}
|
||||
const name: string = DdlGenerator.qualifiedName(schema, synonym, dialect);
|
||||
return 'CREATE SYNONYM ' + name + ' FOR ' + target.trim() + ';';
|
||||
}
|
||||
|
||||
/** 创建枚举类型;仅 PostgreSQL。 */
|
||||
static createEnumTypeSql(name: string, values: string[], dialect: SqlDialect): string {
|
||||
if (dialect !== SqlDialect.POSTGRESQL || values.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const quoted: string[] = [];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
quoted.push('\'' + values[i].replace(new RegExp('\'', 'g'), '\'\'') + '\'');
|
||||
}
|
||||
return 'CREATE TYPE ' + DdlGenerator.quote(name, dialect) + ' AS ENUM (' + quoted.join(', ') + ');';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 对象比较(1.5.17)。
|
||||
*
|
||||
* 两类对象:
|
||||
* - 表:复用 TableDiff 的结构差异,输出可读摘要;
|
||||
* - 视图/过程/函数/触发器:比较定义文本(TextDiff)。
|
||||
*/
|
||||
import { TableDef } from './TableModel';
|
||||
import { ChangeKind, TableChange, TableDiff } from './TableDiff';
|
||||
import { DiffLine, TextDiff } from './TextDiff';
|
||||
|
||||
export interface ObjectCompareSummary {
|
||||
identical: boolean;
|
||||
/** 结构差异摘要(表对象)。 */
|
||||
structureChanges: string[];
|
||||
/** 文本差异(定义类对象)。 */
|
||||
textChanges: DiffLine[];
|
||||
}
|
||||
|
||||
export class ObjectComparator {
|
||||
/** 表结构比较。 */
|
||||
static compareTables(current: TableDef, target: TableDef): ObjectCompareSummary {
|
||||
const changes: TableChange[] = TableDiff.compare(current, target);
|
||||
const summary: string[] = [];
|
||||
for (let i = 0; i < changes.length; i++) {
|
||||
summary.push(ObjectComparator.describe(changes[i]));
|
||||
}
|
||||
return { identical: summary.length === 0, structureChanges: summary, textChanges: [] };
|
||||
}
|
||||
|
||||
/** 定义文本比较(视图/过程/函数/触发器)。 */
|
||||
static compareDefinitions(currentText: string, targetText: string): ObjectCompareSummary {
|
||||
const identical: boolean = TextDiff.identical(currentText, targetText);
|
||||
return {
|
||||
identical: identical,
|
||||
structureChanges: [],
|
||||
textChanges: identical ? [] : TextDiff.compare(currentText, targetText)
|
||||
};
|
||||
}
|
||||
|
||||
private static describe(change: TableChange): string {
|
||||
switch (change.kind) {
|
||||
case ChangeKind.ADD_COLUMN:
|
||||
return '新增列: ' + change.name;
|
||||
case ChangeKind.DROP_COLUMN:
|
||||
return '删除列: ' + change.name;
|
||||
case ChangeKind.ALTER_COLUMN:
|
||||
return '修改列: ' + change.name;
|
||||
case ChangeKind.ADD_INDEX:
|
||||
return '新增索引: ' + change.name;
|
||||
case ChangeKind.DROP_INDEX:
|
||||
return '删除索引: ' + change.name;
|
||||
case ChangeKind.ADD_FOREIGN_KEY:
|
||||
return '新增外键: ' + change.name;
|
||||
case ChangeKind.DROP_FOREIGN_KEY:
|
||||
return '删除外键: ' + change.name;
|
||||
case ChangeKind.ADD_CHECK:
|
||||
return '新增检查约束: ' + change.name;
|
||||
case ChangeKind.DROP_CHECK:
|
||||
return '删除检查约束: ' + change.name;
|
||||
default:
|
||||
return change.detail + ': ' + change.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 存储过程与函数 DDL(1.5.7)。
|
||||
*
|
||||
* 仅生成对象定义骨架:参数列表按用户输入的 SQL 片段原样拼接,函数体按方言包裹。
|
||||
* MySQL 使用 BEGIN ... END,其余引擎使用 AS <body>;调试与执行在驱动层完成。
|
||||
*/
|
||||
import { DdlGenerator, SqlDialect } from './DdlGenerator';
|
||||
|
||||
export enum RoutineKind {
|
||||
PROCEDURE = 0,
|
||||
FUNCTION = 1
|
||||
}
|
||||
|
||||
export interface RoutineDef {
|
||||
schema: string;
|
||||
name: string;
|
||||
kind: RoutineKind;
|
||||
/** 原始参数片段,如 "IN a INT, IN b VARCHAR(20)"。 */
|
||||
params: string;
|
||||
/** 函数返回类型;过程忽略。 */
|
||||
returns: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export class RoutineDdl {
|
||||
static validate(def: RoutineDef): string[] {
|
||||
const errors: string[] = [];
|
||||
if (def.name.trim().length === 0) {
|
||||
errors.push('routine name is required');
|
||||
}
|
||||
if (def.body.trim().length === 0) {
|
||||
errors.push('routine body is required');
|
||||
}
|
||||
if (def.kind === RoutineKind.FUNCTION && def.returns.trim().length === 0) {
|
||||
errors.push('function return type is required');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
static createSql(def: RoutineDef, dialect: SqlDialect): string {
|
||||
const name: string = DdlGenerator.qualifiedName(def.schema, def.name, dialect);
|
||||
const params: string = '(' + def.params.trim() + ')';
|
||||
const rawBody: string = def.body.trim();
|
||||
const isFunction: boolean = def.kind === RoutineKind.FUNCTION;
|
||||
const keyword: string = isFunction ? 'FUNCTION' : 'PROCEDURE';
|
||||
const returns: string = isFunction ? ' RETURNS ' + def.returns.trim() : '';
|
||||
if (dialect === SqlDialect.MYSQL) {
|
||||
return 'CREATE ' + keyword + ' ' + name + ' ' + params + returns +
|
||||
'\nBEGIN\n' + rawBody + '\nEND;';
|
||||
}
|
||||
// 非 MySQL 引擎用 AS <body>,去掉用户输入末尾的重复分号,避免生成 ";;"
|
||||
const body: string = rawBody.replace(new RegExp(';+$'), '');
|
||||
return 'CREATE OR REPLACE ' + keyword + ' ' + name + ' ' + params + returns +
|
||||
' AS\n' + body + ';';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 对象容器 DDL(1.5.10):模式/库、表空间、分区。
|
||||
*
|
||||
* 各引擎概念不同:
|
||||
* - MySQL:schema 即 database;
|
||||
* - PostgreSQL / SQL Server:schema 是库内命名空间;
|
||||
* - Oracle:schema 即用户,无独立 CREATE SCHEMA;表空间有原生支持;
|
||||
* - SQLite:无 schema(用附加库),无表空间与分区。
|
||||
* 不支持时返回空字符串,由 UI 按能力矩阵隐藏入口。
|
||||
*/
|
||||
import { DdlGenerator, SqlDialect } from './DdlGenerator';
|
||||
|
||||
export enum PartitionMethod {
|
||||
RANGE = 0,
|
||||
LIST = 1,
|
||||
HASH = 2
|
||||
}
|
||||
|
||||
export interface PartitionDef {
|
||||
column: string;
|
||||
method: PartitionMethod;
|
||||
/** HASH 分区数量;其它方式忽略。 */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export class SpaceDdl {
|
||||
/** 创建模式(MySQL 为 CREATE DATABASE)。 */
|
||||
static createSchemaSql(name: string, dialect: SqlDialect): string {
|
||||
if (dialect === SqlDialect.MYSQL) {
|
||||
return 'CREATE DATABASE ' + DdlGenerator.quote(name, dialect) + ';';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLITE || dialect === SqlDialect.ORACLE) {
|
||||
return '';
|
||||
}
|
||||
return 'CREATE SCHEMA ' + DdlGenerator.quote(name, dialect) + ';';
|
||||
}
|
||||
|
||||
/** 删除模式。 */
|
||||
static dropSchemaSql(name: string, dialect: SqlDialect): string {
|
||||
if (dialect === SqlDialect.MYSQL) {
|
||||
return 'DROP DATABASE ' + DdlGenerator.quote(name, dialect) + ';';
|
||||
}
|
||||
if (dialect === SqlDialect.SQLITE || dialect === SqlDialect.ORACLE) {
|
||||
return '';
|
||||
}
|
||||
return 'DROP SCHEMA ' + DdlGenerator.quote(name, dialect) + ';';
|
||||
}
|
||||
|
||||
/** 创建表空间;MySQL/SQL Server 返回空字符串(用文件组/NDB 表空间另行处理)。 */
|
||||
static createTablespaceSql(name: string, path: string, dialect: SqlDialect): string {
|
||||
const quotedName: string = DdlGenerator.quote(name, dialect);
|
||||
if (dialect === SqlDialect.POSTGRESQL) {
|
||||
return 'CREATE TABLESPACE ' + quotedName + ' LOCATION \'' + path.trim() + '\';';
|
||||
}
|
||||
if (dialect === SqlDialect.ORACLE) {
|
||||
return 'CREATE TABLESPACE ' + quotedName + ' DATAFILE \'' + path.trim() + '\' SIZE 100M;';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 分区子句(拼在 CREATE TABLE 末尾)。 */
|
||||
static partitionClause(def: PartitionDef, dialect: SqlDialect): string {
|
||||
if (dialect === SqlDialect.SQLITE || dialect === SqlDialect.SQLSERVER) {
|
||||
return '';
|
||||
}
|
||||
const column: string = DdlGenerator.quote(def.column, dialect);
|
||||
const method: string = SpaceDdl.methodName(def.method);
|
||||
if (def.method === PartitionMethod.HASH && dialect === SqlDialect.MYSQL && def.count > 0) {
|
||||
return 'PARTITION BY HASH (' + column + ') PARTITIONS ' + def.count;
|
||||
}
|
||||
return 'PARTITION BY ' + method + ' (' + column + ')';
|
||||
}
|
||||
|
||||
private static methodName(method: PartitionMethod): string {
|
||||
switch (method) {
|
||||
case PartitionMethod.LIST:
|
||||
return 'LIST';
|
||||
case PartitionMethod.HASH:
|
||||
return 'HASH';
|
||||
default:
|
||||
return 'RANGE';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 表结构差异与 ALTER 生成(1.5.11)。
|
||||
*
|
||||
* compare(current, target):按列、索引、外键、检查约束四类比对,产出变更列表;
|
||||
* toAlterSql(current, target, dialect):把变更翻译为 ALTER 语句(按方言差异处理)。
|
||||
*
|
||||
* 匹配规则:
|
||||
* - 列:按名称(大小写不敏感)匹配;类型/长度/精度/可空/默认值任一不同视为修改;
|
||||
* - 索引:按「列组合 + 唯一性」匹配(索引名变化不产生变更);
|
||||
* - 外键:按「列 + 目标表 + 目标列」匹配;
|
||||
* - 检查约束:按「名称 + 表达式」匹配。
|
||||
*/
|
||||
import {
|
||||
CheckConstraint,
|
||||
ColumnDef,
|
||||
ForeignKeyDef,
|
||||
IndexDef,
|
||||
TableDef
|
||||
} from './TableModel';
|
||||
import { DdlGenerator, SqlDialect } from './DdlGenerator';
|
||||
import { IndexModel } from './IndexModel';
|
||||
import { ConstraintDdl } from './ConstraintModel';
|
||||
|
||||
export enum ChangeKind {
|
||||
ADD_COLUMN = 0,
|
||||
DROP_COLUMN = 1,
|
||||
ALTER_COLUMN = 2,
|
||||
ADD_INDEX = 3,
|
||||
DROP_INDEX = 4,
|
||||
ADD_FOREIGN_KEY = 5,
|
||||
DROP_FOREIGN_KEY = 6,
|
||||
ADD_CHECK = 7,
|
||||
DROP_CHECK = 8
|
||||
}
|
||||
|
||||
export interface TableChange {
|
||||
kind: ChangeKind;
|
||||
/** 展示名称(列名/索引名/约束名)。 */
|
||||
name: string;
|
||||
/** 变更说明,用于预览列表。 */
|
||||
detail: string;
|
||||
column?: ColumnDef;
|
||||
index?: IndexDef;
|
||||
foreignKey?: ForeignKeyDef;
|
||||
check?: CheckConstraint;
|
||||
}
|
||||
|
||||
export class TableDiff {
|
||||
/** 计算差异(确定性顺序:列 → 索引 → 外键 → 检查)。 */
|
||||
static compare(current: TableDef, target: TableDef): TableChange[] {
|
||||
const out: TableChange[] = [];
|
||||
TableDiff.diffColumns(current, target, out);
|
||||
TableDiff.diffIndexes(current, target, out);
|
||||
TableDiff.diffForeignKeys(current, target, out);
|
||||
TableDiff.diffChecks(current, target, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
static hasChanges(changes: TableChange[]): boolean {
|
||||
return changes.length > 0;
|
||||
}
|
||||
|
||||
/** 生成 ALTER 语句数组。 */
|
||||
static toAlterSql(current: TableDef, target: TableDef, dialect: SqlDialect): string[] {
|
||||
const changes: TableChange[] = TableDiff.compare(current, target);
|
||||
const tableName: string = DdlGenerator.qualifiedName(target.schema.length > 0 ? target.schema : current.schema,
|
||||
target.name, dialect);
|
||||
const statements: string[] = [];
|
||||
for (let i = 0; i < changes.length; i++) {
|
||||
const change: TableChange = changes[i];
|
||||
switch (change.kind) {
|
||||
case ChangeKind.ADD_COLUMN: {
|
||||
const column: ColumnDef = change.column as ColumnDef;
|
||||
if (dialect === SqlDialect.ORACLE) {
|
||||
statements.push('ALTER TABLE ' + tableName + ' ADD (' + DdlGenerator.columnSql(column, dialect) + ');');
|
||||
} else {
|
||||
statements.push('ALTER TABLE ' + tableName + ' ADD COLUMN ' + DdlGenerator.columnSql(column, dialect) + ';');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ChangeKind.DROP_COLUMN:
|
||||
statements.push('ALTER TABLE ' + tableName + ' DROP COLUMN ' +
|
||||
DdlGenerator.quote(change.name, dialect) + ';');
|
||||
break;
|
||||
case ChangeKind.ALTER_COLUMN: {
|
||||
const targetColumn: ColumnDef = change.column as ColumnDef;
|
||||
const oldColumn: ColumnDef | undefined = TableDiff.findColumn(current, targetColumn.name);
|
||||
TableDiff.alterColumnSql(tableName, oldColumn, targetColumn, dialect, statements);
|
||||
break;
|
||||
}
|
||||
case ChangeKind.ADD_INDEX:
|
||||
statements.push(IndexModel.createSql(target, change.index as IndexDef, dialect));
|
||||
break;
|
||||
case ChangeKind.DROP_INDEX:
|
||||
statements.push(IndexModel.dropSql(target, (change.index as IndexDef).name, dialect));
|
||||
break;
|
||||
case ChangeKind.ADD_FOREIGN_KEY:
|
||||
statements.push(ConstraintDdl.addForeignKeySql(target, change.foreignKey as ForeignKeyDef, dialect));
|
||||
break;
|
||||
case ChangeKind.DROP_FOREIGN_KEY:
|
||||
statements.push(ConstraintDdl.dropForeignKeySql(target, (change.foreignKey as ForeignKeyDef).name, dialect));
|
||||
break;
|
||||
case ChangeKind.ADD_CHECK:
|
||||
if (dialect === SqlDialect.SQLITE) {
|
||||
// SQLite 不支持 ADD CONSTRAINT,需要重建表
|
||||
statements.push('');
|
||||
break;
|
||||
}
|
||||
statements.push('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' +
|
||||
DdlGenerator.quote((change.check as CheckConstraint).name, dialect) +
|
||||
' CHECK (' + (change.check as CheckConstraint).expression + ');');
|
||||
break;
|
||||
case ChangeKind.DROP_CHECK:
|
||||
if (dialect === SqlDialect.SQLITE) {
|
||||
statements.push('');
|
||||
} else if (dialect === SqlDialect.MYSQL) {
|
||||
statements.push('ALTER TABLE ' + tableName + ' DROP CHECK ' +
|
||||
DdlGenerator.quote(change.name, dialect) + ';');
|
||||
} else {
|
||||
statements.push('ALTER TABLE ' + tableName + ' DROP CONSTRAINT ' +
|
||||
DdlGenerator.quote(change.name, dialect) + ';');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
const filtered: string[] = [];
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
if (statements[i].length > 0) {
|
||||
filtered.push(statements[i]);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static diffColumns(current: TableDef, target: TableDef, out: TableChange[]): void {
|
||||
const currentByName: Map<string, ColumnDef> = new Map<string, ColumnDef>();
|
||||
for (let i = 0; i < current.columns.length; i++) {
|
||||
currentByName.set(current.columns[i].name.toLowerCase(), current.columns[i]);
|
||||
}
|
||||
for (let i = 0; i < target.columns.length; i++) {
|
||||
const column: ColumnDef = target.columns[i];
|
||||
const old: ColumnDef | undefined = currentByName.get(column.name.toLowerCase());
|
||||
if (old === undefined) {
|
||||
out.push({ kind: ChangeKind.ADD_COLUMN, name: column.name, detail: '新增列', column: column });
|
||||
} else if (!TableDiff.sameColumn(old, column)) {
|
||||
out.push({ kind: ChangeKind.ALTER_COLUMN, name: column.name, detail: '修改列定义', column: column });
|
||||
}
|
||||
currentByName.delete(column.name.toLowerCase());
|
||||
}
|
||||
currentByName.forEach((oldColumn: ColumnDef) => {
|
||||
out.push({ kind: ChangeKind.DROP_COLUMN, name: oldColumn.name, detail: '删除列', column: oldColumn });
|
||||
});
|
||||
}
|
||||
|
||||
private static diffIndexes(current: TableDef, target: TableDef, out: TableChange[]): void {
|
||||
const currentSigs: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < current.indexes.length; i++) {
|
||||
currentSigs.add(TableDiff.indexSignature(current.indexes[i]));
|
||||
}
|
||||
const targetSigs: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < target.indexes.length; i++) {
|
||||
targetSigs.add(TableDiff.indexSignature(target.indexes[i]));
|
||||
if (!currentSigs.has(TableDiff.indexSignature(target.indexes[i]))) {
|
||||
out.push({ kind: ChangeKind.ADD_INDEX, name: target.indexes[i].name, detail: '新增索引', index: target.indexes[i] });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < current.indexes.length; i++) {
|
||||
if (!targetSigs.has(TableDiff.indexSignature(current.indexes[i]))) {
|
||||
out.push({ kind: ChangeKind.DROP_INDEX, name: current.indexes[i].name, detail: '删除索引', index: current.indexes[i] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static diffForeignKeys(current: TableDef, target: TableDef, out: TableChange[]): void {
|
||||
const currentSigs: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < current.foreignKeys.length; i++) {
|
||||
currentSigs.add(TableDiff.fkSignature(current.foreignKeys[i]));
|
||||
}
|
||||
const targetSigs: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < target.foreignKeys.length; i++) {
|
||||
targetSigs.add(TableDiff.fkSignature(target.foreignKeys[i]));
|
||||
if (!currentSigs.has(TableDiff.fkSignature(target.foreignKeys[i]))) {
|
||||
out.push({ kind: ChangeKind.ADD_FOREIGN_KEY, name: target.foreignKeys[i].name, detail: '新增外键', foreignKey: target.foreignKeys[i] });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < current.foreignKeys.length; i++) {
|
||||
if (!targetSigs.has(TableDiff.fkSignature(current.foreignKeys[i]))) {
|
||||
out.push({ kind: ChangeKind.DROP_FOREIGN_KEY, name: current.foreignKeys[i].name, detail: '删除外键', foreignKey: current.foreignKeys[i] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static diffChecks(current: TableDef, target: TableDef, out: TableChange[]): void {
|
||||
const currentChecks: CheckConstraint[] = current.checks === undefined ? [] : current.checks;
|
||||
const targetChecks: CheckConstraint[] = target.checks === undefined ? [] : target.checks;
|
||||
const currentSigs: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < currentChecks.length; i++) {
|
||||
currentSigs.add(TableDiff.checkSignature(currentChecks[i]));
|
||||
}
|
||||
const targetSigs: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < targetChecks.length; i++) {
|
||||
targetSigs.add(TableDiff.checkSignature(targetChecks[i]));
|
||||
if (!currentSigs.has(TableDiff.checkSignature(targetChecks[i]))) {
|
||||
out.push({ kind: ChangeKind.ADD_CHECK, name: targetChecks[i].name, detail: '新增检查约束', check: targetChecks[i] });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < currentChecks.length; i++) {
|
||||
if (!targetSigs.has(TableDiff.checkSignature(currentChecks[i]))) {
|
||||
out.push({ kind: ChangeKind.DROP_CHECK, name: currentChecks[i].name, detail: '删除检查约束', check: currentChecks[i] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static alterColumnSql(tableName: string, oldColumn: ColumnDef | undefined,
|
||||
targetColumn: ColumnDef, dialect: SqlDialect, out: string[]): void {
|
||||
if (dialect === SqlDialect.MYSQL) {
|
||||
out.push('ALTER TABLE ' + tableName + ' MODIFY COLUMN ' + DdlGenerator.columnSql(targetColumn, dialect) + ';');
|
||||
return;
|
||||
}
|
||||
if (dialect === SqlDialect.ORACLE) {
|
||||
out.push('ALTER TABLE ' + tableName + ' MODIFY (' + DdlGenerator.columnSql(targetColumn, dialect) + ');');
|
||||
return;
|
||||
}
|
||||
if (dialect === SqlDialect.SQLSERVER) {
|
||||
out.push('ALTER TABLE ' + tableName + ' ALTER COLUMN ' + DdlGenerator.columnSql(targetColumn, dialect) + ';');
|
||||
return;
|
||||
}
|
||||
if (dialect === SqlDialect.SQLITE) {
|
||||
// SQLite 不支持 ALTER COLUMN,需重建表;此处返回提示性空语句由 UI 处理
|
||||
out.push('');
|
||||
return;
|
||||
}
|
||||
// PostgreSQL:按差异逐条处理
|
||||
const quoted: string = DdlGenerator.quote(targetColumn.name, dialect);
|
||||
if (oldColumn === undefined || DdlGenerator.typeSql(oldColumn, dialect) !== DdlGenerator.typeSql(targetColumn, dialect)) {
|
||||
out.push('ALTER TABLE ' + tableName + ' ALTER COLUMN ' + quoted + ' TYPE ' +
|
||||
DdlGenerator.typeSql(targetColumn, dialect) + ';');
|
||||
}
|
||||
if (oldColumn === undefined || oldColumn.nullable !== targetColumn.nullable) {
|
||||
out.push('ALTER TABLE ' + tableName + ' ALTER COLUMN ' + quoted +
|
||||
(targetColumn.nullable ? ' DROP NOT NULL;' : ' SET NOT NULL;'));
|
||||
}
|
||||
if (oldColumn === undefined || oldColumn.defaultValue !== targetColumn.defaultValue) {
|
||||
if (targetColumn.defaultValue.length === 0) {
|
||||
out.push('ALTER TABLE ' + tableName + ' ALTER COLUMN ' + quoted + ' DROP DEFAULT;');
|
||||
} else {
|
||||
out.push('ALTER TABLE ' + tableName + ' ALTER COLUMN ' + quoted + DdlGenerator.defaultSql(targetColumn) + ';');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static findColumn(table: TableDef, name: string): ColumnDef | undefined {
|
||||
for (let i = 0; i < table.columns.length; i++) {
|
||||
if (table.columns[i].name.toLowerCase() === name.toLowerCase()) {
|
||||
return table.columns[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static sameColumn(a: ColumnDef, b: ColumnDef): boolean {
|
||||
return a.type === b.type && a.length === b.length && a.precision === b.precision &&
|
||||
a.scale === b.scale && a.nullable === b.nullable && a.defaultValue === b.defaultValue;
|
||||
}
|
||||
|
||||
private static indexSignature(index: IndexDef): string {
|
||||
const cols: string[] = [];
|
||||
for (let i = 0; i < index.columns.length; i++) {
|
||||
cols.push(index.columns[i].toLowerCase());
|
||||
}
|
||||
return cols.join(',') + '|' + (index.unique ? 'u' : 'n');
|
||||
}
|
||||
|
||||
private static fkSignature(fk: ForeignKeyDef): string {
|
||||
const cols: string[] = [];
|
||||
for (let i = 0; i < fk.columns.length; i++) {
|
||||
cols.push(fk.columns[i].toLowerCase());
|
||||
}
|
||||
const refs: string[] = [];
|
||||
for (let i = 0; i < fk.refColumns.length; i++) {
|
||||
refs.push(fk.refColumns[i].toLowerCase());
|
||||
}
|
||||
return cols.join(',') + '>' + fk.refTable.toLowerCase() + '(' + refs.join(',') + ')';
|
||||
}
|
||||
|
||||
private static checkSignature(check: CheckConstraint): string {
|
||||
return check.name.toLowerCase() + ':' + check.expression.toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 表结构模型与校验(1.5.2)。
|
||||
*
|
||||
* 表设计器的内部表示:列、索引、外键。与引擎无关,DDL 生成由 DdlGenerator 按方言输出。
|
||||
* 纯逻辑,可离线测试。
|
||||
*/
|
||||
export enum ColumnType {
|
||||
INT = 0,
|
||||
BIGINT = 1,
|
||||
VARCHAR = 2,
|
||||
TEXT = 3,
|
||||
DECIMAL = 4,
|
||||
BOOLEAN = 5,
|
||||
DATE = 6,
|
||||
TIMESTAMP = 7,
|
||||
BLOB = 8,
|
||||
JSON = 9
|
||||
}
|
||||
|
||||
export interface ColumnDef {
|
||||
name: string;
|
||||
type: ColumnType;
|
||||
/** VARCHAR 长度;0 表示使用默认 255。 */
|
||||
length: number;
|
||||
/** DECIMAL 精度。 */
|
||||
precision: number;
|
||||
/** DECIMAL 小数位。 */
|
||||
scale: number;
|
||||
nullable: boolean;
|
||||
primaryKey: boolean;
|
||||
/** 默认值文本;空表示无默认值。字符串默认值由生成器负责加引号。 */
|
||||
defaultValue: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface IndexDef {
|
||||
name: string;
|
||||
columns: string[];
|
||||
unique: boolean;
|
||||
}
|
||||
|
||||
export interface ForeignKeyDef {
|
||||
name: string;
|
||||
columns: string[];
|
||||
refTable: string;
|
||||
refColumns: string[];
|
||||
}
|
||||
|
||||
/** 检查约束(1.5.5)。 */
|
||||
export interface CheckConstraint {
|
||||
name: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export interface TableDef {
|
||||
schema: string;
|
||||
name: string;
|
||||
columns: ColumnDef[];
|
||||
indexes: IndexDef[];
|
||||
foreignKeys: ForeignKeyDef[];
|
||||
/** 检查约束;可选,未设置视为无。 */
|
||||
checks?: CheckConstraint[];
|
||||
}
|
||||
|
||||
/** 新列默认值。 */
|
||||
export function createColumn(name: string, type: ColumnType): ColumnDef {
|
||||
return {
|
||||
name: name,
|
||||
type: type,
|
||||
length: 0,
|
||||
precision: 0,
|
||||
scale: 0,
|
||||
nullable: true,
|
||||
primaryKey: false,
|
||||
defaultValue: '',
|
||||
comment: ''
|
||||
};
|
||||
}
|
||||
|
||||
/** 新表默认值。 */
|
||||
export function emptyTable(schema: string, name: string): TableDef {
|
||||
return { schema: schema, name: name, columns: [], indexes: [], foreignKeys: [] };
|
||||
}
|
||||
|
||||
/** 表结构校验,返回错误消息列表;空数组表示通过。 */
|
||||
export class TableValidator {
|
||||
static validate(table: TableDef): string[] {
|
||||
const errors: string[] = [];
|
||||
if (table.name.trim().length === 0) {
|
||||
errors.push('table name is required');
|
||||
}
|
||||
if (table.columns.length === 0) {
|
||||
errors.push('at least one column is required');
|
||||
}
|
||||
const seen: Set<string> = new Set<string>();
|
||||
for (let i = 0; i < table.columns.length; i++) {
|
||||
const column: ColumnDef = table.columns[i];
|
||||
const key: string = column.name.toLowerCase();
|
||||
if (column.name.trim().length === 0) {
|
||||
errors.push('column name is required at index ' + i);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(key)) {
|
||||
errors.push('duplicate column: ' + column.name);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
for (let i = 0; i < table.indexes.length; i++) {
|
||||
const index: IndexDef = table.indexes[i];
|
||||
if (index.name.trim().length === 0) {
|
||||
errors.push('index name is required at index ' + i);
|
||||
}
|
||||
if (index.columns.length === 0) {
|
||||
errors.push('index has no columns: ' + index.name);
|
||||
}
|
||||
for (let j = 0; j < index.columns.length; j++) {
|
||||
if (!seen.has(index.columns[j].toLowerCase())) {
|
||||
errors.push('index ' + index.name + ' references unknown column: ' + index.columns[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < table.foreignKeys.length; i++) {
|
||||
const fk: ForeignKeyDef = table.foreignKeys[i];
|
||||
if (fk.refTable.trim().length === 0) {
|
||||
errors.push('foreign key ' + fk.name + ' has no referenced table');
|
||||
}
|
||||
for (let j = 0; j < fk.columns.length; j++) {
|
||||
if (!seen.has(fk.columns[j].toLowerCase())) {
|
||||
errors.push('foreign key ' + fk.name + ' references unknown column: ' + fk.columns[j]);
|
||||
}
|
||||
}
|
||||
if (fk.columns.length !== fk.refColumns.length) {
|
||||
errors.push('foreign key ' + fk.name + ' column count mismatch');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 文本差异(1.5.17)。
|
||||
*
|
||||
* 用于视图/存储过程等对象的定义比较:先做规范化(统一换行、去行尾空白、忽略首尾空行),
|
||||
* 再用 LCS 计算逐行差异。定义文本通常只有几十到几百行,O(n*m) 足够。
|
||||
*/
|
||||
export enum DiffLineKind {
|
||||
SAME = 0,
|
||||
ADDED = 1,
|
||||
REMOVED = 2
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
kind: DiffLineKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export class TextDiff {
|
||||
/** 规范化:\r\n → \n,去行尾空白,去首尾空行。 */
|
||||
static normalize(text: string): string[] {
|
||||
const raw: string[] = text.replace(new RegExp('\r\n', 'g'), '\n').replace(new RegExp('\r', 'g'), '\n').split('\n');
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
lines.push(raw[i].replace(new RegExp('[ \t]+$'), ''));
|
||||
}
|
||||
while (lines.length > 0 && lines[0].length === 0) {
|
||||
lines.shift();
|
||||
}
|
||||
while (lines.length > 0 && lines[lines.length - 1].length === 0) {
|
||||
lines.pop();
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 是否等价(规范化后逐行相同)。 */
|
||||
static identical(oldText: string, newText: string): boolean {
|
||||
const a: string[] = TextDiff.normalize(oldText);
|
||||
const b: string[] = TextDiff.normalize(newText);
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 逐行差异(LCS 回溯)。 */
|
||||
static compare(oldText: string, newText: string): DiffLine[] {
|
||||
const a: string[] = TextDiff.normalize(oldText);
|
||||
const b: string[] = TextDiff.normalize(newText);
|
||||
const n: number = a.length;
|
||||
const m: number = b.length;
|
||||
const lcs: number[][] = [];
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const row: number[] = [];
|
||||
for (let j = 0; j <= m; j++) {
|
||||
row.push(0);
|
||||
}
|
||||
lcs.push(row);
|
||||
}
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
if (a[i] === b[j]) {
|
||||
lcs[i][j] = lcs[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
lcs[i][j] = lcs[i + 1][j] >= lcs[i][j + 1] ? lcs[i + 1][j] : lcs[i][j + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
const out: DiffLine[] = [];
|
||||
let i: number = 0;
|
||||
let j: number = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ kind: DiffLineKind.SAME, text: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
out.push({ kind: DiffLineKind.REMOVED, text: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
out.push({ kind: DiffLineKind.ADDED, text: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
out.push({ kind: DiffLineKind.REMOVED, text: a[i] });
|
||||
i++;
|
||||
}
|
||||
while (j < m) {
|
||||
out.push({ kind: DiffLineKind.ADDED, text: b[j] });
|
||||
j++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static hasChanges(lines: DiffLine[]): boolean {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].kind !== DiffLineKind.SAME) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 触发器 DDL(1.5.8)。
|
||||
*
|
||||
* MySQL / SQLite / Oracle 使用 BEGIN ... END 包裹体;PostgreSQL 触发器绑定函数,
|
||||
* 因此 body 字段在 PG 下表示「执行函数名」。
|
||||
*/
|
||||
import { DdlGenerator, SqlDialect } from './DdlGenerator';
|
||||
|
||||
export enum TriggerTiming {
|
||||
BEFORE = 0,
|
||||
AFTER = 1
|
||||
}
|
||||
|
||||
export enum TriggerEvent {
|
||||
INSERT = 0,
|
||||
UPDATE = 1,
|
||||
DELETE = 2
|
||||
}
|
||||
|
||||
export interface TriggerDef {
|
||||
schema: string;
|
||||
name: string;
|
||||
/** 触发表名(可含 schema)。 */
|
||||
table: string;
|
||||
timing: TriggerTiming;
|
||||
event: TriggerEvent;
|
||||
/** 触发器体;PG 下为函数名。 */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export class TriggerDdl {
|
||||
static validate(def: TriggerDef): string[] {
|
||||
const errors: string[] = [];
|
||||
if (def.name.trim().length === 0) {
|
||||
errors.push('trigger name is required');
|
||||
}
|
||||
if (def.table.trim().length === 0) {
|
||||
errors.push('trigger table is required');
|
||||
}
|
||||
if (def.body.trim().length === 0) {
|
||||
errors.push('trigger body is required');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
static createSql(def: TriggerDef, dialect: SqlDialect): string {
|
||||
const name: string = DdlGenerator.qualifiedName(def.schema, def.name, dialect);
|
||||
const timing: string = def.timing === TriggerTiming.BEFORE ? 'BEFORE' : 'AFTER';
|
||||
const event: string = TriggerDdl.eventName(def.event);
|
||||
const table: string = def.table.trim();
|
||||
const body: string = def.body.trim();
|
||||
if (dialect === SqlDialect.POSTGRESQL) {
|
||||
return 'CREATE TRIGGER ' + name + ' ' + timing + ' ' + event + ' ON ' + table +
|
||||
' FOR EACH ROW EXECUTE FUNCTION ' + body + ';';
|
||||
}
|
||||
if (dialect === SqlDialect.ORACLE) {
|
||||
return 'CREATE OR REPLACE TRIGGER ' + name + ' ' + timing + ' ' + event + ' ON ' + table +
|
||||
' FOR EACH ROW\nBEGIN\n' + body + '\nEND;';
|
||||
}
|
||||
return 'CREATE TRIGGER ' + name + ' ' + timing + ' ' + event + ' ON ' + table +
|
||||
'\nFOR EACH ROW\nBEGIN\n' + body + '\nEND;';
|
||||
}
|
||||
|
||||
private static eventName(event: TriggerEvent): string {
|
||||
switch (event) {
|
||||
case TriggerEvent.INSERT:
|
||||
return 'INSERT';
|
||||
case TriggerEvent.UPDATE:
|
||||
return 'UPDATE';
|
||||
default:
|
||||
return 'DELETE';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 视图 DDL(1.5.6)。
|
||||
*
|
||||
* 生成普通视图与物化视图的创建语句。物化视图在 MySQL / SQLite 无原生支持,返回空字符串,
|
||||
* 由 UI 提示「该引擎不支持物化视图」。
|
||||
*/
|
||||
import { DdlGenerator, SqlDialect } from './DdlGenerator';
|
||||
|
||||
export interface ViewDef {
|
||||
schema: string;
|
||||
name: string;
|
||||
/** 视图查询语句。 */
|
||||
query: string;
|
||||
orReplace: boolean;
|
||||
materialized: boolean;
|
||||
}
|
||||
|
||||
export class ViewDdl {
|
||||
static validate(view: ViewDef): string[] {
|
||||
const errors: string[] = [];
|
||||
if (view.name.trim().length === 0) {
|
||||
errors.push('view name is required');
|
||||
}
|
||||
if (view.query.trim().length === 0) {
|
||||
errors.push('view query is required');
|
||||
}
|
||||
if (view.materialized && view.orReplace) {
|
||||
errors.push('OR REPLACE is not supported for materialized view');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 创建视图;不支持时返回空字符串。 */
|
||||
static createSql(view: ViewDef, dialect: SqlDialect): string {
|
||||
const name: string = DdlGenerator.qualifiedName(view.schema, view.name, dialect);
|
||||
// 去掉用户输入末尾的分号,避免生成 ";;"
|
||||
const query: string = view.query.trim().replace(new RegExp(';+$'), '');
|
||||
if (view.materialized) {
|
||||
if (dialect === SqlDialect.MYSQL || dialect === SqlDialect.SQLITE) {
|
||||
return '';
|
||||
}
|
||||
return 'CREATE MATERIALIZED VIEW ' + name + ' AS\n' + query + ';';
|
||||
}
|
||||
const prefix: string = view.orReplace ? 'CREATE OR REPLACE VIEW ' : 'CREATE VIEW ';
|
||||
return prefix + name + ' AS\n' + query + ';';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 引擎权限项枚举(1.5.15)。
|
||||
*
|
||||
* 以字符串引擎代号为键(避免 data 层依赖 engine 模块的 EngineKind),
|
||||
* 提供权限项查询与按作用域分组,供权限授予界面(1.16.4)与权限脚本生成(1.16.8)使用。
|
||||
*
|
||||
* 数据为初版整理,标 TODO 的条目需在对应驱动联调时按目标版本核对。
|
||||
*/
|
||||
|
||||
export interface PermissionItem {
|
||||
/** 引擎内的权限关键字。 */
|
||||
code: string;
|
||||
/** 界面显示名。 */
|
||||
label: string;
|
||||
/** 作用域:system/database/schema/table/column/routine。 */
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export class PermissionCatalog {
|
||||
private static readonly MYSQL: PermissionItem[] = [
|
||||
{ code: 'SELECT', label: '查询', scope: 'table' },
|
||||
{ code: 'INSERT', label: '插入', scope: 'table' },
|
||||
{ code: 'UPDATE', label: '更新', scope: 'table' },
|
||||
{ code: 'DELETE', label: '删除', scope: 'table' },
|
||||
{ code: 'CREATE', label: '建对象', scope: 'database' },
|
||||
{ code: 'DROP', label: '删对象', scope: 'database' },
|
||||
{ code: 'ALTER', label: '改结构', scope: 'table' },
|
||||
{ code: 'INDEX', label: '建索引', scope: 'table' },
|
||||
{ code: 'GRANT OPTION', label: '转授权', scope: 'system' }
|
||||
];
|
||||
|
||||
private static readonly POSTGRESQL: PermissionItem[] = [
|
||||
{ code: 'SELECT', label: '查询', scope: 'table' },
|
||||
{ code: 'INSERT', label: '插入', scope: 'table' },
|
||||
{ code: 'UPDATE', label: '更新', scope: 'table' },
|
||||
{ code: 'DELETE', label: '删除', scope: 'table' },
|
||||
{ code: 'TRUNCATE', label: '清空', scope: 'table' },
|
||||
{ code: 'REFERENCES', label: '外键引用', scope: 'table' },
|
||||
{ code: 'TRIGGER', label: '触发器', scope: 'table' },
|
||||
{ code: 'CREATE', label: '建对象', scope: 'schema' },
|
||||
{ code: 'CONNECT', label: '连接', scope: 'database' },
|
||||
{ code: 'USAGE', label: '使用', scope: 'schema' }
|
||||
];
|
||||
|
||||
private static readonly SQLSERVER: PermissionItem[] = [
|
||||
{ code: 'SELECT', label: '查询', scope: 'table' },
|
||||
{ code: 'INSERT', label: '插入', scope: 'table' },
|
||||
{ code: 'UPDATE', label: '更新', scope: 'table' },
|
||||
{ code: 'DELETE', label: '删除', scope: 'table' },
|
||||
{ code: 'EXECUTE', label: '执行', scope: 'routine' },
|
||||
{ code: 'ALTER', label: '改结构', scope: 'schema' },
|
||||
{ code: 'CONTROL', label: '完全控制', scope: 'system' },
|
||||
{ code: 'VIEW DEFINITION', label: '查看定义', scope: 'schema' }
|
||||
];
|
||||
|
||||
private static readonly ORACLE: PermissionItem[] = [
|
||||
{ code: 'SELECT', label: '查询', scope: 'table' },
|
||||
{ code: 'INSERT', label: '插入', scope: 'table' },
|
||||
{ code: 'UPDATE', label: '更新', scope: 'table' },
|
||||
{ code: 'DELETE', label: '删除', scope: 'table' },
|
||||
{ code: 'ALTER', label: '改结构', scope: 'table' },
|
||||
{ code: 'INDEX', label: '建索引', scope: 'table' },
|
||||
{ code: 'EXECUTE', label: '执行', scope: 'routine' },
|
||||
{ code: 'REFERENCES', label: '外键引用', scope: 'table' },
|
||||
{ code: 'GRANT ANY PRIVILEGE', label: '任意授权', scope: 'system' }
|
||||
];
|
||||
|
||||
private static readonly MONGODB: PermissionItem[] = [
|
||||
{ code: 'read', label: '只读', scope: 'database' },
|
||||
{ code: 'readWrite', label: '读写', scope: 'database' },
|
||||
{ code: 'dbAdmin', label: '库管理', scope: 'database' },
|
||||
{ code: 'userAdmin', label: '用户管理', scope: 'database' },
|
||||
{ code: 'clusterAdmin', label: '集群管理', scope: 'system' },
|
||||
{ code: 'root', label: '超级权限', scope: 'system' }
|
||||
];
|
||||
|
||||
private static readonly REDIS: PermissionItem[] = [
|
||||
{ code: '+@read', label: '读命令', scope: 'database' },
|
||||
{ code: '+@write', label: '写命令', scope: 'database' },
|
||||
{ code: '+@admin', label: '管理命令', scope: 'system' },
|
||||
{ code: '+@dangerous', label: '危险命令', scope: 'system' }
|
||||
];
|
||||
|
||||
private static readonly SQLITE: PermissionItem[] = [];
|
||||
|
||||
/** 引擎代号到权限项;未知引擎返回空数组。 */
|
||||
static items(engineCode: string): PermissionItem[] {
|
||||
const key: string = engineCode.trim().toLowerCase();
|
||||
switch (key) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
case 'tidb':
|
||||
return PermissionCatalog.MYSQL.slice();
|
||||
case 'oceanbase':
|
||||
// mysqli 兼容模式,权限项按 MySQL 子集处理
|
||||
return PermissionCatalog.MYSQL.slice();
|
||||
case 'postgresql':
|
||||
return PermissionCatalog.POSTGRESQL.slice();
|
||||
case 'sqlserver':
|
||||
return PermissionCatalog.SQLSERVER.slice();
|
||||
case 'oracle':
|
||||
return PermissionCatalog.ORACLE.slice();
|
||||
case 'mongodb':
|
||||
return PermissionCatalog.MONGODB.slice();
|
||||
case 'redis':
|
||||
return PermissionCatalog.REDIS.slice();
|
||||
case 'sqlite':
|
||||
return PermissionCatalog.SQLITE.slice();
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 按权限码查找(大小写不敏感)。 */
|
||||
static find(engineCode: string, code: string): PermissionItem | undefined {
|
||||
const items: PermissionItem[] = PermissionCatalog.items(engineCode);
|
||||
const needle: string = code.trim().toLowerCase();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].code.toLowerCase() === needle) {
|
||||
return items[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 某引擎出现的作用域集合(去重,按首次出现顺序)。 */
|
||||
static scopes(engineCode: string): string[] {
|
||||
const items: PermissionItem[] = PermissionCatalog.items(engineCode);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (out.indexOf(items[i].scope) < 0) {
|
||||
out.push(items[i].scope);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 智能补全(1.6.3)。
|
||||
*
|
||||
* 输入:光标前缀、当前库对象、函数名、权限项、关键字。
|
||||
* 输出:按「对象 → 函数 → 关键字 → 权限项」优先级排序的候选,前缀大小写不敏感,去重。
|
||||
*
|
||||
* 说明:当前为前缀匹配(与多数编辑器默认行为一致);模糊匹配与语义上下文
|
||||
* (如 FROM 后只出表名)依赖 AST,属 1.6.x 后续迭代。
|
||||
*/
|
||||
export interface CompletionCandidate {
|
||||
label: string;
|
||||
kind: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface CompletionRequest {
|
||||
prefix: string;
|
||||
objects: string[];
|
||||
functions: string[];
|
||||
keywords: string[];
|
||||
permissions: string[];
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export class CompletionEngine {
|
||||
static complete(request: CompletionRequest): CompletionCandidate[] {
|
||||
const prefix: string = request.prefix.trim().toLowerCase();
|
||||
const limit: number = request.limit > 0 ? request.limit : 20;
|
||||
const seen: Set<string> = new Set<string>();
|
||||
const out: CompletionCandidate[] = [];
|
||||
CompletionEngine.collect(request.objects, prefix, 'object', '对象', seen, out, limit);
|
||||
CompletionEngine.collect(request.functions, prefix, 'function', '函数', seen, out, limit);
|
||||
CompletionEngine.collect(request.keywords, prefix, 'keyword', '关键字', seen, out, limit);
|
||||
CompletionEngine.collect(request.permissions, prefix, 'permission', '权限项', seen, out, limit);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static collect(source: string[], prefix: string, kind: string, detail: string,
|
||||
seen: Set<string>, out: CompletionCandidate[], limit: number): void {
|
||||
for (let i = 0; i < source.length && out.length < limit; i++) {
|
||||
const label: string = source[i].trim();
|
||||
const key: string = label.toLowerCase();
|
||||
if (key.length === 0 || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (prefix.length > 0 && key.indexOf(prefix) !== 0) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
out.push({ label: label, kind: kind, detail: detail });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 控制台命令解析(1.6.9)。
|
||||
*
|
||||
* 两种输入:
|
||||
* - SQL:普通语句,交给查询执行;
|
||||
* - 元命令:以 \ 或 . 开头(如 \connect、\dt、\d table、.exit),由控制台插件处理。
|
||||
*
|
||||
* 解析只做拆分与规范化,不执行;未知命令也原样返回,由 UI 提示"不支持"。
|
||||
*/
|
||||
export enum ConsoleCommandKind {
|
||||
SQL = 0,
|
||||
META = 1
|
||||
}
|
||||
|
||||
export interface ConsoleCommand {
|
||||
kind: ConsoleCommandKind;
|
||||
/** 元命令名(不含前缀,小写);SQL 为空。 */
|
||||
name: string;
|
||||
/** 元命令参数(已去引号);SQL 为空。 */
|
||||
args: string[];
|
||||
/** 原始输入(已 trim)。 */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export class ConsoleParser {
|
||||
static isMeta(line: string): boolean {
|
||||
const trimmed: string = line.trim();
|
||||
return trimmed.length > 0 && (trimmed.charAt(0) === '\\' || trimmed.charAt(0) === '.');
|
||||
}
|
||||
|
||||
static parse(line: string): ConsoleCommand {
|
||||
const trimmed: string = line.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { kind: ConsoleCommandKind.SQL, name: '', args: [], raw: '' };
|
||||
}
|
||||
if (!ConsoleParser.isMeta(trimmed)) {
|
||||
return { kind: ConsoleCommandKind.SQL, name: '', args: [], raw: trimmed };
|
||||
}
|
||||
const body: string = trimmed.substring(1).trim();
|
||||
if (body.length === 0) {
|
||||
return { kind: ConsoleCommandKind.META, name: '', args: [], raw: trimmed };
|
||||
}
|
||||
const parts: string[] = ConsoleParser.splitArgs(body);
|
||||
const name: string = parts.length > 0 ? parts[0].toLowerCase() : '';
|
||||
const args: string[] = parts.length > 1 ? parts.slice(1) : [];
|
||||
return { kind: ConsoleCommandKind.META, name: name, args: args, raw: trimmed };
|
||||
}
|
||||
|
||||
/** 按空白拆分,支持单/双引号包裹的参数。 */
|
||||
private static splitArgs(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
let current: string = '';
|
||||
let quote: string = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch: string = text.charAt(i);
|
||||
if (quote.length > 0) {
|
||||
if (ch === quote) {
|
||||
quote = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === '\'') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ' ' || ch === '\t') {
|
||||
if (current.length > 0) {
|
||||
out.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.length > 0) {
|
||||
out.push(current);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 执行计划解析(1.6.7)。
|
||||
*
|
||||
* 把引擎返回的文本计划解析为树:按缩进层级(每级 2 空格)与 `->` 标记建树。
|
||||
* 兼容 PostgreSQL 的 `-> Seq Scan on t (cost=...)` 与 MySQL 8 的树形输出
|
||||
* (`-> Nested loop inner join`)。
|
||||
*
|
||||
* 纯逻辑:不执行 EXPLAIN,也不解释 cost;UI 负责渲染树与高亮关键字。
|
||||
*/
|
||||
export interface PlanNode {
|
||||
/** 节点标题(去掉箭头与多余空白)。 */
|
||||
label: string;
|
||||
/** 原始行(保留 cost 等信息)。 */
|
||||
raw: string;
|
||||
/** 层级,根为 0。 */
|
||||
depth: number;
|
||||
children: PlanNode[];
|
||||
}
|
||||
|
||||
export class ExplainParser {
|
||||
/** 解析缩进式文本计划。 */
|
||||
static parseIndented(text: string): PlanNode[] {
|
||||
const lines: string[] = text.replace(new RegExp('\r\n', 'g'), '\n').split('\n');
|
||||
const roots: PlanNode[] = [];
|
||||
const stack: PlanNode[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw: string = lines[i];
|
||||
if (raw.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
const depth: number = ExplainParser.depthOf(raw);
|
||||
const label: string = ExplainParser.labelOf(raw);
|
||||
const node: PlanNode = { label: label, raw: raw.trim(), depth: depth, children: [] };
|
||||
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
|
||||
stack.pop();
|
||||
}
|
||||
if (stack.length === 0) {
|
||||
roots.push(node);
|
||||
} else {
|
||||
stack[stack.length - 1].children.push(node);
|
||||
}
|
||||
stack.push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** 统计节点总数(含子节点)。 */
|
||||
static countNodes(nodes: PlanNode[]): number {
|
||||
let count: number = 0;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
count += 1 + ExplainParser.countNodes(nodes[i].children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** 扁平化为「缩进文本」,便于复制与日志。 */
|
||||
static flatten(nodes: PlanNode[], indent: number): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
out.push(ExplainParser.spaces(indent) + nodes[i].label);
|
||||
const childLines: string[] = ExplainParser.flatten(nodes[i].children, indent + 2);
|
||||
for (let j = 0; j < childLines.length; j++) {
|
||||
out.push(childLines[j]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static depthOf(line: string): number {
|
||||
let spaces: number = 0;
|
||||
while (spaces < line.length && line.charAt(spaces) === ' ') {
|
||||
spaces++;
|
||||
}
|
||||
return Math.floor(spaces / 2);
|
||||
}
|
||||
|
||||
private static labelOf(line: string): string {
|
||||
let text: string = line.trim();
|
||||
if (text.indexOf('->') === 0) {
|
||||
text = text.substring(2).trim();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private static spaces(count: number): string {
|
||||
let out: string = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
out += ' ';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 大结果集分页与窗口(1.6.11)。
|
||||
*
|
||||
* 结果集虚拟滚动的核心:按页记录已加载区间,滚动到可视区时计算需要请求的页。
|
||||
* 与 1.2.10 数据网格内核的分页设计对应,纯逻辑、可测试。
|
||||
*
|
||||
* 约定:
|
||||
* - 页大小固定(构造时给定);总行数未知时传 -1,pageCount 返回 -1;
|
||||
* - 页号从 0 开始;
|
||||
* - pagesFor 对输入做裁剪,末页不足一页按实际行数处理。
|
||||
*/
|
||||
export class PagedResult {
|
||||
private totalRows: number;
|
||||
private readonly pageSize: number;
|
||||
private readonly loaded: Set<number> = new Set<number>();
|
||||
|
||||
constructor(totalRows: number, pageSize: number) {
|
||||
if (pageSize < 1) {
|
||||
throw new Error('pageSize must be >= 1');
|
||||
}
|
||||
this.totalRows = totalRows;
|
||||
this.pageSize = pageSize;
|
||||
}
|
||||
|
||||
/** 总行数(-1 表示未知)。 */
|
||||
rows(): number {
|
||||
return this.totalRows;
|
||||
}
|
||||
|
||||
/** 页数;总行数未知时返回 -1。 */
|
||||
pageCount(): number {
|
||||
if (this.totalRows < 0) {
|
||||
return -1;
|
||||
}
|
||||
return Math.ceil(this.totalRows / this.pageSize);
|
||||
}
|
||||
|
||||
/** 末尾页的实际行数(用于渲染末页空白判断)。 */
|
||||
lastPageRowCount(): number {
|
||||
if (this.totalRows <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const remainder: number = this.totalRows % this.pageSize;
|
||||
return remainder === 0 ? this.pageSize : remainder;
|
||||
}
|
||||
|
||||
/** 覆盖 [firstRow, lastRow] 的页号列表(已裁剪到有效范围)。 */
|
||||
pagesFor(firstRow: number, lastRow: number): number[] {
|
||||
if (this.totalRows === 0) {
|
||||
return [];
|
||||
}
|
||||
const maxRow: number = this.totalRows > 0 ? this.totalRows - 1 : lastRow;
|
||||
const start: number = firstRow < 0 ? 0 : firstRow;
|
||||
const end: number = lastRow > maxRow ? maxRow : lastRow;
|
||||
if (end < start) {
|
||||
return [];
|
||||
}
|
||||
const firstPage: number = Math.floor(start / this.pageSize);
|
||||
const lastPage: number = Math.floor(end / this.pageSize);
|
||||
const out: number[] = [];
|
||||
for (let page = firstPage; page <= lastPage; page++) {
|
||||
out.push(page);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
markLoaded(page: number): void {
|
||||
if (page >= 0) {
|
||||
this.loaded.add(page);
|
||||
}
|
||||
}
|
||||
|
||||
isLoaded(page: number): boolean {
|
||||
return this.loaded.has(page);
|
||||
}
|
||||
|
||||
/** 已加载页数。 */
|
||||
loadedPages(): number {
|
||||
return this.loaded.size;
|
||||
}
|
||||
|
||||
/** 已加载行数(末页按实际行数计算)。 */
|
||||
loadedRows(): number {
|
||||
let rows: number = 0;
|
||||
this.loaded.forEach((page: number) => {
|
||||
rows += this.rowsInPage(page);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 某页实际行数。 */
|
||||
rowsInPage(page: number): number {
|
||||
if (page < 0) {
|
||||
return 0;
|
||||
}
|
||||
const start: number = page * this.pageSize;
|
||||
if (this.totalRows < 0) {
|
||||
return this.pageSize;
|
||||
}
|
||||
if (start >= this.totalRows) {
|
||||
return 0;
|
||||
}
|
||||
const remaining: number = this.totalRows - start;
|
||||
return remaining < this.pageSize ? remaining : this.pageSize;
|
||||
}
|
||||
|
||||
/** 从 fromPage 开始的下一个未加载页;没有则返回 -1。 */
|
||||
nextUnloadedPage(fromPage: number): number {
|
||||
const count: number = this.pageCount();
|
||||
const start: number = fromPage < 0 ? 0 : fromPage;
|
||||
for (let page = start; ; page++) {
|
||||
if (count >= 0 && page >= count) {
|
||||
return -1;
|
||||
}
|
||||
if (!this.loaded.has(page)) {
|
||||
return page;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 重设总行数(重新执行查询时),已加载标记清零。 */
|
||||
reset(totalRows: number): void {
|
||||
this.totalRows = totalRows;
|
||||
this.loaded.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 参数化查询(1.6.13)。
|
||||
*
|
||||
* 支持三种占位风格:
|
||||
* - 问号:?(MySQL/SQLite/Oracle 通用)
|
||||
* - 命名::name(Oracle/PG 常用)
|
||||
* - 位置:$1(PostgreSQL)
|
||||
*
|
||||
* 职责:统计占位符、校验数量、把值安全地绑定为字面量(单引号双写,拒绝含 NUL 的值)。
|
||||
* 说明:真实驱动应优先使用引擎原生预编译绑定;本模块用于不支持预编译的场景与预览。
|
||||
*/
|
||||
export enum ParamStyle {
|
||||
QUESTION = 0,
|
||||
NAMED = 1,
|
||||
POSITIONAL = 2
|
||||
}
|
||||
|
||||
export interface ParamSpec {
|
||||
style: ParamStyle;
|
||||
/** 命名风格的参数名(不含冒号);问号/位置风格为空。 */
|
||||
names: string[];
|
||||
/** 占位符总数。 */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 命名参数。 */
|
||||
export interface NamedParam {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class ParameterBinder {
|
||||
/** 统计占位符;字符串与注释中的占位符不计入。 */
|
||||
static inspect(sql: string): ParamSpec {
|
||||
const names: string[] = [];
|
||||
let questionCount: number = 0;
|
||||
let positionalCount: number = 0;
|
||||
let i: number = 0;
|
||||
while (i < sql.length) {
|
||||
const ch: string = sql.charAt(i);
|
||||
if (ch === '\'' || ch === '"' || ch === '`') {
|
||||
i = ParameterBinder.skipQuoted(sql, i, ch);
|
||||
continue;
|
||||
}
|
||||
if (ch === '-' && sql.charAt(i + 1) === '-') {
|
||||
while (i < sql.length && sql.charAt(i) !== '\n') {
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && sql.charAt(i + 1) === '*') {
|
||||
i += 2;
|
||||
while (i < sql.length && !(sql.charAt(i) === '*' && sql.charAt(i + 1) === '/')) {
|
||||
i++;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '?') {
|
||||
questionCount++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ':' && ParameterBinder.isNameStart(sql.charAt(i + 1))) {
|
||||
let j: number = i + 1;
|
||||
while (j < sql.length && ParameterBinder.isNamePart(sql.charAt(j))) {
|
||||
j++;
|
||||
}
|
||||
names.push(sql.substring(i + 1, j));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (ch === '$' && sql.charAt(i + 1) >= '0' && sql.charAt(i + 1) <= '9') {
|
||||
positionalCount++;
|
||||
i++;
|
||||
while (i < sql.length && sql.charAt(i) >= '0' && sql.charAt(i) <= '9') {
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (names.length > 0) {
|
||||
return { style: ParamStyle.NAMED, names: names, count: names.length };
|
||||
}
|
||||
if (positionalCount > 0) {
|
||||
return { style: ParamStyle.POSITIONAL, names: [], count: positionalCount };
|
||||
}
|
||||
return { style: ParamStyle.QUESTION, names: [], count: questionCount };
|
||||
}
|
||||
|
||||
/** 校验参数数量;返回错误列表。 */
|
||||
static validate(sql: string, paramCount: number): string[] {
|
||||
const errors: string[] = [];
|
||||
const spec: ParamSpec = ParameterBinder.inspect(sql);
|
||||
if (spec.style === ParamStyle.QUESTION && spec.count !== paramCount) {
|
||||
errors.push('expected ' + spec.count + ' parameters, got ' + paramCount);
|
||||
}
|
||||
if (spec.style === ParamStyle.POSITIONAL && spec.count !== paramCount) {
|
||||
errors.push('expected ' + spec.count + ' parameters, got ' + paramCount);
|
||||
}
|
||||
if (spec.style === ParamStyle.NAMED && spec.names.length !== paramCount) {
|
||||
errors.push('expected ' + spec.names.length + ' parameters, got ' + paramCount);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 字面量安全化:单引号双写;含 NUL 时抛错(避免截断/注入)。 */
|
||||
static quoteLiteral(value: string): string {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) === 0) {
|
||||
throw new Error('parameter contains NUL character');
|
||||
}
|
||||
}
|
||||
return '\'' + value.replace(new RegExp('\'', 'g'), '\'\'') + '\'';
|
||||
}
|
||||
|
||||
/** 绑定问号或位置占位符(按出现顺序替换),返回可执行 SQL。 */
|
||||
static bind(sql: string, values: string[]): string {
|
||||
const spec: ParamSpec = ParameterBinder.inspect(sql);
|
||||
if (spec.style === ParamStyle.NAMED) {
|
||||
throw new Error('named parameters require bindNamed');
|
||||
}
|
||||
if (spec.count !== values.length) {
|
||||
throw new Error('parameter count mismatch: expected ' + spec.count + ', got ' + values.length);
|
||||
}
|
||||
let out: string = '';
|
||||
let valueIndex: number = 0;
|
||||
let i: number = 0;
|
||||
while (i < sql.length) {
|
||||
const ch: string = sql.charAt(i);
|
||||
if (ch === '\'' || ch === '"' || ch === '`') {
|
||||
const end: number = ParameterBinder.skipQuoted(sql, i, ch);
|
||||
out += sql.substring(i, end);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (ch === '-' && sql.charAt(i + 1) === '-') {
|
||||
const start: number = i;
|
||||
while (i < sql.length && sql.charAt(i) !== '\n') {
|
||||
i++;
|
||||
}
|
||||
out += sql.substring(start, i);
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && sql.charAt(i + 1) === '*') {
|
||||
const start: number = i;
|
||||
i += 2;
|
||||
while (i < sql.length && !(sql.charAt(i) === '*' && sql.charAt(i + 1) === '/')) {
|
||||
i++;
|
||||
}
|
||||
i += 2;
|
||||
out += sql.substring(start, i);
|
||||
continue;
|
||||
}
|
||||
if (ch === '?') {
|
||||
out += ParameterBinder.quoteLiteral(values[valueIndex]);
|
||||
valueIndex++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '$' && sql.charAt(i + 1) >= '0' && sql.charAt(i + 1) <= '9') {
|
||||
while (i < sql.length && ((sql.charAt(i) >= '0' && sql.charAt(i) <= '9') || sql.charAt(i) === '$')) {
|
||||
i++;
|
||||
}
|
||||
out += ParameterBinder.quoteLiteral(values[valueIndex]);
|
||||
valueIndex++;
|
||||
continue;
|
||||
}
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 绑定命名占位符(:name),未提供的参数保持原样。 */
|
||||
static bindNamed(sql: string, variables: NamedParam[]): string {
|
||||
let out: string = sql;
|
||||
for (let i = 0; i < variables.length; i++) {
|
||||
const token: string = ':' + variables[i].name;
|
||||
out = out.split(token).join(ParameterBinder.quoteLiteral(variables[i].value));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static skipQuoted(sql: string, start: number, quote: string): number {
|
||||
let i: number = start + 1;
|
||||
while (i < sql.length) {
|
||||
if (sql.charAt(i) === quote) {
|
||||
if (sql.charAt(i + 1) === quote) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
private static isNameStart(ch: string): boolean {
|
||||
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_';
|
||||
}
|
||||
|
||||
private static isNamePart(ch: string): boolean {
|
||||
return ParameterBinder.isNameStart(ch) || (ch >= '0' && ch <= '9');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 性能剖析(1.6.8)。
|
||||
*
|
||||
* 记录语句耗时并给出统计(均值/最小/最大/P50/P95)与慢查询列表。
|
||||
* 纯逻辑:不采集引擎侧指标,只消费执行层上报的 durationMs。
|
||||
*/
|
||||
export interface TimingSample {
|
||||
sql: string;
|
||||
durationMs: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface TimingStats {
|
||||
count: number;
|
||||
totalMs: number;
|
||||
avgMs: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
}
|
||||
|
||||
export class Profiler {
|
||||
private readonly capacity: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly samples: TimingSample[] = [];
|
||||
|
||||
constructor(capacity: number, clock?: () => number) {
|
||||
if (capacity < 1) {
|
||||
throw new Error('capacity must be >= 1');
|
||||
}
|
||||
this.capacity = capacity;
|
||||
this.clock = clock === undefined ? () => Date.now() : clock;
|
||||
}
|
||||
|
||||
record(sql: string, durationMs: number): TimingSample {
|
||||
const sample: TimingSample = { sql: sql.trim(), durationMs: durationMs, at: this.clock() };
|
||||
this.samples.push(sample);
|
||||
while (this.samples.length > this.capacity) {
|
||||
this.samples.shift();
|
||||
}
|
||||
return sample;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.samples.length;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.samples.length = 0;
|
||||
}
|
||||
|
||||
/** 统计;无样本时全部为 0。 */
|
||||
stats(): TimingStats {
|
||||
if (this.samples.length === 0) {
|
||||
return { count: 0, totalMs: 0, avgMs: 0, minMs: 0, maxMs: 0, p50Ms: 0, p95Ms: 0 };
|
||||
}
|
||||
const values: number[] = [];
|
||||
let total: number = 0;
|
||||
for (let i = 0; i < this.samples.length; i++) {
|
||||
values.push(this.samples[i].durationMs);
|
||||
total += this.samples[i].durationMs;
|
||||
}
|
||||
values.sort((a: number, b: number) => a - b);
|
||||
return {
|
||||
count: values.length,
|
||||
totalMs: total,
|
||||
avgMs: total / values.length,
|
||||
minMs: values[0],
|
||||
maxMs: values[values.length - 1],
|
||||
p50Ms: Profiler.percentile(values, 50),
|
||||
p95Ms: Profiler.percentile(values, 95)
|
||||
};
|
||||
}
|
||||
|
||||
/** 最慢的 limit 条(耗时降序)。 */
|
||||
slowest(limit: number): TimingSample[] {
|
||||
const copy: TimingSample[] = this.samples.slice();
|
||||
copy.sort((a: TimingSample, b: TimingSample) => b.durationMs - a.durationMs);
|
||||
return limit > 0 && copy.length > limit ? copy.slice(0, limit) : copy;
|
||||
}
|
||||
|
||||
/** 超过阈值的样本数。 */
|
||||
slowCount(thresholdMs: number): number {
|
||||
let count: number = 0;
|
||||
for (let i = 0; i < this.samples.length; i++) {
|
||||
if (this.samples[i].durationMs > thresholdMs) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static percentile(sorted: number[], p: number): number {
|
||||
if (sorted.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const index: number = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
const safe: number = index < 0 ? 0 : (index >= sorted.length ? sorted.length - 1 : index);
|
||||
return sorted[safe];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 查询历史(1.6.12)。
|
||||
*
|
||||
* 环形容量内存历史:记录 SQL、耗时、行数、成功与否,支持最近读取与关键字搜索。
|
||||
* 持久化由 1.3.7 存储层负责(后续接入),本类保持纯逻辑。
|
||||
*/
|
||||
export interface HistoryEntry {
|
||||
id: number;
|
||||
at: number;
|
||||
sql: string;
|
||||
durationMs: number;
|
||||
rowCount: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export class QueryHistory {
|
||||
private readonly capacity: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly entries: HistoryEntry[] = [];
|
||||
private nextId: number = 1;
|
||||
|
||||
constructor(capacity: number, clock?: () => number) {
|
||||
if (capacity < 1) {
|
||||
throw new Error('capacity must be >= 1');
|
||||
}
|
||||
this.capacity = capacity;
|
||||
this.clock = clock === undefined ? () => Date.now() : clock;
|
||||
}
|
||||
|
||||
record(sql: string, durationMs: number, rowCount: number, success: boolean): HistoryEntry {
|
||||
const entry: HistoryEntry = {
|
||||
id: this.nextId,
|
||||
at: this.clock(),
|
||||
sql: sql.trim(),
|
||||
durationMs: durationMs,
|
||||
rowCount: rowCount,
|
||||
success: success
|
||||
};
|
||||
this.nextId++;
|
||||
this.entries.push(entry);
|
||||
while (this.entries.length > this.capacity) {
|
||||
this.entries.shift();
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** 最近 limit 条,时间倒序;limit<=0 返回全部。 */
|
||||
recent(limit: number): HistoryEntry[] {
|
||||
const start: number = limit > 0 && this.entries.length > limit ? this.entries.length - limit : 0;
|
||||
const out: HistoryEntry[] = [];
|
||||
for (let i = this.entries.length - 1; i >= start; i--) {
|
||||
out.push(this.entries[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** SQL 关键字搜索(大小写不敏感),时间倒序。 */
|
||||
search(keyword: string): HistoryEntry[] {
|
||||
const needle: string = keyword.trim().toLowerCase();
|
||||
if (needle.length === 0) {
|
||||
return this.recent(0);
|
||||
}
|
||||
const out: HistoryEntry[] = [];
|
||||
for (let i = this.entries.length - 1; i >= 0; i--) {
|
||||
if (this.entries[i].sql.toLowerCase().indexOf(needle) >= 0) {
|
||||
out.push(this.entries[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.entries.length;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.length = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 代码片段库(1.6.4)。
|
||||
*
|
||||
* 片段:名称 + SQL 模板 + 标签;模板支持 {{变量}} 占位,插入时按传入变量替换。
|
||||
* 纯内存实现,持久化由 1.3.7 存储层负责(后续接入)。
|
||||
*/
|
||||
export interface Snippet {
|
||||
id: string;
|
||||
name: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface SnippetVariable {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class SnippetStore {
|
||||
private readonly snippets: Snippet[] = [];
|
||||
private nextId: number = 1;
|
||||
|
||||
add(name: string, body: string, tags: string[]): Snippet {
|
||||
const snippet: Snippet = {
|
||||
id: 'snip-' + this.nextId,
|
||||
name: name.trim(),
|
||||
body: body,
|
||||
tags: tags.slice()
|
||||
};
|
||||
this.nextId++;
|
||||
this.snippets.push(snippet);
|
||||
return snippet;
|
||||
}
|
||||
|
||||
list(): Snippet[] {
|
||||
return this.snippets.slice();
|
||||
}
|
||||
|
||||
find(id: string): Snippet | undefined {
|
||||
for (let i = 0; i < this.snippets.length; i++) {
|
||||
if (this.snippets[i].id === id) {
|
||||
return this.snippets[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 按名称或标签搜索(大小写不敏感)。 */
|
||||
search(keyword: string): Snippet[] {
|
||||
const needle: string = keyword.trim().toLowerCase();
|
||||
if (needle.length === 0) {
|
||||
return this.list();
|
||||
}
|
||||
const out: Snippet[] = [];
|
||||
for (let i = 0; i < this.snippets.length; i++) {
|
||||
const snippet: Snippet = this.snippets[i];
|
||||
let hit: boolean = snippet.name.toLowerCase().indexOf(needle) >= 0;
|
||||
for (let j = 0; j < snippet.tags.length && !hit; j++) {
|
||||
hit = snippet.tags[j].toLowerCase().indexOf(needle) >= 0;
|
||||
}
|
||||
if (hit) {
|
||||
out.push(snippet);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
remove(id: string): boolean {
|
||||
for (let i = 0; i < this.snippets.length; i++) {
|
||||
if (this.snippets[i].id === id) {
|
||||
this.snippets.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.snippets.length;
|
||||
}
|
||||
|
||||
/** 将 {{变量}} 替换为值;未提供的变量保持原样。 */
|
||||
static expand(body: string, variables: SnippetVariable[]): string {
|
||||
let out: string = body;
|
||||
for (let i = 0; i < variables.length; i++) {
|
||||
const token: string = '{{' + variables[i].name.trim() + '}}';
|
||||
out = out.split(token).join(variables[i].value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* SQL 格式化(1.6.6)。
|
||||
*
|
||||
* 规则(基础版):
|
||||
* - 关键字大写;
|
||||
* - 顶层子句前换行:SELECT/FROM/WHERE/GROUP BY/ORDER BY/HAVING/LIMIT/OFFSET/UNION 与 JOIN 族;
|
||||
* - 逗号后空格、运算符两侧空格、点号紧邻、函数名与左括号之间不留空格;
|
||||
* - 字符串、引号标识符、注释原样保留;
|
||||
* - 括号内不换行(仅顶层子句换行),嵌套过深时保持单行以便阅读。
|
||||
*
|
||||
* 纯逻辑,便于测试;复杂的缩进风格(每列一行等)属后续增强。
|
||||
*/
|
||||
import { SqlToken, SqlTokenizer, TokenKind } from './SqlTokenizer';
|
||||
|
||||
export class SqlFormatter {
|
||||
private static readonly NEWLINE_KEYWORDS: string[] = [
|
||||
'SELECT', 'FROM', 'WHERE', 'GROUP', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'UNION',
|
||||
'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER'
|
||||
];
|
||||
|
||||
/** 作为函数名使用的关键字:与左括号之间不留空格。 */
|
||||
private static readonly FUNCTION_KEYWORDS: string[] = ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'];
|
||||
|
||||
static format(sql: string): string {
|
||||
const tokens: SqlToken[] = SqlTokenizer.tokenize(sql);
|
||||
let out: string = '';
|
||||
let depth: number = 0;
|
||||
let prev: SqlToken | undefined = undefined;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token: SqlToken = tokens[i];
|
||||
if (token.kind === TokenKind.WHITESPACE) {
|
||||
continue;
|
||||
}
|
||||
const text: string = token.kind === TokenKind.KEYWORD ? token.text.toUpperCase() : token.text;
|
||||
const isJoinGroup: boolean = token.kind === TokenKind.KEYWORD &&
|
||||
SqlFormatter.NEWLINE_KEYWORDS.indexOf(token.text.toUpperCase()) >= 0;
|
||||
if (token.text === '(') {
|
||||
out += SqlFormatter.parenSpace(prev);
|
||||
out += text;
|
||||
depth++;
|
||||
prev = token;
|
||||
continue;
|
||||
}
|
||||
if (token.text === ')') {
|
||||
out += text;
|
||||
depth = depth > 0 ? depth - 1 : 0;
|
||||
prev = token;
|
||||
continue;
|
||||
}
|
||||
if (isJoinGroup && depth === 0 && out.length > 0) {
|
||||
out += '\n' + text;
|
||||
prev = token;
|
||||
continue;
|
||||
}
|
||||
out += SqlFormatter.separator(prev, token) + text;
|
||||
prev = token;
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
/** 左括号前是否留空格:函数调用紧贴,其余留空格。 */
|
||||
private static parenSpace(prev: SqlToken | undefined): string {
|
||||
if (prev === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (prev.kind === TokenKind.IDENTIFIER || prev.kind === TokenKind.QUOTED_IDENTIFIER) {
|
||||
return '';
|
||||
}
|
||||
if (prev.kind === TokenKind.KEYWORD && SqlFormatter.FUNCTION_KEYWORDS.indexOf(prev.text.toUpperCase()) >= 0) {
|
||||
return '';
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
|
||||
private static separator(prev: SqlToken | undefined, current: SqlToken): string {
|
||||
if (prev === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (current.text === ',' || current.text === ';' || current.text === '.') {
|
||||
return '';
|
||||
}
|
||||
if (prev.text === '.' || prev.text === '(') {
|
||||
return '';
|
||||
}
|
||||
if (current.kind === TokenKind.OPERATOR || prev.kind === TokenKind.OPERATOR) {
|
||||
return ' ';
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 语法高亮与折叠(1.6.2)。
|
||||
*
|
||||
* 在 1.6.1 的词法层之上产出渲染所需的区间:
|
||||
* - spans:跳过空白,按 token 类型给出高亮区间(原串偏移);
|
||||
* - foldingRanges:括号跨行时给出可折叠的行区间。
|
||||
*
|
||||
* 纯逻辑,UI 按区间着色与折叠。
|
||||
*/
|
||||
import { SqlToken, SqlTokenizer, TokenKind } from './SqlTokenizer';
|
||||
|
||||
export interface HighlightSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
kind: TokenKind;
|
||||
}
|
||||
|
||||
export interface FoldingRange {
|
||||
/** 起始行(0 起)。 */
|
||||
startLine: number;
|
||||
/** 结束行(0 起)。 */
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
export class SqlHighlighter {
|
||||
/** 高亮区间(不含空白与普通标点,减少无意义 span)。 */
|
||||
static spans(sql: string): HighlightSpan[] {
|
||||
const tokens: SqlToken[] = SqlTokenizer.tokenize(sql);
|
||||
const out: HighlightSpan[] = [];
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token: SqlToken = tokens[i];
|
||||
if (token.kind === TokenKind.WHITESPACE) {
|
||||
continue;
|
||||
}
|
||||
if (token.kind === TokenKind.PUNCTUATION && token.text !== '(' && token.text !== ')') {
|
||||
continue;
|
||||
}
|
||||
out.push({ start: token.start, end: token.end, kind: token.kind });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 跨行括号的折叠区间(行号 0 起)。 */
|
||||
static foldingRanges(sql: string): FoldingRange[] {
|
||||
const tokens: SqlToken[] = SqlTokenizer.tokenize(sql);
|
||||
const lineStarts: number[] = [0];
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
if (sql.charAt(i) === '\n') {
|
||||
lineStarts.push(i + 1);
|
||||
}
|
||||
}
|
||||
const stack: number[] = [];
|
||||
const out: FoldingRange[] = [];
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token: SqlToken = tokens[i];
|
||||
if (token.kind !== TokenKind.PUNCTUATION) {
|
||||
continue;
|
||||
}
|
||||
if (token.text === '(') {
|
||||
stack.push(token.start);
|
||||
} else if (token.text === ')') {
|
||||
if (stack.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const openPos: number = stack.pop() as number;
|
||||
const startLine: number = SqlHighlighter.lineOf(lineStarts, openPos);
|
||||
const endLine: number = SqlHighlighter.lineOf(lineStarts, token.start);
|
||||
if (endLine > startLine) {
|
||||
out.push({ startLine: startLine, endLine: endLine });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static lineOf(lineStarts: number[], offset: number): number {
|
||||
let line: number = 0;
|
||||
for (let i = 0; i < lineStarts.length; i++) {
|
||||
if (lineStarts[i] <= offset) {
|
||||
line = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* SQL 分词与语句切分(1.6.1)。
|
||||
*
|
||||
* 目标:为高亮、补全、语句切分提供稳定的词法层,不依赖任何引擎。
|
||||
* 覆盖:行注释(--)、块注释(/* *\/)、单引号字符串('' 转义)、双引号标识符("" 转义)、
|
||||
* 反引号标识符(`` 转义)、数字、标识符/关键字、运算符与标点。
|
||||
*
|
||||
* 说明:这不是完整的 SQL 语法分析器;语法树与方言差异由后续 1.6.x 在词法层之上构建。
|
||||
*/
|
||||
export enum TokenKind {
|
||||
WHITESPACE = 0,
|
||||
COMMENT = 1,
|
||||
STRING = 2,
|
||||
QUOTED_IDENTIFIER = 3,
|
||||
NUMBER = 4,
|
||||
IDENTIFIER = 5,
|
||||
KEYWORD = 6,
|
||||
PUNCTUATION = 7,
|
||||
OPERATOR = 8
|
||||
}
|
||||
|
||||
export interface SqlToken {
|
||||
kind: TokenKind;
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const KEYWORDS: string[] = [
|
||||
'SELECT', 'FROM', 'WHERE', 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE',
|
||||
'CREATE', 'ALTER', 'DROP', 'TABLE', 'INDEX', 'VIEW', 'AS', 'JOIN', 'LEFT', 'RIGHT',
|
||||
'INNER', 'OUTER', 'ON', 'GROUP', 'BY', 'ORDER', 'LIMIT', 'OFFSET', 'AND', 'OR', 'NOT',
|
||||
'NULL', 'IS', 'IN', 'EXISTS', 'DISTINCT', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'HAVING',
|
||||
'UNION', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'BEGIN', 'COMMIT', 'ROLLBACK'
|
||||
];
|
||||
|
||||
export class SqlTokenizer {
|
||||
static isKeyword(word: string): boolean {
|
||||
return KEYWORDS.indexOf(word.toUpperCase()) >= 0;
|
||||
}
|
||||
|
||||
/** 全量分词(含空白与注释),token 的 start/end 为原串偏移。 */
|
||||
static tokenize(sql: string): SqlToken[] {
|
||||
const out: SqlToken[] = [];
|
||||
let i: number = 0;
|
||||
while (i < sql.length) {
|
||||
const ch: string = sql.charAt(i);
|
||||
// 空白
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
||||
const start: number = i;
|
||||
while (i < sql.length && ' \t\n\r'.indexOf(sql.charAt(i)) >= 0) {
|
||||
i++;
|
||||
}
|
||||
out.push({ kind: TokenKind.WHITESPACE, text: sql.substring(start, i), start: start, end: i });
|
||||
continue;
|
||||
}
|
||||
// 行注释
|
||||
if (ch === '-' && sql.charAt(i + 1) === '-') {
|
||||
const start: number = i;
|
||||
while (i < sql.length && sql.charAt(i) !== '\n') {
|
||||
i++;
|
||||
}
|
||||
out.push({ kind: TokenKind.COMMENT, text: sql.substring(start, i), start: start, end: i });
|
||||
continue;
|
||||
}
|
||||
// 块注释
|
||||
if (ch === '/' && sql.charAt(i + 1) === '*') {
|
||||
const start: number = i;
|
||||
i += 2;
|
||||
while (i < sql.length && !(sql.charAt(i) === '*' && sql.charAt(i + 1) === '/')) {
|
||||
i++;
|
||||
}
|
||||
i = i < sql.length ? i + 2 : i;
|
||||
out.push({ kind: TokenKind.COMMENT, text: sql.substring(start, i), start: start, end: i });
|
||||
continue;
|
||||
}
|
||||
// 单引号字符串
|
||||
if (ch === '\'') {
|
||||
const start: number = i;
|
||||
i++;
|
||||
while (i < sql.length) {
|
||||
if (sql.charAt(i) === '\'') {
|
||||
if (sql.charAt(i + 1) === '\'') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
out.push({ kind: TokenKind.STRING, text: sql.substring(start, i), start: start, end: i });
|
||||
continue;
|
||||
}
|
||||
// 双引号标识符
|
||||
if (ch === '"') {
|
||||
const start: number = i;
|
||||
i++;
|
||||
while (i < sql.length) {
|
||||
if (sql.charAt(i) === '"') {
|
||||
if (sql.charAt(i + 1) === '"') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
out.push({ kind: TokenKind.QUOTED_IDENTIFIER, text: sql.substring(start, i), start: start, end: i });
|
||||
continue;
|
||||
}
|
||||
// 反引号标识符
|
||||
if (ch === '`') {
|
||||
const start: number = i;
|
||||
i++;
|
||||
while (i < sql.length) {
|
||||
if (sql.charAt(i) === '`') {
|
||||
if (sql.charAt(i + 1) === '`') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
out.push({ kind: TokenKind.QUOTED_IDENTIFIER, text: sql.substring(start, i), start: start, end: i });
|
||||
continue;
|
||||
}
|
||||
// 数字
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
const start: number = i;
|
||||
while (i < sql.length && sql.charAt(i) >= '0' && sql.charAt(i) <= '9') {
|
||||
i++;
|
||||
}
|
||||
if (sql.charAt(i) === '.') {
|
||||
i++;
|
||||
while (i < sql.length && sql.charAt(i) >= '0' && sql.charAt(i) <= '9') {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
out.push({ kind: TokenKind.NUMBER, text: sql.substring(start, i), start: start, end: i });
|
||||
continue;
|
||||
}
|
||||
// 标识符/关键字
|
||||
if (SqlTokenizer.isIdentifierStart(ch)) {
|
||||
const start: number = i;
|
||||
while (i < sql.length && SqlTokenizer.isIdentifierPart(sql.charAt(i))) {
|
||||
i++;
|
||||
}
|
||||
const text: string = sql.substring(start, i);
|
||||
out.push({
|
||||
kind: SqlTokenizer.isKeyword(text) ? TokenKind.KEYWORD : TokenKind.IDENTIFIER,
|
||||
text: text,
|
||||
start: start,
|
||||
end: i
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 标点与运算符
|
||||
const start: number = i;
|
||||
i++;
|
||||
const kind: TokenKind = '(),;'.indexOf(ch) >= 0 ? TokenKind.PUNCTUATION : TokenKind.OPERATOR;
|
||||
out.push({ kind: kind, text: ch, start: start, end: i });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按顶层分号切分语句(字符串与注释内的分号不切分),去掉空语句。 */
|
||||
static statements(sql: string): string[] {
|
||||
const tokens: SqlToken[] = SqlTokenizer.tokenize(sql);
|
||||
const out: string[] = [];
|
||||
let current: string = '';
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token: SqlToken = tokens[i];
|
||||
if (token.kind === TokenKind.PUNCTUATION && token.text === ';') {
|
||||
const trimmed: string = current.trim();
|
||||
if (trimmed.length > 0) {
|
||||
out.push(trimmed);
|
||||
}
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += token.text;
|
||||
}
|
||||
const tail: string = current.trim();
|
||||
if (tail.length > 0) {
|
||||
out.push(tail);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 去掉注释(保留空白与其它 token 原文)。 */
|
||||
static stripComments(sql: string): string {
|
||||
const tokens: SqlToken[] = SqlTokenizer.tokenize(sql);
|
||||
let out: string = '';
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (tokens[i].kind !== TokenKind.COMMENT) {
|
||||
out += tokens[i].text;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static isIdentifierStart(ch: string): boolean {
|
||||
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$';
|
||||
}
|
||||
|
||||
private static isIdentifierPart(ch: string): boolean {
|
||||
return SqlTokenizer.isIdentifierStart(ch) || (ch >= '0' && ch <= '9');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 结果值查看器(1.6.10)。
|
||||
*
|
||||
* 对单元格值做内容嗅探,决定用哪种视图展示:文本 / JSON / 数字 / 布尔 / 二进制(HEX) / 图片。
|
||||
* 图片识别基于文件头魔数(PNG/JPEG/GIF),用于 BLOB 字段预览。
|
||||
*/
|
||||
export enum ValueKind {
|
||||
NULL_VALUE = 0,
|
||||
TEXT = 1,
|
||||
JSON = 2,
|
||||
NUMBER = 3,
|
||||
BOOLEAN = 4,
|
||||
BINARY = 5,
|
||||
IMAGE = 6
|
||||
}
|
||||
|
||||
export interface ValueView {
|
||||
kind: ValueKind;
|
||||
/** 文本视图内容(二进制时为占位文本)。 */
|
||||
text: string;
|
||||
/** 二进制值的十六进制预览(最多 64 字节)。 */
|
||||
hexPreview: string;
|
||||
}
|
||||
|
||||
export class ValueInspector {
|
||||
/** 文本值分类。 */
|
||||
static classify(value: string | undefined): ValueKind {
|
||||
if (value === undefined || value === null) {
|
||||
return ValueKind.NULL_VALUE;
|
||||
}
|
||||
const trimmed: string = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return ValueKind.TEXT;
|
||||
}
|
||||
const lower: string = trimmed.toLowerCase();
|
||||
if (lower === 'true' || lower === 'false') {
|
||||
return ValueKind.BOOLEAN;
|
||||
}
|
||||
if (ValueInspector.isNumeric(trimmed)) {
|
||||
return ValueKind.NUMBER;
|
||||
}
|
||||
if ((trimmed.charAt(0) === '{' && trimmed.charAt(trimmed.length - 1) === '}') ||
|
||||
(trimmed.charAt(0) === '[' && trimmed.charAt(trimmed.length - 1) === ']')) {
|
||||
return ValueKind.JSON;
|
||||
}
|
||||
if (ValueInspector.hasControlChars(value)) {
|
||||
return ValueKind.BINARY;
|
||||
}
|
||||
return ValueKind.TEXT;
|
||||
}
|
||||
|
||||
/** 由原始字节判断图片类型;非图片返回 undefined。 */
|
||||
static sniffImage(bytes: number[]): string | undefined {
|
||||
if (ValueInspector.startsWith(bytes, [0x89, 0x50, 0x4E, 0x47])) {
|
||||
return 'png';
|
||||
}
|
||||
if (ValueInspector.startsWith(bytes, [0xFF, 0xD8, 0xFF])) {
|
||||
return 'jpeg';
|
||||
}
|
||||
if (ValueInspector.startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) {
|
||||
return 'gif';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 构造展示模型:图片优先,其次按文本分类。 */
|
||||
static view(value: string | undefined, bytes?: number[]): ValueView {
|
||||
if (bytes !== undefined && bytes.length > 0) {
|
||||
const image: string | undefined = ValueInspector.sniffImage(bytes);
|
||||
if (image !== undefined) {
|
||||
return {
|
||||
kind: ValueKind.IMAGE,
|
||||
text: '<image:' + image + '>',
|
||||
hexPreview: ValueInspector.hexPreview(bytes, 16)
|
||||
};
|
||||
}
|
||||
}
|
||||
const kind: ValueKind = ValueInspector.classify(value);
|
||||
const text: string = value === undefined ? '' : value;
|
||||
return {
|
||||
kind: kind,
|
||||
text: text,
|
||||
hexPreview: (kind === ValueKind.BINARY && bytes !== undefined)
|
||||
? ValueInspector.hexPreview(bytes, 64)
|
||||
: ''
|
||||
};
|
||||
}
|
||||
|
||||
/** 十六进制预览(最多 maxBytes 字节,空格分隔)。 */
|
||||
static hexPreview(bytes: number[], maxBytes: number): string {
|
||||
const limit: number = maxBytes > 0 && bytes.length > maxBytes ? maxBytes : bytes.length;
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const hex: string = (bytes[i] & 0xFF).toString(16).toUpperCase();
|
||||
parts.push(hex.length < 2 ? '0' + hex : hex);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
private static isNumeric(text: string): boolean {
|
||||
let dots: number = 0;
|
||||
let start: number = 0;
|
||||
if (text.charAt(0) === '-' || text.charAt(0) === '+') {
|
||||
start = 1;
|
||||
}
|
||||
if (start >= text.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const ch: string = text.charAt(i);
|
||||
if (ch === '.') {
|
||||
dots++;
|
||||
if (dots > 1) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch < '0' || ch > '9') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static hasControlChars(text: string): boolean {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const code: number = text.charCodeAt(i);
|
||||
if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static startsWith(bytes: number[], prefix: number[]): boolean {
|
||||
if (bytes.length < prefix.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if ((bytes[i] & 0xFF) !== prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 形态与断点适配(1.23.6)。
|
||||
*
|
||||
* 统一描述 PC / 平板 / 折叠屏(折叠与展开)下的布局参数:
|
||||
* 断点阈值与 1.23.1 规范一致(sm < 600vp,md < 840vp,lg ≥ 840vp),
|
||||
* 列数 1/2/3,对象树宽度 0/240/280,侧栏 0/64/72。
|
||||
*
|
||||
* 纯逻辑,不依赖 ArkUI;UI 层据此选择布局组件。
|
||||
*/
|
||||
export enum Orientation {
|
||||
PORTRAIT = 0,
|
||||
LANDSCAPE = 1
|
||||
}
|
||||
|
||||
export enum DeviceForm {
|
||||
PC = 0,
|
||||
TABLET = 1,
|
||||
FOLDABLE_FOLDED = 2,
|
||||
FOLDABLE_UNFOLDED = 3
|
||||
}
|
||||
|
||||
export enum Breakpoint {
|
||||
SM = 0,
|
||||
MD = 1,
|
||||
LG = 2
|
||||
}
|
||||
|
||||
export interface FormContext {
|
||||
width: number;
|
||||
height: number;
|
||||
orientation: Orientation;
|
||||
form: DeviceForm;
|
||||
breakpoint: Breakpoint;
|
||||
}
|
||||
|
||||
export interface LayoutSpec {
|
||||
columns: number;
|
||||
treeWidth: number;
|
||||
railWidth: number;
|
||||
/** 结果区是否可并排显示。 */
|
||||
sidePanel: boolean;
|
||||
}
|
||||
|
||||
export class FormAdapter {
|
||||
static readonly SM_MAX: number = 599;
|
||||
static readonly MD_MAX: number = 839;
|
||||
|
||||
/** 由宽高与设备形态推断上下文。 */
|
||||
static classify(width: number, height: number, form: DeviceForm): FormContext {
|
||||
const orientation: Orientation = width > height ? Orientation.LANDSCAPE : Orientation.PORTRAIT;
|
||||
return {
|
||||
width: width,
|
||||
height: height,
|
||||
orientation: orientation,
|
||||
form: form,
|
||||
breakpoint: FormAdapter.breakpointOf(width)
|
||||
};
|
||||
}
|
||||
|
||||
static breakpointOf(width: number): Breakpoint {
|
||||
if (width <= FormAdapter.SM_MAX) {
|
||||
return Breakpoint.SM;
|
||||
}
|
||||
if (width <= FormAdapter.MD_MAX) {
|
||||
return Breakpoint.MD;
|
||||
}
|
||||
return Breakpoint.LG;
|
||||
}
|
||||
|
||||
/** 布局参数。 */
|
||||
static layoutOf(context: FormContext): LayoutSpec {
|
||||
switch (context.breakpoint) {
|
||||
case Breakpoint.SM:
|
||||
return { columns: 1, treeWidth: 0, railWidth: 0, sidePanel: false };
|
||||
case Breakpoint.MD:
|
||||
return { columns: 2, treeWidth: 240, railWidth: 64, sidePanel: false };
|
||||
default:
|
||||
return { columns: 3, treeWidth: 280, railWidth: 72, sidePanel: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** 折叠屏:展开视为大屏平板,折叠视为手机宽度。 */
|
||||
static resolveFoldable(width: number, height: number, unfolded: boolean): FormContext {
|
||||
const form: DeviceForm = unfolded ? DeviceForm.FOLDABLE_UNFOLDED : DeviceForm.FOLDABLE_FOLDED;
|
||||
return FormAdapter.classify(width, height, form);
|
||||
}
|
||||
|
||||
/** 尺寸变化是否引起断点或形态变化(用于决定重建布局还是仅重排)。 */
|
||||
static requiresRelayout(previous: FormContext, next: FormContext): boolean {
|
||||
return previous.breakpoint !== next.breakpoint || previous.form !== next.form ||
|
||||
previous.orientation !== next.orientation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 输入形态差异化分派(1.23.8)。
|
||||
*
|
||||
* 同一意图在不同输入源上的表达不同:
|
||||
* - 上下文菜单:鼠标右键 / 键盘 Shift+F10 / 触控长按;
|
||||
* - 打开:鼠标双击 / 触控双击;
|
||||
* - 范围选择:Shift+点击 / 触控拖选;
|
||||
* - 悬停提示:仅鼠标(触控无 hover)。
|
||||
*
|
||||
* 本模块把「输入源 + 手势」映射为统一动作(复用 PointerResolver 的动作枚举语义)。
|
||||
*/
|
||||
export enum InputSource {
|
||||
KEYBOARD = 0,
|
||||
MOUSE = 1,
|
||||
TOUCH = 2,
|
||||
PEN = 3
|
||||
}
|
||||
|
||||
export enum InputGesture {
|
||||
TAP = 0,
|
||||
DOUBLE_TAP = 1,
|
||||
LONG_PRESS = 2,
|
||||
RIGHT_CLICK = 3,
|
||||
HOVER = 4,
|
||||
DRAG = 5,
|
||||
SHIFT_TAP = 6,
|
||||
CTRL_TAP = 7,
|
||||
KEY_MENU = 8
|
||||
}
|
||||
|
||||
export enum DispatchAction {
|
||||
NONE = 0,
|
||||
SELECT = 1,
|
||||
TOGGLE_SELECT = 2,
|
||||
RANGE_SELECT = 3,
|
||||
OPEN = 4,
|
||||
CONTEXT_MENU = 5,
|
||||
DRAG_START = 6,
|
||||
TOOLTIP = 7
|
||||
}
|
||||
|
||||
export class InputDispatcher {
|
||||
static dispatch(source: InputSource, gesture: InputGesture): DispatchAction {
|
||||
switch (gesture) {
|
||||
case InputGesture.TAP:
|
||||
if (source === InputSource.TOUCH || source === InputSource.PEN) {
|
||||
return DispatchAction.SELECT;
|
||||
}
|
||||
return DispatchAction.SELECT;
|
||||
case InputGesture.DOUBLE_TAP:
|
||||
return DispatchAction.OPEN;
|
||||
case InputGesture.LONG_PRESS:
|
||||
// 触控长按等价于右键菜单;鼠标长按不触发菜单
|
||||
return source === InputSource.TOUCH || source === InputSource.PEN
|
||||
? DispatchAction.CONTEXT_MENU
|
||||
: DispatchAction.NONE;
|
||||
case InputGesture.RIGHT_CLICK:
|
||||
return DispatchAction.CONTEXT_MENU;
|
||||
case InputGesture.HOVER:
|
||||
return source === InputSource.MOUSE ? DispatchAction.TOOLTIP : DispatchAction.NONE;
|
||||
case InputGesture.DRAG:
|
||||
return DispatchAction.DRAG_START;
|
||||
case InputGesture.SHIFT_TAP:
|
||||
return DispatchAction.RANGE_SELECT;
|
||||
case InputGesture.CTRL_TAP:
|
||||
return DispatchAction.TOGGLE_SELECT;
|
||||
case InputGesture.KEY_MENU:
|
||||
return DispatchAction.CONTEXT_MENU;
|
||||
default:
|
||||
return DispatchAction.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
/** 该输入源是否支持悬停。 */
|
||||
static supportsHover(source: InputSource): boolean {
|
||||
return source === InputSource.MOUSE || source === InputSource.PEN;
|
||||
}
|
||||
|
||||
/** 该输入源是否使用长按作为上下文菜单入口。 */
|
||||
static usesLongPressForMenu(source: InputSource): boolean {
|
||||
return source === InputSource.TOUCH || source === InputSource.PEN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* PC 键鼠交互解析(1.23.4)。
|
||||
*
|
||||
* 把鼠标事件(按钮 + 单击次数 + 修饰键)与所在区域映射为统一交互动作,
|
||||
* 由 UI 层按动作分发。右键菜单、范围选择、Ctrl 多选等规则集中在此,避免散落在组件里。
|
||||
*
|
||||
* 键盘快捷键见 1.7.12 的 KeymapRegistry;本模块只处理指针。
|
||||
*/
|
||||
export enum MouseButton {
|
||||
LEFT = 0,
|
||||
MIDDLE = 1,
|
||||
RIGHT = 2
|
||||
}
|
||||
|
||||
export enum InteractionContext {
|
||||
GRID = 0,
|
||||
TREE = 1,
|
||||
EDITOR = 2
|
||||
}
|
||||
|
||||
export enum InteractionAction {
|
||||
NONE = 0,
|
||||
SELECT = 1,
|
||||
TOGGLE_SELECT = 2,
|
||||
RANGE_SELECT = 3,
|
||||
OPEN = 4,
|
||||
CONTEXT_MENU = 5,
|
||||
RENAME = 6
|
||||
}
|
||||
|
||||
export interface PointerInput {
|
||||
button: MouseButton;
|
||||
/** 1 单击,2 双击(连续点击合并由 UI 层完成)。 */
|
||||
clickCount: number;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
|
||||
export class PointerResolver {
|
||||
static resolve(context: InteractionContext, input: PointerInput): InteractionAction {
|
||||
if (input.button === MouseButton.RIGHT) {
|
||||
return InteractionAction.CONTEXT_MENU;
|
||||
}
|
||||
if (input.button === MouseButton.MIDDLE) {
|
||||
return InteractionAction.NONE;
|
||||
}
|
||||
if (input.clickCount >= 2) {
|
||||
return PointerResolver.doubleClick(context, input);
|
||||
}
|
||||
if (input.shift) {
|
||||
return InteractionAction.RANGE_SELECT;
|
||||
}
|
||||
if (input.ctrl) {
|
||||
return InteractionAction.TOGGLE_SELECT;
|
||||
}
|
||||
return InteractionAction.SELECT;
|
||||
}
|
||||
|
||||
/** 双击语义:网格打开详情,树展开/打开,编辑器选词。 */
|
||||
private static doubleClick(context: InteractionContext, input: PointerInput): InteractionAction {
|
||||
if (context === InteractionContext.EDITOR) {
|
||||
return InteractionAction.SELECT;
|
||||
}
|
||||
if (context === InteractionContext.TREE && input.alt) {
|
||||
return InteractionAction.RENAME;
|
||||
}
|
||||
return InteractionAction.OPEN;
|
||||
}
|
||||
|
||||
/** 动作的中文名(供菜单/提示使用)。 */
|
||||
static actionLabel(action: InteractionAction): string {
|
||||
switch (action) {
|
||||
case InteractionAction.SELECT:
|
||||
return '选择';
|
||||
case InteractionAction.TOGGLE_SELECT:
|
||||
return '切换选择';
|
||||
case InteractionAction.RANGE_SELECT:
|
||||
return '范围选择';
|
||||
case InteractionAction.OPEN:
|
||||
return '打开';
|
||||
case InteractionAction.CONTEXT_MENU:
|
||||
return '上下文菜单';
|
||||
case InteractionAction.RENAME:
|
||||
return '重命名';
|
||||
default:
|
||||
return '无';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* PC 窗口状态模型(1.23.3)。
|
||||
*
|
||||
* 管理窗口尺寸/位置约束与恢复策略:
|
||||
* - 最小/最大尺寸限制;
|
||||
* - 从持久化状态恢复时裁剪到可用屏幕;
|
||||
* - 分屏/最大化/浮动模式判定;
|
||||
* - 与 FormAdapter 联动得到断点布局。
|
||||
*
|
||||
* 实际窗口 API(window.setWindowLayout 等)由平台层调用,本模型只做状态计算。
|
||||
*/
|
||||
import { Breakpoint, DeviceForm, FormAdapter, FormContext } from './FormAdapter';
|
||||
|
||||
export enum WindowMode {
|
||||
FULLSCREEN = 0,
|
||||
SPLIT = 1,
|
||||
FLOATING = 2,
|
||||
MAXIMIZED = 3
|
||||
}
|
||||
|
||||
export interface WindowState {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
mode: WindowMode;
|
||||
}
|
||||
|
||||
export interface WindowLimits {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
export class WindowLayout {
|
||||
/** 桌面办公工具的默认约束。 */
|
||||
static defaultLimits(): WindowLimits {
|
||||
return { minWidth: 480, minHeight: 360, maxWidth: 4096, maxHeight: 4096 };
|
||||
}
|
||||
|
||||
/** 尺寸裁剪到 [min, max]。 */
|
||||
static clampSize(width: number, height: number, limits: WindowLimits): { width: number; height: number } {
|
||||
return {
|
||||
width: Math.min(limits.maxWidth, Math.max(limits.minWidth, width)),
|
||||
height: Math.min(limits.maxHeight, Math.max(limits.minHeight, height))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复已保存的窗口状态:
|
||||
* - 尺寸裁剪到约束;
|
||||
* - 位置裁剪到可用屏幕(保证标题栏可见);
|
||||
* - 屏幕过小时降级为最大化。
|
||||
*/
|
||||
static restore(saved: WindowState, screenWidth: number, screenHeight: number, limits: WindowLimits): WindowState {
|
||||
const size = WindowLayout.clampSize(saved.width, saved.height, limits);
|
||||
if (size.width >= screenWidth || size.height >= screenHeight) {
|
||||
return {
|
||||
width: Math.max(limits.minWidth, screenWidth),
|
||||
height: Math.max(limits.minHeight, screenHeight),
|
||||
x: 0,
|
||||
y: 0,
|
||||
mode: WindowMode.MAXIMIZED
|
||||
};
|
||||
}
|
||||
const maxX: number = Math.max(0, screenWidth - size.width);
|
||||
const maxY: number = Math.max(0, screenHeight - size.height);
|
||||
return {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
x: Math.min(maxX, Math.max(0, saved.x)),
|
||||
y: Math.min(maxY, Math.max(0, saved.y)),
|
||||
mode: saved.mode
|
||||
};
|
||||
}
|
||||
|
||||
/** 根据窗口宽度推断模式:占满屏幕为最大化,否则浮动。 */
|
||||
static modeOf(width: number, screenWidth: number): WindowMode {
|
||||
return width >= screenWidth - 1 ? WindowMode.MAXIMIZED : WindowMode.FLOATING;
|
||||
}
|
||||
|
||||
/** 窗口状态 → 断点上下文。 */
|
||||
static toFormContext(state: WindowState): FormContext {
|
||||
return FormAdapter.classify(state.width, state.height, DeviceForm.PC);
|
||||
}
|
||||
|
||||
/** 是否需要重建布局(模式或断点变化)。 */
|
||||
static requiresRelayout(previous: WindowState, next: WindowState): boolean {
|
||||
if (previous.mode !== next.mode) {
|
||||
return true;
|
||||
}
|
||||
return FormAdapter.breakpointOf(previous.width) !== FormAdapter.breakpointOf(next.width);
|
||||
}
|
||||
|
||||
/** 断点对应的最小可用宽度建议(避免布局挤压)。 */
|
||||
static recommendedMinWidth(breakpoint: Breakpoint): number {
|
||||
switch (breakpoint) {
|
||||
case Breakpoint.SM:
|
||||
return 480;
|
||||
case Breakpoint.MD:
|
||||
return 600;
|
||||
default:
|
||||
return 840;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "data",
|
||||
"type": "har",
|
||||
"deviceTypes": [
|
||||
"tablet",
|
||||
"2in1"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "data_module_desc",
|
||||
"value": "库阁数据层:对象模型、元数据、查询会话与结果集"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user