From ebfefc2295b897a45d5422099e141df779dd2e34 Mon Sep 17 00:00:00 2001 From: yizhi <946185759@qq.com> Date: Wed, 30 Oct 2024 14:31:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AC=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + package.json | 22 ++ src/database.ts | 182 +++++++++ src/entity.ts | 389 ++++++++++++++++++++ src/error.ts | 6 + src/index.ts | 13 + src/query.ts | 856 +++++++++++++++++++++++++++++++++++++++++++ src/test.ts | 104 ++++++ src/type/index.ts | 1 + src/type/tsvector.ts | 127 +++++++ src/types.ts | 62 ++++ src/util.ts | 35 ++ tsconfig.json | 100 +++++ 13 files changed, 1902 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/database.ts create mode 100644 src/entity.ts create mode 100644 src/error.ts create mode 100644 src/index.ts create mode 100644 src/query.ts create mode 100644 src/test.ts create mode 100644 src/type/index.ts create mode 100644 src/type/tsvector.ts create mode 100644 src/types.ts create mode 100644 src/util.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7625ea5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/node_modules +/dist +/typing +/package-lock.json +/.vscode diff --git a/package.json b/package.json new file mode 100644 index 0000000..7832972 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "@yizhi/database", + "version": "1.0.0", + "main": "dist/index.js", + "types": "typing/index.d.ts", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "moment": "^2.30.1", + "pg": "^8.13.1", + "pinyin": "^4.0.0-alpha.2" + }, + "devDependencies": { + "@types/node": "^22.8.1", + "@types/pg": "^8.11.10", + "@types/pinyin": "^2.10.2", + "typescript": "^5.6.3" + } +} diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..ca1d3d6 --- /dev/null +++ b/src/database.ts @@ -0,0 +1,182 @@ +import { Pool, PoolClient, QueryResult, QueryResultRow } from "pg"; +import { BasicEntity } from "./entity"; +import { DeleteBuilder, IDeleteBuilder, IInsertBuilder, InsertBuilder, ISelectBuilder, IUpdateBuilder, SelectBuilder, UpdateBuilder } from "./query"; +import { Class } from "./types"; +import { formatSQL } from "./util"; + +interface IPostgresClient { + /** + * 执行sql语句 + * @param sql sql语句 + * @param args sql参数 + */ + query(sql: string, args?: any[]): Promise>; + + /** 开启事务 */ + trans(): Promise; + + /** + * 查询实体 + * @param Entity 实体 + * @param alias 别名 + */ + select(Entity: Class, alias?: string): ISelectBuilder; + + /** + * 进行数据库插入 + * @param Entity 实体 + */ + insert(Entity: Class): IInsertBuilder; + + /** + * 进行实体更新 + * @param Entity 实体 + */ + update(Entity: Class): IUpdateBuilder; + + /** + * 进行实体删除 + * @param Entity 实体 + */ + del(Entity: Class): IDeleteBuilder; +} + +interface IPostgresConfig { + host?: string + port?: number + user?: string + password?: string + database?: string + max?: number +} + +class PostgresClient implements IPostgresClient { + #client: PoolClient + #transOn = false; + + constructor(client: PoolClient) { + this.#client = client; + } + + public query(sql: string, args?: any[] | Record): Promise> { return this.#client.query(args ? formatSQL(sql, args) : sql); } + + + public async trans() { + await this.query("begin") + this.#transOn = true; + } + + public get transOn() { return this.#transOn; } + + public release() { this.#client.release(); } + + public select(Entity: Class, alias?: string): ISelectBuilder { return new SelectBuilder(this.query.bind(this), Entity, alias); } + + public insert(Entity: Class): IInsertBuilder { return new InsertBuilder(this.query.bind(this), Entity); } + + public update(Entity: Class): IUpdateBuilder { return new UpdateBuilder(this.query.bind(this), Entity); } + + public del(Entity: Class): IDeleteBuilder { return new DeleteBuilder(this.query.bind(this), Entity); } +}; + +/** 数据库操作相关 */ +export namespace database { + + let pool: Pool | null = null; + let confLoader!: () => IPostgresConfig + + function getPool() { + if (!pool) { + const conf = confLoader(); + pool = new Pool({ + host: conf.host, + port: conf.port, + user: conf.user, + password: conf.password, + database: conf.database, + max: conf.max, + ssl: false, + }); + } + return pool; + } + + /** + * 配置数据库 + * @param loader 配置加载器 + */ + export function config(loader: () => IPostgresConfig) { + confLoader = loader; + } + + /** + * 直接执行sql语句 + * @param sql sql语句 + * @param args sql参数 + */ + export function query(sql: string, args?: any[] | Record) { return getPool().query(args ? formatSQL(sql, args) : sql); } + + /** + * 查询实体 + * @param Entity 实体 + * @param alias 别名 + */ + export function select(Entity: Class, alias?: string): ISelectBuilder { return new SelectBuilder(database.query, Entity, alias); } + + /** + * 进行数据库插入 + * @param Entity 实体 + */ + export function insert(Entity: Class): IInsertBuilder { return new InsertBuilder(database.query, Entity); } + + /** + * 进行实体更新 + * @param Entity 实体 + */ + export function update(Entity: Class): IUpdateBuilder { return new UpdateBuilder(database.query, Entity); } + + /** + * 进行实体删除 + * @param Entity 实体 + */ + export function del(Entity: Class): IDeleteBuilder { return new DeleteBuilder(database.query, Entity); } + + /** + * 连接数据库 + * @param callback 回调 + */ + export async function connect(callback: (client: PostgresClient) => R | Promise): Promise { + const conn = await getPool().connect(); + const client = new PostgresClient(conn); + let ret: R; + try { + ret = await callback(client); + if (client.transOn) await client.query("commit"); + } + catch (err) { + if (client.transOn) await client.query("rollback"); + throw err; + } + finally { + client.release(); + } + + return ret; + } + + /** + * 执行事务操作 + * @param callback 回调 + */ + export function transaction(callback: (client: PostgresClient) => R | Promise): Promise { + return connect(async (client) => { + await client.trans(); + return callback(client); + }); + }; + + /** 关闭数据库 */ + export function close() { + if (pool) pool.end().catch((err) => console.error(err)); + } +} diff --git a/src/entity.ts b/src/entity.ts new file mode 100644 index 0000000..d94d884 --- /dev/null +++ b/src/entity.ts @@ -0,0 +1,389 @@ +import moment from "moment"; +import { DatabaseError } from "./error"; +import { TSVector } from "./type"; +import { escapeID, escapeValue } from "./util"; +import type { ArrayType, ArrayTypeSelf, BasicFieldName, Class, Decorators, IDLikeFieldName, Values } from "./types"; + +export interface IEntityFieldConfig { + /** 字段名 */ + name: string + /** 解析后的属性名 */ + prop: string + /** 是否是主键 */ + primary: boolean + /** 编码方式(存入数据库时) */ + encode: (data: any) => string + /** 解码方式(查询数据库时) */ + decode: (data: any) => any +} + + +export interface IEntityJoinConfig { + /** 属性名称 */ + prop: string + /** 是否是many */ + many: boolean + /** 目标表 */ + entity: () => any + /** + * join条件生成方法 + * @param cur 当前表名 + * @param dst 目标表名 + */ + builder(cur: string, dst: string): string +} + +export interface IEntityConfig { + /** 类信息 */ + clazz: any + /** 模式名 */ + schema?: string + /** 表名 */ + table: string + /** 字段配置 */ + fields: IEntityFieldConfig[] + /** join信息 */ + joins: IEntityJoinConfig[] +} + +export class BasicEntity { + private ___is_db_entity___ = true + + public static make(this: Class, data: Partial>>, prefix?: string) { + const obj = new this(); + const config = getEntityConfig(this); + for (const col of config.fields) { + const pname = prefix ? `${prefix}${col.prop}` : col.prop; + if (pname in data) (obj as any)[col.prop] = (data as any)[pname]; + } + return obj; + } + + /** + * 提取指定属性 + * @param keys 要提取的属性 + */ + public pick>(...keys: K[]): Pick { + const result: any = {}; + for (const k of keys) { + result[k] = this[k]; + } + return result; + } + + /** + * 排除指定的属性,提取剩余属性 + * @param keys 要排除的键 + */ + public omit>(...keys: K[]): Omit>, K> { + const result: any = {}; + const config = getEntityConfigWithoutCheck(this.constructor)!; + for (const col of config.fields) { + if (keys.includes(col.prop as K)) continue; + result[col.prop] = this[col.prop as K]; + } + return result; + } +} + +function entityConfigOf(target: any): IEntityConfig { + const cnf = target.__entity_config__ as IEntityConfig; + + if (!cnf) target.__entity_config__ = { + clazz: target, + fields: [], + joins: [] + }; + else if (cnf.clazz != target) target.__entity_config__ = { + ...cnf, + clazz: target, + fields: [...cnf.fields], + joins: [...cnf.joins], + } + return target.__entity_config__; +} + +function getEntityConfigWithoutCheck(target: any): IEntityConfig | null { + return target?.__entity_config__ ?? null; +} + + +/** + * 指定实体的表名 + * @param table 表名,如果需要指定schema,可以使用"."的格式 + */ +export function Table(table: string): Decorators.ClassDecorator { + return function (target) { + const [schema, tbl] = table.split(".").map(s => s.trim()).filter(s => !!s); + const conf = entityConfigOf(target); + conf.table = table; + if (!tbl) { + conf.schema = undefined; + conf.table = schema; + } + else { + conf.schema = schema; + conf.table = tbl; + } + } +} + +/** + * 定义一个字段 + * @param name 字段名称 + * @param encode 字段编码凡是 + * @param decode 字段解码方式 + * @param primary 是否是主键 + */ +export function Field(name: string | undefined, encode: IEntityFieldConfig["encode"], decode: IEntityFieldConfig["decode"], primary: boolean): Decorators.PropDecorator { + return function (target, propertyKey) { + const conf = entityConfigOf(target.constructor) + conf.fields.push({ name: name ?? propertyKey, prop: propertyKey, encode, decode, primary }); + } +} + + +/** + * 定义一个整形字段 + * @param name 字段名称 + * @param option 字段选项 + */ +export function IntColumn(name: string, option?: { primary: boolean }): Decorators.PropDecorator +export function IntColumn(option?: { primary: boolean }): Decorators.PropDecorator +export function IntColumn(name?: any, option?: any) { + if (typeof name != "string") { + option = name; + name = undefined; + } + return Field(name, v => escapeValue(v), v => v, option?.primary ?? false); +} + + +/** + * 定义浮点数(包括定点小数) + * @param name 字段名称 + */ +export function FloatColumn(name?: string) { return Field(name, v => escapeValue(v), v => v, false); } + + +/** + * 定义字符串字段(支持,char varchar text) + * @param name 字段名称 + * @param option 字段选项 + */ +export function StringColumn(name: string, option?: { primary?: boolean }): Decorators.PropDecorator +export function StringColumn(option?: { primary?: boolean }): Decorators.PropDecorator +export function StringColumn(name?: any, option?: any) { + if (typeof name != "string") { + option = name + name = undefined + } + return Field(name, v => escapeValue(v), v => v, option?.primary ?? false); +} + +/** + * 定义布尔字段 + * @param name 字段名称 + */ +export function BooleanColumn(name?: string) { + const checker = (v: any) => { + if (typeof v == "boolean") return v; + if (typeof v != "string") return false; + v = v.toLowerCase(); + return ["true", "yes", "on"].includes(v) + } + + return Field(name, (v) => escapeValue(checker(v)), checker, false); +} + +/** + * 定义日期字段 + * @param name 字段名称 + */ +export function DateColumn(name?: string) { + return Field(name, v => { + if (v instanceof DateColumn) v = moment(v); + if (typeof v == "string") v = moment(v); + if (!moment.isMoment(v)) return escapeValue(null); + return escapeValue(v.format("YYYY-MM-DD")); + }, v => { + if (typeof v == "string" || (v instanceof global.Date)) return moment(v).format("YYYY-MM-DD"); + return null; + }, false); +} + +/** + * 定义日期时间字段 + * @param name 字段名称 + */ +export function DateTimeColumn(name?: string) { + return Field(name, v => { + if (v instanceof DateColumn) v = moment(v); + if (typeof v == "string") v = moment(v); + if (!moment.isMoment(v)) return escapeValue(null); + return escapeValue(v.format("YYYY-MM-DD HH:mm:ss")); + }, v => { + if (typeof v == "string" || (v instanceof global.Date)) return moment(v).format("YYYY-MM-DD HH:mm:ss"); + return null; + }, false); +} + + +//数组定义 +function _Array(name: string | undefined, typeName: string, itemEncoder: (v: any) => (string | null), itemDecoder: ((v: any) => (T | null)) | undefined) { + return Field(name, v => { + if (!(v instanceof Array)) throw new DatabaseError("DB_ENTITY_FIELD_NOT_ARRAY", `字段${name}需要给定一个数组`); + return escapeValue(`{${v.map(item => itemEncoder(item)).join(",")}}`) + `::${typeName}`; + }, v => { + if (!(v instanceof Array)) return null; + if (itemDecoder) return v.map(item => itemDecoder(item)); + else return v; + }, false); +} + +/** + * 定义整数数组,可以通过option.bytes来设置整数长度,默认为4 + * @param name 字段名称 + * @param option 字段选项 + */ +export function IntArrayColumn(name: string, option?: { bytes?: 2 | 4 | 8 }): Decorators.PropDecorator +export function IntArrayColumn(option?: { bytes?: 2 | 4 | 8 }): Decorators.PropDecorator +export function IntArrayColumn(name?: any, option?: any) { + if (name && typeof name !== "string") { + option = name + name = undefined + } + + return _Array(name, `int${option?.bytes ?? 4}[]`, item => { + item = parseInt(item); + if (isNaN(item) || !isFinite(item)) return null; + return item.toString(); + }, item => { + item = parseInt(item); + if (isNaN(item) || !isFinite(item)) return null; + return item; + }); +} + +/** + * 定义字符串数组 + * @param name 字段名称 + */ +export function StringArrayColumn(name?: string) { + return _Array(name, "varchar[]", item => { + if (typeof item == "string") return item; + else return null; + }, item => item); +} + +/** + * 定义jsonb字段 + * @param name 字段名称 + */ +export function JsonColumn(name?: string) { + return Field(name, v => escapeValue(JSON.stringify(v)) + "::jsonb", v => v, false); +} + +/** + * 定义tsvector字段 + * @param name 字段名称 + */ +export function TSVectorColumn(name?: string) { + return Field(name, v => { + if (!(v instanceof TSVector)) throw new DatabaseError("DB_ENTITY_FIELD_NOT_TSVECTOR", `字段${name}需要给定一个TSVector`) + return `E'${v.value}'::tsvector`; + }, v => TSVector.fromValue(v), false); +} + +//获取实体配置 +export function getEntityConfig(clazz: any) { + const conf = getEntityConfigWithoutCheck(clazz); + if (!conf) throw new DatabaseError("DB_ENTITY_NOT_DECLARE", `实体${clazz.name}未注册`); + return conf; +} + +//获取表名 +export function getTableName(clazz: any) { + const tname = getEntityConfigWithoutCheck(clazz)?.table; + if (!tname) throw new DatabaseError("DB_ENTITY_TABLE_NOT_DECLARE", `实体${clazz.name}缺少表格装饰`); + return tname; +} + +//获取字段配置 +export function getField(clazz: any, prop: string) { + const field = getEntityConfigWithoutCheck(clazz)?.fields.find(f => f.prop == prop); + if (!field) throw new DatabaseError("DB_ENTITY_FIELD_NOT_DECLARE", `实体${clazz.name}缺少字段${prop}的装饰`); + return field; +} + +//获取字段名 +export function getFieldName(clazz: any, prop: string) { + return getField(clazz, prop).name; +} + +//获取主键字段名 +export function getPrimaryFieldName(clazz: any) { + const pname = getEntityConfigWithoutCheck(clazz)?.fields.find(f => f.primary)?.name; + if (!pname) throw new DatabaseError("DB_ENTITY_FIELD_NOT_DECLARE", `实体${clazz.name}缺少主键装饰`); + return pname; +} + +export function getJoin(clazz: any, prop: string) { + const join = getEntityConfigWithoutCheck(clazz)?.joins.find(j => j.prop == prop); + if (!join) throw new DatabaseError("DB_ENTITY_JOIN_NOT_DECLARE", `实体${clazz.name}的${prop}属性缺少join装饰`); + return join; +} + +/** + * 一对一或多对一Join + * @param entity 要Join的实体 + * @param propOrBuilder Join的字段或Join构造器 + */ +export function JoinOne(entity: () => Class, prop: IDLikeFieldName): Decorators.PropDecorator +export function JoinOne(entity: () => Class, prop: IDLikeFieldName, ref: IDLikeFieldName): Decorators.PropDecorator +export function JoinOne(entity: () => Class, builder: ((cur: string, dst: string) => string)): Decorators.PropDecorator +export function JoinOne(entity: () => Class, propOrBuilder: IDLikeFieldName | ((cur: string, dst: string) => string), ref?: IDLikeFieldName): Decorators.PropDecorator { + return function (target, propertyKey) { + const conf = entityConfigOf(target.constructor); + conf.joins.push({ + prop: propertyKey, + entity: entity, + many: false, + builder: (cur, dst) => { + if (typeof propOrBuilder == "function") return propOrBuilder(cur, dst); + const refName = ref ? getFieldName(entity(), ref as string) : getPrimaryFieldName(entity()); + // return `"${cur}"."${getFieldName(target.constructor, propOrBuilder as string)}"="${dst}"."${getPrimaryFieldName(entity())}"` + return `${escapeID(cur, getFieldName(target.constructor, propOrBuilder as string))}=${escapeID(dst, refName)}` + } + }); + } +} + + +/** + * 一对多Join + * @param entity 要Join的实体 + * @param prop Join的字段 + * @param ref 当前表引用的字段 + * @param builder Join构造器 + */ +export function JoinMany(entity: () => Class, prop: IDLikeFieldName): Decorators.PropDecorator +export function JoinMany(entity: () => Class, prop: IDLikeFieldName, ref: IDLikeFieldName): Decorators.PropDecorator +export function JoinMany(entity: () => Class, builder: ((cur: string, dst: string) => string)): Decorators.PropDecorator +export function JoinMany(entity: () => Class, propOrBuilder: IDLikeFieldName | ((cur: string, dst: string) => string), ref?: IDLikeFieldName): Decorators.PropDecorator { + return function (target, propertyKey) { + const conf = entityConfigOf(target.constructor); + conf.joins.push({ + prop: propertyKey, + entity: entity, + many: true, + builder: (cur, dst) => { + if (typeof propOrBuilder == "function") return propOrBuilder(cur, dst); + const refName = ref ? getFieldName(target.constructor, ref as string) : getPrimaryFieldName(target.constructor); + // return `"${dst}"."${getFieldName(entity(), propOrBuilder as string)}"="${cur}"."${getPrimaryFieldName(target.constructor)}"` + return `${escapeID(dst, getFieldName(entity(), propOrBuilder as string))}=${escapeID(cur, refName)}` + } + }); + } +} + diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..5ecf857 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,6 @@ +/** 数据库错误 */ +export class DatabaseError extends Error { + constructor(public readonly code: string, message: string) { + super(message); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8783c28 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +export { + Table, + Field, IntColumn, FloatColumn, StringColumn, BooleanColumn, DateColumn, DateTimeColumn, JsonColumn, + IntArrayColumn, StringArrayColumn, + TSVectorColumn, + JoinOne, JoinMany, + BasicEntity, +} from "./entity"; + +export * from "./type"; +export * from "./database"; +export * from "./error"; +export * from "./util"; \ No newline at end of file diff --git a/src/query.ts b/src/query.ts new file mode 100644 index 0000000..1648b23 --- /dev/null +++ b/src/query.ts @@ -0,0 +1,856 @@ +import { Pool, PoolClient, QueryResult, QueryResultRow } from "pg"; +import { BasicEntity, IEntityFieldConfig, IEntityJoinConfig, getEntityConfig, getField, getJoin, getTableName } from "./entity"; +import type { JoinaFieldName, Class, ArrayTypeSelf, Values } from "./types"; +import { escapeID, escapeValue, formatSQL } from "./util"; + + +//编译器原因,不加此段代码会出错,具体原因未知,请保持定义和entity.ts重的同名定义一致 +/** 取基础类型的字段名 */ +export type BasicFieldName = Values<{ [P in keyof T]: + //忽略never + Exclude extends never ? never : ( + //忽略函数 + Exclude extends Function ? never : ( + //忽略实体 + ArrayTypeSelf> extends BasicEntity ? never : ( + P + ) + ) + ) +}>; + +interface IQueryFunc { + (sql: string, args?: any[]): Promise> +} + + +type StringArgs = Str extends `${string}${P}${infer R}${S}${infer T}` ? (R | StringArgs) : never; + + +type BasicWhere = never + | { in: V[] } | { nin: V[] } + | { ne: V | null } | { gt: V } | { gte: V } | { lt: V } | { lte: V } + | { like: string } | { nlike: string } + +type ConditionOption = { [P in BasicFieldName]?: Exclude | BasicWhere> | null } + +/** 搜索选项映射关系 */ +interface ISearchOptionMap { + /** tsquery搜索 */ + tsquery: string | string[] | { + /** 关键字 */ + keywords: string | string[] + /** 是否进行绝对匹配,如果为true,则只匹配关键字,否则匹配关键字前缀,默认true */ + absolute?: boolean + /** 匹配方式,默认or */ + method?: "or" | "and" + } + /** like搜索 */ + like: string | { + /** 搜索关键字 */ + keywords: string + /** 搜索方式, left表示左匹配,right表示右匹配,both表示两边匹配,默认both */ + method?: "left" | "right" | "both" + } +} + +interface IWhereFn { + /** + * 设置where条件 + * @param option 条件选项 + * @param sql 自定义sql + * @param args sql参数 + */ + where(option: ConditionOption): this + where(sql: string, args?: any[]): this + where(sql: S, args: { [P in StringArgs]: any }): this + + /** + * 添加搜索条件 + * + * 搜索条件会单独生成一段where语句,并与其他where条件使用AND连接 + * + * @param type 搜索类型 + * @param field 搜索字段 + * @param option 搜索选项 + */ + search(type: T, field: BasicFieldName, option: ISearchOptionMap[T]): this +} + +interface IJoinFn { + /** + * 进行JOIN操作 + * @param name 字段名称 + * @param callback Join后的具体操作 + */ + join>(name: N, callback?: (joinner: IJoinBuilder>) => any): this + + /** + * 进行JOIN操作 + * @param name 字段名称 + * @param alias JOIN别名 + * @param callback Join后的具体操作 + */ + join>(name: N, alias: string, callback?: (joinner: IJoinBuilder>) => any): this +} + +interface IFilterFn { + /** + * 查询字段过滤 + * @param columns 要查询的字段,如果补传递则查询所有字段 + */ + filter(...columns: Array | { [P in BasicFieldName]+?: string }>): this + /** + * 排除字段 + * @param columns 要排除的字段 + */ + exclude(...columns: Array>): this + /** + * 忽略当前实体的字段 + */ + ignore(): this + /** + * 添加列 + * @param column 列名称 + * @param alias 别名 + */ + add(column: BasicFieldName, alias?: string): this + + /** + * 添加自定义列 + * @param column 自定义列 + * @param alias 别名 + */ + add(column: () => string, alias: string): this +} + +interface IReturningFn { + /** + * 设置返回字段 + * @param columns 要返回的字段 + */ + returning(...columns: Array>): this +} + +interface IGroupFn { + /** + * 分组 + * @param name 分组名称 + */ + group(...name: Array<(keyof E) | (() => string)>): this +} + +export interface ISelectBuilder extends IWhereFn, IFilterFn, IJoinFn { + /** + * 设置查询偏移 + * @param offset 查询偏移 + */ + offset(offset: number): this + + /** + * 设置查询数量 + * @param limit 查询数量 + */ + limit(limit: number): this + + /** + * 排序 + * @param name 排序名称 + * @param sort 排序方式 + */ + order(name: string, sort: "asc" | "desc"): this + /** + * 排序 + * @param name 排序名称 + * @param sort 排序方式 + */ + order(name: keyof E, sort: "asc" | "desc"): this + + // __sql__(count: boolean): string + + /** 查询数据 */ + find(): Promise + + /** 查询一条数据 */ + findOne(): Promise + + /** 仅查询数量 */ + count(): Promise + + /** 查询数据和数量 */ + findAndCount(): Promise<[data: E[], count: number]> + + /** 仅执行sql语句 */ + query(): Promise; +} + +export interface IInsertBuilder extends IReturningFn { + /** + * 设置要插入的数据 + * @param data 要插入的数据 + */ + data>(data: Pick | Pick[]): this; + + /** 执行插入 */ + query(): Promise +} + +export interface IUpdateBuilder extends IWhereFn, IReturningFn { + /** + * 设置更新数据 + * @param data 要更新的数据 + */ + set(data: Partial>>): this + + /** 执行更新 */ + query(): Promise +} + +export interface IDeleteBuilder extends IWhereFn, IReturningFn { + /** 执行更新 */ + query(): Promise +} + +interface IJoinBuilder extends IWhereFn, IFilterFn, IJoinFn { + /** + * 设置on条件 + * @param option 条件选项 + * @param sql 自定义sql + * @param args sql参数 + */ + on(option: ConditionOption): this + on(sql: string, args?: any[]): this + on(sql: S, args: { [P in StringArgs]: any }): this +} + + +type Real = Exclude +type JoinedEntity = Real extends Array ? (R extends BasicEntity ? R : never) : (Real extends BasicEntity ? Real : never); + +type Constructor = new (...args: any[]) => SQLBuilder; + +class SQLBuilderContext { + #nameDict: Record = {}; + #groups: string[] = []; + #searchs: string[] = []; + + public genName(name: string) { + this.#nameDict[name] ??= 0; + const idx = ++this.#nameDict[name]; + return `${name}${idx}`; + } + + public addGroup(name: string) { this.#groups.push(name); } + + public get groups() { return this.#groups; } + + public addSearch(search: string) { this.#searchs.push(search); } + + public get searchs() { return this.#searchs; } +} + +class SQLBuilder { + #E: Class + #alias: string + #ctx: SQLBuilderContext + + constructor(ctx: SQLBuilderContext, entity: Class, alias: string | undefined) { + this.#ctx = ctx; + this.#E = entity; + this.#alias = alias ?? this.tableName; + } + + /** 实体类 */ + public get clazz() { return this.#E; } + + /** 上下文 */ + public get ctx() { return this.#ctx; } + + /** 实体配置 */ + public get config() { return getEntityConfig(this.#E); } + + /** 取表别名 */ + public get alias() { return this.#alias; } + + /** 表名称 */ + public get tableName() { return getTableName(this.#E); } + + /** 获取表名SQL */ + public get tableSQL() { + const config = getEntityConfig(this.#E); + if (config.schema) return escapeID(config.schema, config.table); + return escapeID(config.table); + } + + /** 字段遍历 */ + public eachField(callback: (field: IEntityFieldConfig) => R): Array { + const fields = getEntityConfig(this.#E).fields; + return fields.map(f => callback(f)) + } + + /** 获取字段配置 */ + public fieldc(prop: string) { + return getField(this.#E, prop); + } + + /** 获取join配置 */ + public joinc(prop: string) { + return getJoin(this.#E, prop); + } + + /** 获取主键字段 */ + public get primaries() { + return getEntityConfig(this.#E).fields.filter(f => f.primary); + } + + /** 获取主键字段名 */ + public get primaryNames() { + return this.primaries.map(p => p.name); + } + + public buildEntity(data: any) { + const e = new this.#E(); + for (const field of this.config.fields) { + const val = data[`${this.alias}__${field.prop}`]; + e[field.prop as keyof E] = (val === null || val === undefined) ? val : field.decode(val); + } + return e; + } + + #checkPrimary(dataItem: D) { + return this.primaries.every(p => { + const val = (dataItem as any)[`${this.alias}__${p.prop}`]; + return val !== null && val !== undefined; + }) + } + + public __build__(data: D[], joinners: Joinner[]) { + const map: Array<[E, D[]]> = []; + + //数据分组 + for (const item of data) { + //空主键 + if (!this.#checkPrimary(item)) continue; + let mapItem = map.findLast(([e, d]) => { + return this.primaries.every(p => e[p.prop as keyof E] === item[`${this.alias}__${p.prop}` as keyof D] as any) + }); + if (mapItem) mapItem[1].push(item); + else map.push([this.buildEntity(item) as E, [item]]); + } + + //子数据 + return map.map(([val, data]) => { + for (const joinner of joinners) joinner.build(val, data); + return val; + }); + } +} + + +function WithFilter>(Base: B) { + return class extends Base implements IFilterFn { + #columns?: Array<[string, string]>; + #customColumns: Array<[string, string]> = []; + + public filter(...columns: Array | { [P in BasicFieldName]+?: string }>) { + this.#columns = []; + if (!columns.length) columns = this.config.fields.map(f => f.prop as BasicFieldName); + for (const col of columns) { + if (typeof col == "string") this.#columns.push([escapeID(this.alias, this.fieldc(col).name), escapeID(`${this.alias}__${col}`)]); + else for (const [k, asName] of Object.entries(col)) { + this.#columns.push([escapeID(this.alias, this.fieldc(k as string).name), asName as string]); + } + } + return this; + } + + public exclude(...columns: Array>) { + this.#columns = []; + for (const col of this.config.fields) { + if (columns.includes(col as any)) continue; + this.#columns.push([escapeID(this.alias, col.name), escapeID(`${this.alias}__${col.prop}`)]); + } + return this; + } + + public ignore() { + this.#columns = []; + return this; + } + + public add(column: any, alias?: string) { + if (typeof column == "string") this.#customColumns.push([escapeID(column), escapeID(alias ?? column)]); + else if (typeof column == "function") this.#customColumns.push([column(), escapeID(alias!)]); + return this; + } + + /** 字段列表 */ + public get columns() { + //如果没有则构建所有字段 + if (!this.#columns) this.filter(...this.eachField(f => f.prop as BasicFieldName)); + //返回字段 + return [...this.#columns!, ...this.#customColumns]; + } + } +} + + +/** + * 构建条件语句,可用于where、having、on等语句 + * @param builder SQLBuilder + * @param option 条件选项或条件语句 + * @param args 条件语句参数 + */ +function buildCondition(builder: SQLBuilder, option: ConditionOption | string, args?: any) { + if (typeof option == "string") { + if (typeof args === "undefined") return option; + else return formatSQL(option, args); + } + else { + const wheres: string[] = []; + for (const [col, topVal] of Object.entries(option)) { + const field = builder.fieldc(col); + const fieldName = escapeID(builder.alias, field.name); + if (topVal === null) wheres.push(`${fieldName} is null`); + else if (typeof topVal == "object") { + for (let [op, opv] of Object.entries(topVal as BasicWhere) as [string, any][]) { + switch (op) { + case "in": + wheres.push(`${fieldName} in (${(opv as any[]).map(v => field.encode(v)).join(",")})`); + break; + case "nin": + wheres.push(`${fieldName} not in (${(opv as any[]).map(v => field.encode(v)).join(",")})`); + break + case "ne": + if (opv === null) wheres.push(`${fieldName} is not null`); + else wheres.push(`${fieldName}!=${field.encode(opv)}`); + break; + case "gt": + wheres.push(`${fieldName}>${field.encode(opv)}`); + break; + case "gte": + wheres.push(`${fieldName}>=${field.encode(opv)}`); + break; + case "lt": + wheres.push(`${fieldName}<${field.encode(opv)}`); + break; + case "lte": + wheres.push(`${fieldName}<=${field.encode(topVal)}`); + break; + case "like": + wheres.push(`${fieldName} like ${escapeValue(opv)}`); + break; + case "nlike": + wheres.push(`${fieldName} note like ${escapeValue(opv)}`); + break; + default: + break; + } + } + } + else if (typeof topVal !== "undefined") wheres.push(`${fieldName}=${field.encode(topVal)}`); + } + return wheres.join(" and "); + } +} + +/** Where 混入 */ +function WithWhere>(Base: B) { + return class extends Base implements IWhereFn { + #wheres: string[] = []; + + public where(option: ConditionOption | string, args?: any) { + const where = buildCondition(this, option, args); + if (where) this.#wheres.push(where); + return this; + } + + public search(type: T, field: BasicFieldName, option: ISearchOptionMap[T]) { + const fieldName = escapeID(this.alias, field as string); + switch (type) { + case "tsquery": { + const opt = option as ISearchOptionMap["tsquery"]; + let keywords: string[]; + let method: "or" | "and" = "or"; + let absolute = true; + //处理选项 + if (typeof opt === "string") keywords = opt.split(/\s+/).map(s => s.trim()).filter(s => s.length); + else if (opt instanceof Array) keywords = opt; + else { + if (typeof opt.keywords === "string") keywords = opt.keywords.split(/\s+/).map(s => s.trim()).filter(s => s.length); + else keywords = opt.keywords; + if (opt.method) method = opt.method; + if (typeof opt.absolute == "boolean") absolute = opt.absolute; + } + //生成tsquery + if (keywords.length) { + const sql = keywords.map(k => absolute ? k : `${k}:*`).join(method == "or" ? "|" : "&"); + this.ctx.addSearch(`${fieldName} @@ to_tsquery(${escapeValue(sql)})`); + } + break; + } + case "like": { + const opt = option as ISearchOptionMap["like"]; + const keywords = ((typeof opt === "string") ? opt : opt.keywords).trim(); + if (keywords.length) { + const method = (typeof opt === "object") ? (opt.method ?? "both") : "both"; + switch (method) { + case "left": + this.ctx.addSearch(`${fieldName} like ${escapeValue(`${keywords}%`)}`); + break; + case "right": + this.ctx.addSearch(`${fieldName} like ${escapeValue(`%${keywords}`)}`); + break; + case "both": + this.ctx.addSearch(`${fieldName} like ${escapeValue(`%${keywords}%`)}`); + break; + default: + break; + } + } + break; + } + } + return this; + } + + public get wheres() { return this.#wheres; } + }; +} + +/** Join 混入 */ +function WithJoin>(Base: B) { + return class extends Base implements IJoinFn { + #joinners: Joinner[] = []; + + public join(name: string, alias?: any, callback?: any) { + const joinConfig = this.joinc(name as string); + const JoinEntity = joinConfig.entity(); + if (typeof callback == "undefined" && typeof alias == "function") { + callback = alias; + alias = undefined; + } + const joinner = new Joinner(this, name, this.alias, JoinEntity, joinConfig, alias ?? name); + this.#joinners.push(joinner); + if (callback) callback(joinner); + return this; + } + + public get joinners() { return this.#joinners; } + } +} + +/** Group by 混入 */ +function WithGroup>(Base: B) { + return class extends Base implements IGroupFn { + + public group(...name: Array string)>) { + for (const item of name) { + if (typeof item == "function") this.ctx.addGroup(item()); + else if (typeof item == "string") this.ctx.addGroup(escapeID(this.alias, item)); + } + return this; + } + } +} + +/** Returning 混入 */ +function WithReturning>(Base: B) { + return class extends Base implements IReturningFn { + #returning?: string[] + + public returning(...columns: Array>) { + if (!columns.length) columns = this.config.fields.map(c => c.name as BasicFieldName); + this.#returning = columns.map(c => `${escapeID(this.alias, c as string)} as ${escapeID(`${this.alias}__${c as string}`)}`); + return this; + } + + public get returnings() { return this.#returning; } + + public get returningsWithDefault() { + return this.returnings ?? this.primaries.map(c => `${escapeID(this.alias, c.name)} as ${escapeID(`${this.alias}__${c.prop}`)}`); + } + + public buildReturningData(data: D[]) { return data.map(d => this.buildEntity(d)); } + } +} + +class Joinner extends WithGroup(WithJoin(WithWhere(WithFilter(SQLBuilder)))) implements IJoinBuilder { + #joinName: string + #joinConfig: IEntityJoinConfig + #baseName: string + #origin: SQLBuilder + #ons: string[] = []; + + public constructor(origin: SQLBuilder, baseName: string, joinName: string, entity: Class, config: IEntityJoinConfig, alias: string) { + super(origin.ctx, entity, alias); + this.#origin = origin; + this.#baseName = baseName; + this.#joinName = joinName; + this.#joinConfig = config; + } + + public on(option: ConditionOption | string, args?: any) { + const onSQL = buildCondition(this, option, args); + if (onSQL) this.#ons.push(onSQL); + return this; + } + + public get __select_columns__(): Array<[name: string, alias: string]> { + const result = [...this.columns]; + this.joinners.forEach(j => result.push(...j.__select_columns__)); + return result; + } + + public get __wheres__(): string[] { + const result = [...this.wheres]; + this.joinners.forEach(j => result.push(...j.__wheres__)); + return result; + } + + public get __join_sql__() { + const onSQL = [ + `${this.#joinConfig.builder(this.#joinName, this.alias)}`, + ...this.#ons, + ].join(" and "); + return `left join ${this.tableSQL} ${escapeID(this.alias)} on ${onSQL}`; + } + + public build(base: BE, data: D[]) { + const parsed = this.__build__(data, this.joinners); + if (this.#joinConfig.many) base[this.#baseName as keyof BE] = parsed as any; + else base[this.#baseName as keyof BE] = (parsed[0] ?? null) as any; + } +} + +export class SelectBuilder extends WithGroup(WithJoin(WithWhere(WithFilter(SQLBuilder)))) implements ISelectBuilder { + #offset?: number + #limit?: number + #orders?: Array<[name: string, order: "asc" | "desc"]> + #query: IQueryFunc + #fullJoinners?: Joinner[] + + constructor(query: IQueryFunc, entity: Class, alias?: string) { + super(new SQLBuilderContext(), entity, alias); + this.#query = query; + } + + public offset(offset: number) { + this.#offset = offset; + return this; + } + + public limit(limit: number) { + this.#limit = limit; + return this; + } + + public order(name: any, sort: "asc" | "desc") { + this.#orders ??= []; + const col = this.config.fields.find(f => f.prop === name); + if (col) this.#orders.push([escapeID(this.alias, col.name), sort]); + else this.#orders.push([name, sort]); + return this; + } + + public async find() { + const sql = this.__sql__(false); + // console.log(sql); + const result = await this.#query(sql); + const ret = this.build(result.rows); + return ret as E[]; + } + + async findOne() { return this.find().then(res => res[0] ?? null); } + + async count() { + const sql = `select count(*) as __data_count__ from (\n\t${this.__inner_sql__(false, "\n\t")}\n)`; + const res = await this.#query(sql); + return parseInt(res.rows[0].__data_count__) as number + } + + async findAndCount() { + const res = await this.#query(this.__sql__(true)); + const count = res.rows[0]?.__data_count__ ?? 0; + return [this.build(res.rows) as E[], parseInt(count)] as [E[], number]; + } + + async query(): Promise { + const sql = this.__sql__(false); + const res = await this.#query(sql); + return res.rows; + } + + public get __select_columns__() { + const result = [...this.columns]; + this.joinners.forEach(j => result.push(...j.__select_columns__)); + return result.map(([c, a]) => `${c} as ${a}`) + } + + #whereCache: string | undefined = undefined; + public get __wheres__() { + if (!this.#whereCache) { + const result = [...this.wheres]; + this.joinners.forEach(j => result.push(...j.__wheres__)); + if (this.ctx.searchs.length) result.push(this.ctx.searchs.join(" or ")) + this.#whereCache = result.join(" and "); + } + return this.#whereCache; + } + + private getAllJoinners(joinners: Joinner[]) { + const result = [...joinners]; + joinners.forEach(j => result.push(...this.getAllJoinners(j.joinners))); + return result; + } + + public get __joins__() { + this.#fullJoinners ??= this.getAllJoinners(this.joinners); + return this.#fullJoinners.map(j => j.__join_sql__) + } + + public __inner_sql__(count: boolean, join = "\n") { + //内部的join + const innerSQLItems = [ + `select ${escapeID(this.alias)}.*${count ? `, count(*) over () as __data_count__` : ''}`, //字段列表 + `from ${this.tableSQL} ${escapeID(this.alias)}`, //表名 + ...this.__joins__, //join + ...this.__wheres__.length ? [`where ${this.__wheres__}`] : [], //where条件 + `group by ${this.primaryNames.map(p => escapeID(this.alias, p)).join(",")}`, //内部查询分组 + ...(!count && this.#orders?.length) ? [`order by ${this.#orders.map(([name, order]) => `${name} ${order}`).join(",")}`] : [], //排序 + ...(typeof this.#offset == "number") ? [`offset ${this.#offset}`] : [], //offset + ...(typeof this.#limit == "number") ? [`limit ${this.#limit}`] : [], //limit + ]; + return innerSQLItems.join(join); + } + + public __sql__(count: boolean) { + //外部的SQL + const outterSQLItems = [ + `select ${this.__select_columns__}${count ? `, ${escapeID(this.alias)}.__data_count__` : ''}`, //字段列表 + `from (\n\t${this.__inner_sql__(count, "\n\t")}\n) ${escapeID(this.alias)}`, //子查询 + ...this.__joins__, //join + ...this.__wheres__.length ? [`where ${this.__wheres__}`] : [], //where条件 + ...this.ctx.groups.length ? [`group by ${this.ctx.groups.join(",")}`] : [], //分组 + ...this.#orders ? [`order by ${this.#orders.map(([name, ord]) => `${name} ${ord}`).join(", ")}`] : [], //排序 + ]; + + return outterSQLItems.join("\n"); + } + + public build(data: D[]) { + return this.__build__(data, this.joinners); + } + +} + +export class InsertBuilder extends WithReturning(SQLBuilder) implements IInsertBuilder { + #query: IQueryFunc + #values: string[] = []; + #columns: string[] = []; + + public constructor(query: IQueryFunc, entity: Class) { + super(new SQLBuilderContext(), entity, undefined); + this.#query = query; + } + + public data>(data: Pick | Pick[]) { + data = (data instanceof Array) ? data : [data]; + this.#values = []; + this.#columns = []; + let columnsSet = false; + + for (const item of data) { + const buffer: (string | null)[] = []; + for (const col of this.config.fields) { + if (!(col.prop in item)) continue; + const val = item[col.prop as keyof typeof item]; + if (val === null || val === undefined) buffer.push("null"); + else buffer.push(col.encode(val)); + if (!columnsSet) this.#columns.push(escapeID(col.name)); + } + this.#values.push(`(${buffer.join(",")})`); + columnsSet = true; + } + + return this; + } + + public async query() { + if (!this.#values.length) return []; + //构建SQL + const sqlItems = [ + `insert into ${this.tableSQL} as ${escapeID(this.alias)} (${this.#columns.join(",")}) values`, + this.#values.join(",\n"), + `returning ${this.returningsWithDefault}`, + ] + const sql = sqlItems.join("\n"); + + //执行并返回数据 + const res = await this.#query(sql); + return this.buildReturningData(res.rows) as E[]; + } +} + +export class UpdateBuilder extends WithReturning(WithWhere(SQLBuilder)) implements IUpdateBuilder { + #query: IQueryFunc + #updates?: string[] + + public constructor(query: IQueryFunc, entity: Class) { + super(new SQLBuilderContext(), entity, undefined); + this.#query = query; + } + + public set(data: Partial>>) { + this.#updates = [] + for (const col of this.config.fields) { + const val = data[col.prop as keyof typeof data]; + if (val === undefined) continue; + if (val === null) this.#updates.push(`${escapeID(col.name)}=null`); + else this.#updates.push(`${escapeID(col.name)}=${col.encode(val)}`); + } + return this; + } + + public async query() { + if (!this.#updates?.length) return []; + //构建SQL + const wheres = this.wheres; + const sqlItems = [ + `update ${this.tableSQL} ${escapeID(this.alias)}`, + `set ${this.#updates.join(",")}`, + ...wheres.length ? [`where ${wheres.join(" and ")}`] : [], + `returning ${this.returningsWithDefault}`, + ] + const sql = sqlItems.join("\n"); + + //执行并构建返回数据 + const res = await this.#query(sql); + return this.buildReturningData(res.rows) as E[]; + } + +} + +export class DeleteBuilder extends WithReturning(WithWhere(SQLBuilder)) implements IDeleteBuilder { + #query: IQueryFunc + + public constructor(query: IQueryFunc, entity: Class) { + super(new SQLBuilderContext(), entity, undefined); + this.#query = query; + } + + public async query() { + //构建SQL + const wheres = this.wheres; + const sqlItems = [ + `delete from ${this.tableSQL} ${escapeID(this.alias)}`, + ...wheres.length ? [`where ${wheres.join(" and ")}`] : [], + `returning ${this.returningsWithDefault}`, + ] + const sql = sqlItems.join("\n"); + + //执行并构建返回数据 + const res = await this.#query(sql); + return this.buildReturningData(res.rows) as E[]; + } +} diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 0000000..e3e022a --- /dev/null +++ b/src/test.ts @@ -0,0 +1,104 @@ +import { database } from "./database"; +import { BasicEntity, DateTimeColumn, IntColumn, JoinOne, StringColumn, Table } from "./entity"; + +database.config(() => ({ + host: "127.0.0.1", + port: 5432, + user: "postgres", + password: "ljk5408828", + database: "urnas", + max: 100, +})); + + +/** 数据实体 */ +class DataEntity extends BasicEntity { + /** ID */ + @IntColumn({ primary: true }) declare id: number +} + +/** 带有时间的实体 */ +class TimedEntity extends DataEntity { + /** 创建时间 */ + @DateTimeColumn() declare ctime: string + + /** 修改时间 */ + @DateTimeColumn() declare utime: string | null + + /** 删除时间 */ + @DateTimeColumn() declare dtime: string | null +} + + +export enum UserRole { + /** 系统管理员 */ + SystemAdmin = 1 << 0, + /** 密码本管理员*/ + CodeBookAdmin = 1 << 1, + /** 相册管理员 */ + AlbumAdmin = 1 << 2, + /** FM管理员 */ + FMAdmin = 1 << 3, + + /** 超级用户 */ + Super = SystemAdmin | CodeBookAdmin | AlbumAdmin | FMAdmin, + /** 常规用户 */ + User = 0, +}; + +/** 用户 */ +@Table("user.user") +export class User extends TimedEntity { + /** 账号 */ + @StringColumn() declare number: string + + /** 密码 */ + @StringColumn() declare password: string + + /** 昵称 */ + @StringColumn() declare name: string + + /** 角色 */ + @IntColumn() declare role: UserRole + + /** 邮箱 */ + @StringColumn() declare email: string | null + + /** 手机 */ + @StringColumn() declare phone: string | null + + /** 头像 */ + @StringColumn() declare avatar: string | null + + /** 登录过期时间 */ + @DateTimeColumn() declare ttime: string +} + +@Table("user.user_login") +export class UserLogin extends DataEntity { + @IntColumn() declare userID: number + + @JoinOne(() => User, "userID") declare user: User + + @JoinOne(() => User, "userID") declare test: User + + @StringColumn() declare device: string | null + + @StringColumn() declare ip: string + + @DateTimeColumn() declare ctime: string +} + +database.select(UserLogin) + .join("user") + .join("test") + .find() + .then(res => { + console.log(res); + }).catch(err => { + console.error(err); + }) + .finally(() => { + console.log("END") + database.close(); + }); \ No newline at end of file diff --git a/src/type/index.ts b/src/type/index.ts new file mode 100644 index 0000000..21ab761 --- /dev/null +++ b/src/type/index.ts @@ -0,0 +1 @@ +export * from "./tsvector"; \ No newline at end of file diff --git a/src/type/tsvector.ts b/src/type/tsvector.ts new file mode 100644 index 0000000..04dc112 --- /dev/null +++ b/src/type/tsvector.ts @@ -0,0 +1,127 @@ +import pinyin from "pinyin"; + +export class TSVector { + #items: string[]; + + + private constructor(items: string[]) { + this.#items = items; + } + + /** + * 数据库值 + */ + public get value() { + return this.#items.join(" "); + } + + /** + * 检测是否包含指定元素 + * @param item 元素 + */ + public contains(item: string) { return this.#items.includes(item); } + + /** 获取元素列表 */ + public get items() { return this.#items; } + + /** + * 添加元素 + * @param item 要添加的元素 + */ + public push(item: string) { + item = item.replaceAll("'", ""); + if (this.contains(item)) return; + this.#items.push(item); + } + + /** + * 删除元素 + * @param item 要删除的元素 + */ + public remove(item: string) { + item = item.replaceAll("'", ""); + const index = this.#items.indexOf(item); + if (index >= 0) this.#items.splice(index, 1); + } + + /** + * 从数据库值构建 + * @param value 数据库值 + */ + public static fromValue(value: string) { + const items = value.split(" ").map(s => s.trim()).filter(s => !!s).map(s => s.replaceAll("'", "")); + return new this(items); + } + + /** + * 从关键词构建 + * @param keywords 关键词列表 + */ + public static fromKeyworlds(keywords: string[]) { + const items: string[] = [] + + function resolvePinyin(pinyins: string[][]): string[][] { + if (!pinyins.length) return []; + let [first, ...rest] = pinyins; + //后续结果 + const ties = resolvePinyin(rest); + //处理第一个 + first = first.map(s => s.trim().replace(/[^a-zA-Z0-9 ]/g, "")).filter(s => !!s); + if (!first.length) return ties; + + //组合结果 + const result: string[][] = []; + for (let py of first) { + let leads = py.split(" "); //这里处理一下英文和数字 + if (ties.length) { + for (const tie of ties) result.push([...leads, ...tie]); + } + else result.push(leads); + } + + //返回 + return result; + } + + //处理关键词 + keywords = keywords.map(s => s.trim()).filter(s => !!s); + + //屏蔽纯字母数字的关键字 + keywords = keywords.filter(kw => { + if (/^[a-zA-Z0-9]+$/.test(kw)) { + items.push(kw); + return false; + } + return true; + }) + + //处理给定的每个关键词 + for (let kw of keywords) { + //获取拼音 + const pinyinList = pinyin(kw, { style: pinyin.STYLE_NORMAL, segment: true, heteronym: true }); + + //处理拼音 + for (const pys of resolvePinyin(pinyinList)) { + //基础拼音 + const basePinyin = pys.join(""); + if (!items.includes(basePinyin)) items.push(basePinyin); + //首字母 + const simplePinyin = pys.map(p => { + if (/^\d+$/.test(p)) return p; + return p[0]; + }).join(""); + if (!items.includes(simplePinyin)) items.push(simplePinyin); + } + + //关键字本身 + kw = kw.replaceAll(" ", ''); + if (!items.includes(kw)) items.push(kw); + } + + //完成 + return new this(items.map(s => s.replaceAll("'", ""))); + } +} + + +// console.log(TSVector.fromKeyworlds(["yizhi kangkang"])) \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..895d33f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,62 @@ +import type { BasicEntity } from "./entity"; + +export type Class = new (...args: any[]) => T; + +export type ArrayType = T extends Array ? U : never; +export type ArrayTypeSelf = T extends Array ? U : T; + +export type Values = T[keyof T]; + +export namespace Decorators { + export type ClassDecorator = (target: Class) => any; + export type PropDecorator = (target: T, propertyKey: string) => any; + export type MethodDecorator = (target: T, propertyKey: string, descriptor: PropertyDescriptor) => any; + export type ParamDecorator = (target: T, propertyKey: string, parameterIndex: number) => any; +} + + +/** 取基础类型的字段名 */ +export type BasicFieldName = Values<{ [P in keyof T]: + //忽略never + Exclude extends never ? never : ( + //忽略函数 + Exclude extends Function ? never : ( + //忽略实体 + ArrayTypeSelf> extends BasicEntity ? never : ( + P + ) + ) + ) +}>; + + +/** 可以用作ID的字段名称 */ +export type IDLikeFieldName = Values<{ [P in keyof T]: + Exclude extends never ? never : ( + string extends Exclude ? P : ( + number extends Exclude ? P : never + ) + ) +}>; + + +/** 获取可用于ManyJoin的字段名 */ +export type ManyJoinFieldName = Values<{ [P in keyof T]: + //忽略never + ArrayType> extends never ? never : ( + //只取Array实体 + ArrayType> extends BasicEntity ? P : never + ) +}>; + +/** 获取可以用户OneJoin的字段名 */ +export type OneJoinFieldName = Values<{ [P in keyof T]: + //忽略never + Exclude extends never ? never : ( + //只取Array实体 + Exclude extends BasicEntity ? P : never + ) +}>; + +/** 可以Join的字段名 */ +export type JoinaFieldName = OneJoinFieldName | ManyJoinFieldName; \ No newline at end of file diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..61aa8a8 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,35 @@ +import { DatabaseError } from "./error"; + +export function escapeID(...keys: string[]) { + return keys.map(key => `"${key}"`).join("."); +} + +export function escapeValue(value: any) { + if (typeof value === "number") return value.toString(); + else if (typeof value === "string") return `E'${(value).replaceAll("'", "\\x27")}'`; + else if (typeof value === "boolean") return value.toString(); + else if (typeof value === "bigint") return value.toString(); + else if (value === null) return "null"; + else throw new DatabaseError("DB_QUERY_ESCAPE_VALUE_ERROR", "无法转义的值"); +} + + +export function formatSQL(sql: string, args: any[] | Record) { + if (args instanceof Array) { + const _args = [...args]; + return sql.replace(/\?/g, () => { + const v = _args.shift(); + if (v === undefined) return "null"; + else if (v instanceof Array) return v.map(escapeValue).join(","); + else return escapeValue(v); + }); + } + else { + return sql.replace(/\{:(\w+)\}/g, (match, key) => { + const v = args[key]; + if (v === undefined) return "null"; + else if (v instanceof Array) return v.map(escapeValue).join(","); + else return escapeValue(v); + }); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c84bf8f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,100 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + /* Language and Environment */ + "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + "rootDir": "./src", /* Specify the root folder within your source files. */ + // "moduleResolution": "Node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + /* Emit */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + "declarationDir": "./typing", /* Specify the output directory for generated declaration files. */ + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} \ No newline at end of file