From c5e5a1e25175c108b42a466d9b23c33940c78861 Mon Sep 17 00:00:00 2001 From: yizhi <946185759@qq.com> Date: Fri, 13 Dec 2024 12:32:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=8C=83=E5=9B=B4=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/entity.ts | 114 ++++++++++++++++++++++---- src/index.ts | 1 + src/query.ts | 64 +++++++++++++-- src/type/index.ts | 3 +- src/type/range.ts | 199 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 358 insertions(+), 25 deletions(-) create mode 100644 src/type/range.ts diff --git a/package.json b/package.json index d1f033b..0137201 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yizhi/postgres", - "version": "1.0.13", + "version": "1.0.14", "main": "dist/index.js", "types": "typing/index.d.ts", "scripts": {}, diff --git a/src/entity.ts b/src/entity.ts index 0e725f4..faa7924 100644 --- a/src/entity.ts +++ b/src/entity.ts @@ -1,10 +1,12 @@ import moment from "moment"; import { DatabaseError } from "./error"; -import { TSVector } from "./type"; +import { DateRange, DateTimeRange, FloatRange, Int4Range, TSVector } from "./type"; import { escapeID, escapeValue } from "./util"; import type { Class, Decorators, IDLikeFieldName } from "./types"; export interface IEntityFieldConfig { + /** 类型名称 */ + typename: string /** 字段名 */ name: string /** 解析后的属性名 */ @@ -17,6 +19,8 @@ export interface IEntityFieldConfig { encode: (data: any) => string /** 解码方式(查询数据库时) */ decode: (data: any) => any + /** 字段选项 */ + option?: Record } @@ -136,16 +140,18 @@ export function Table(table: string): Decorators.ClassDecorator { /** * 定义一个字段 + * @param typename 字段类型名称 * @param name 字段名称 * @param encode 字段编码凡是 * @param decode 字段解码方式 * @param primary 是否是主键 * @param virtual 是否是虚拟字段 + * @param option 字段选项 */ -export function Field(name: string | undefined, encode: IEntityFieldConfig["encode"], decode: IEntityFieldConfig["decode"], primary: boolean, virtual: boolean): Decorators.PropDecorator { +export function Field(typename: string, name: string | undefined, encode: IEntityFieldConfig["encode"], decode: IEntityFieldConfig["decode"], primary: boolean, virtual: boolean, option?: Record): Decorators.PropDecorator { return function (target, propertyKey) { const conf = entityConfigOf(target.constructor) - conf.fields.push({ name: name ?? propertyKey, prop: propertyKey, encode, decode, primary, virtual }); + conf.fields.push({ typename, name: name ?? propertyKey, prop: propertyKey, encode, decode, primary, virtual, option: option }); } } @@ -162,7 +168,7 @@ export function IntColumn(name?: any, option?: any) { option = name; name = undefined; } - return Field(name, v => escapeValue(v), v => v, option?.primary ?? false, option?.virtual ?? false); + return Field("int", name, v => escapeValue(v), v => v, option?.primary ?? false, option?.virtual ?? false); } @@ -179,7 +185,7 @@ export function FloatColumn(name?: any, option?: any) { name = undefined; } - return Field(name, v => escapeValue(v), v => parseFloat(v), false, option?.virtual ?? false); + return Field("float", name, v => escapeValue(v), v => parseFloat(v), false, option?.virtual ?? false); } @@ -195,7 +201,7 @@ export function StringColumn(name?: any, option?: any) { option = name name = undefined } - return Field(name, v => escapeValue(v), v => v, option?.primary ?? false, option?.virtual ?? false); + return Field("string", name, v => escapeValue(v), v => v, option?.primary ?? false, option?.virtual ?? false); } /** @@ -218,7 +224,7 @@ export function BooleanColumn(name?: any, option?: any) { return ["true", "yes", "on"].includes(v) } - return Field(name, (v) => escapeValue(checker(v)), checker, false, option?.virtual ?? false); + return Field("boolean", name, (v) => escapeValue(checker(v)), checker, false, option?.virtual ?? false); } /** @@ -234,7 +240,7 @@ export function DateColumn(name?: any, option?: any) { name = undefined } - return Field(name, v => { + return Field("date", name, v => { if (v instanceof DateColumn) v = moment(v); if (typeof v == "string") v = moment(v); if (!moment.isMoment(v)) return escapeValue(null); @@ -257,7 +263,7 @@ export function DateTimeColumn(name?: any, option?: any) { name = undefined } - return Field(name, v => { + return Field("datetime", name, v => { if (v instanceof DateColumn) v = moment(v); if (typeof v == "string") v = moment(v); if (!moment.isMoment(v)) return escapeValue(null); @@ -270,15 +276,15 @@ export function DateTimeColumn(name?: any, option?: any) { //数组定义 -function _Array(name: string | undefined, typeName: string, itemEncoder: (v: any) => (string | null), itemDecoder: ((v: any) => (T | null)) | undefined, virtual: boolean) { - return Field(name, v => { +function _Array(baseTypename: string, name: string | undefined, typeName: string, itemEncoder: (v: any) => (string | null), itemDecoder: ((v: any) => (T | null)) | undefined, virtual: boolean, option?: Record) { + return Field(`${baseTypename}[]`, 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, virtual); + }, false, virtual, option); } /** @@ -294,7 +300,9 @@ export function IntArrayColumn(name?: any, option?: any) { name = undefined } - return _Array(name, `int${option?.bytes ?? 4}[]`, item => { + const bytes = option?.bytes ?? 4; + + return _Array("int", name, `int${bytes}[]`, item => { item = parseInt(item); if (isNaN(item) || !isFinite(item)) return null; return item.toString(); @@ -302,7 +310,7 @@ export function IntArrayColumn(name?: any, option?: any) { item = parseInt(item); if (isNaN(item) || !isFinite(item)) return null; return item; - }, option?.virtual ?? false); + }, option?.virtual ?? false, { bytes }); } /** @@ -318,7 +326,7 @@ export function StringArrayColumn(name?: any, option?: any) { name = undefined } - return _Array(name, "varchar[]", item => { + return _Array("string", name, "varchar[]", item => { if (typeof item == "string") return item; else return null; }, item => item, option?.virtual ?? false); @@ -336,7 +344,7 @@ export function JsonColumn(name?: any, option?: any) { name = undefined } - return Field(name, v => escapeValue(JSON.stringify(v)) + "::jsonb", v => v, false, option?.virtual ?? false); + return Field("json", name, v => escapeValue(JSON.stringify(v)) + "::jsonb", v => v, false, option?.virtual ?? false); } /** @@ -344,12 +352,84 @@ export function JsonColumn(name?: any, option?: any) { * @param name 字段名称 */ export function TSVectorColumn(name?: string) { - return Field(name, v => { + return Field("tsvector", 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, false); } +/** + * 定义int4range字段 + * @param name 字段名称 + * @param option 字段选项 + */ +export function Int4RangeColumn(option?: { virtual?: boolean }): Decorators.PropDecorator +export function Int4RangeColumn(name: string, option?: { virtual?: boolean }): Decorators.PropDecorator +export function Int4RangeColumn(name?: any, option?: any) { + if (typeof name != "string") { + option = name + name = undefined + } + return Field("range", name, v => { + if (!(v instanceof Int4Range)) throw new DatabaseError("DB_ENTITY_FIELD_NOT_INT4RANGE", `字段${name}需要给定一个Int4Range`); + return v.toString(); + }, v => Int4Range.create(v), false, option?.virtual ?? false, { type: "int4" }); +} + +/** + * 定义numrange字段 + * @param name 字段名称 + * @param option 字段选项 + */ +export function FloatRangeColumn(option?: { virtual?: boolean }): Decorators.PropDecorator +export function FloatRangeColumn(name: string, option?: { virtual?: boolean }): Decorators.PropDecorator +export function FloatRangeColumn(name?: any, option?: any) { + if (typeof name != "string") { + option = name + name = undefined + } + return Field("range", name, v => { + if (!(v instanceof FloatRange)) throw new DatabaseError("DB_ENTITY_FIELD_NOT_NUMRANGE", `字段${name}需要给定一个NumRange`); + return v.toString(); + }, v => FloatRange.create(v), false, option?.virtual ?? false, { type: "float" }); +} + +/** + * 定义daterange字段 + * @param name 字段名称 + * @param option 字段选项 + */ +export function DateRangeColumn(option?: { virtual?: boolean }): Decorators.PropDecorator +export function DateRangeColumn(name: string, option?: { virtual?: boolean }): Decorators.PropDecorator +export function DateRangeColumn(name?: any, option?: any) { + if (typeof name != "string") { + option = name + name = undefined + } + return Field("range", name, v => { + if (!(v instanceof DateRange)) throw new DatabaseError("DB_ENTITY_FIELD_NOT_DATERANGE", `字段${name}需要给定一个DateRange`); + return v.toString(); + }, v => DateRange.create(v), false, option?.virtual ?? false, { type: "date" }); +} + +/** + * 定义tsrange字段 + * @param name 字段名称 + * @param option 字段选项 + */ +export function DateTimeRangeColumn(option?: { virtual?: boolean }): Decorators.PropDecorator +export function DateTimeRangeColumn(name: string, option?: { virtual?: boolean }): Decorators.PropDecorator +export function DateTimeRangeColumn(name?: any, option?: any) { + if (typeof name != "string") { + option = name + name = undefined + } + return Field("range", name, v => { + if (!(v instanceof DateTimeRange)) throw new DatabaseError("DB_ENTITY_FIELD_NOT_DATETIMERANGE", `字段${name}需要给定一个DateTimeRange`); + return v.toString(); + }, v => DateTimeRange.create(v), false, option?.virtual ?? false, { type: "datetime" }); +} + //获取实体配置 export function getEntityConfig(clazz: any) { const conf = getEntityConfigWithoutCheck(clazz); diff --git a/src/index.ts b/src/index.ts index 5736247..9eba66e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export { Field, IntColumn, FloatColumn, StringColumn, BooleanColumn, DateColumn, DateTimeColumn, JsonColumn, IntArrayColumn, StringArrayColumn, TSVectorColumn, + Int4RangeColumn, FloatRangeColumn, DateRangeColumn, DateTimeRangeColumn, JoinOne, JoinMany, BasicEntity, IEntityConfig, diff --git a/src/query.ts b/src/query.ts index b06dad5..c5abc45 100644 --- a/src/query.ts +++ b/src/query.ts @@ -2,6 +2,7 @@ import { QueryResult, QueryResultRow } from "pg"; import { BasicEntity, IEntityFieldConfig, IEntityJoinConfig, getEntityConfig, getField, getJoin, getTableName } from "./entity"; import type { JoinaFieldName, Class, ArrayTypeSelf, Values, StringArgs, SQLStringArgs } from "./types"; import { escapeID, escapeValue, formatSQL } from "./util"; +import { RangeBase } from "./type/range"; //编译器原因,不加此段代码会出错,具体原因未知,请保持定义和types.ts重的同名定义一致 @@ -26,13 +27,36 @@ interface IQueryFunc { type DataOrCustom = T | (() => string); +/** 基础的where条件 */ +type BasicWhere = F extends string | number | boolean ? + | { + in?: F[] + nin?: F[] + ne?: F | null + gt?: F + gte?: F + lt?: F + lte?: F + } & (F extends string ? { + like?: string + nlike?: string + } : {}) : {}; -type BasicWhere = never - | { in: V[] } | { nin: V[] } - | { ne: V | null } | { gt: V } | { gte: V } | { lt: V } | { lte: V } - | { like: string } | { nlike: string } +/** 范围操作where条件 */ +type RangeWhere = F extends RangeBase ? { + /** 范围是否包含元素或另一个范围 `@>` */ + rangeContains?: R | F | null | undefined, + /** 当前范围是否被包含于另一个范围 `<@` */ + rangeContained?: F | null | undefined, + /** 范围是否和另一个范围重叠 `&&` */ + rangeOverlaps?: F | null | undefined, +} : {}; -type ConditionOption = { [P in BasicFieldName]?: Exclude | BasicWhere> | null } +type ConditionOption = { + [P in BasicFieldName]?: + Exclude | null | + (BasicWhere> & RangeWhere>) +} /** 搜索选项映射关系 */ interface ISearchOptionMap { @@ -370,7 +394,7 @@ function WithFilter>(Base: B) { 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]); + this.#columns.push([escapeID(this.alias, this.fieldc(k as string).name), escapeID(asName)]); } } return this; @@ -455,6 +479,34 @@ function buildCondition(builder: SQLBuilder, option: C case "nlike": wheres.push(`${fieldName} note like ${escapeValue(opv)}`); break; + case "rangeContains": { + if (opv === null || opv === undefined) break; + if (opv instanceof RangeBase) wheres.push(`${fieldName} @> ${opv.toString()}`); + else if (field.typename === "range") { + switch (field.option?.type) { + case "int4": + case "float": + wheres.push(`${fieldName} @> ${opv.toString()}`); + break; + case "date": + wheres.push(`${fieldName} @> ${escapeValue(opv)}::date`); + break; + case "datetime": + wheres.push(`${fieldName} @> ${escapeValue(opv)}::timestamp`); + default: + break; + } + } + break + } + case "rangeContained": + if (opv === null || opv === undefined) break; + if (opv instanceof RangeBase) wheres.push(`${fieldName} <@ ${opv.toString()}`); + break + case "rangeOverlaps": + if (opv === null || opv === undefined) break; + if (opv instanceof RangeBase) wheres.push(`${fieldName} && ${opv.toString()}`); + break default: break; } diff --git a/src/type/index.ts b/src/type/index.ts index 21ab761..5590d95 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -1 +1,2 @@ -export * from "./tsvector"; \ No newline at end of file +export * from "./tsvector"; +export { Int4Range, FloatRange, DateRange, DateTimeRange } from "./range"; \ No newline at end of file diff --git a/src/type/range.ts b/src/type/range.ts new file mode 100644 index 0000000..5fba251 --- /dev/null +++ b/src/type/range.ts @@ -0,0 +1,199 @@ +import moment from "moment"; + +export type RangeBound = "[]" | "()" | "[)" | "(]" + + +function parseRange(rangeStr: string) { + const beg = rangeStr.at(0); + const end = rangeStr.at(-1); + if (beg !== "[" && beg !== "(") return null; + if (end !== "]" && end !== ")") return null; + const content = rangeStr.slice(1, - 1).trim(); + if (!content.length) return null; + const items = content.split(",").map(s => s.trim()); + if (items.length !== 2) return null; + return { + bound: (beg + end) as RangeBound, + begin: items[0], + end: items[1] + }; +} + +export abstract class RangeBase { + #begin: T | null; + #end: T | null; + #bound: RangeBound; + + /** + * 创建范围,系统会对范围值进行简单校验,如果校验失败则返回null + * @param begin 开始值 + * @param end 结束值 + * @param bound 边界信息,默认`[)` + */ + public static create>(this: new (...args: any) => T, begin: string, end: string, bound?: RangeBound): T | null; + /** + * 创建范围,系统会对范围值进行简单校验,如果校验失败则返回null + * @param value 范围值,例如`[1,2)` + */ + public static create>(this: new (...args: any) => T, value: string): T | null; + public static create>(this: new (...args: any) => T, beginOrValue: string, end?: string, bound?: RangeBound): T | null { + let range: T | null = null; + if (typeof end === "string") { + range = new this(beginOrValue, end, bound ?? "[)"); + } + else { + const info = parseRange(beginOrValue); + if (!info) return null; + range = new this(info.begin, info.end, info.bound); + } + if (!range.validate()) return null; + return range; + } + + public constructor(begin: T | null, end: T | null, bound: RangeBound) { + this.#begin = begin; + this.#end = end; + this.#bound = bound; + } + + /** 开始值 */ + public get begin() { return this.#begin; } + + /** 结束值 */ + public get end() { return this.#end; } + + /** 边界信息 */ + public get bound() { return this.#bound; } + + /** 转换为SQL字符串 */ + public toString() { + const boundBegin = this.bound.at(0); + const boundEnd = this.bound.at(-1); + return `'${boundBegin}${this.begin ?? ''},${this.end ?? ''}${boundEnd}'::${this.typename()}`; + } + + /** + * 范围是否包含值 + * @param value 值 + */ + public contains(value: T) { + if (this.begin !== null) { + if (this.bound.at(0) === "(") { + if (this.valueCompare(value, this.begin) <= 0) return false; + } + else { + if (this.valueCompare(value, this.begin) < 0) return false; + } + } + if (this.end !== null) { + if (this.bound.at(-1) === ")") { + if (this.valueCompare(value, this.end) >= 0) return false; + } + else { + if (this.valueCompare(value, this.end) > 0) return false; + } + } + return true; + } + + protected abstract validate(): boolean; + + protected abstract typename(): string; + + /** + * 值比较, 如果value1大于value2,返回1,如果value1等于value2,返回0,如果value1小于value2,返回-1 + * @param value1 值1 + * @param value2 值2 + */ + protected abstract valueCompare(value1: T, value2: T): number; +} + +/** + * 32位整数范围 + */ +export class Int4Range extends RangeBase { + public constructor(begin: string | null, end: string | null, bound: RangeBound) { + super( + begin === null ? null : parseFloat(begin), + end === null ? null : parseFloat(end), + bound + ); + } + + protected validate() { return this.#checkInt(this.begin) && this.#checkInt(this.end); } + + protected typename() { return "int4range"; } + + protected valueCompare(value1: number, value2: number) { + return value1 - value2; + } + + #checkInt(value: number | null) { + if (value === null) return true; + return Number.isInteger(value); + } +} + +/** + * 小数范围 + */ +export class FloatRange extends RangeBase { + public constructor(begin: string | null, end: string | null, bound: RangeBound) { + super( + begin === null ? null : parseFloat(begin), + end === null ? null : parseFloat(end), + bound + ); + } + + protected validate() { return this.#checkFloat(this.begin) && this.#checkFloat(this.end); } + + protected typename() { return "numrange"; } + + protected valueCompare(value1: number, value2: number) { + return value1 - value2; + } + + #checkFloat(value: number | null) { + if (value === null) return true; + return Number.isFinite(value); + } +} + +/** + * 日期范围 + */ +export class DateRange extends RangeBase { + + protected validate() { return this.#checkDate(this.begin) && this.#checkDate(this.end); } + + protected typename() { return "daterange"; } + + protected valueCompare(value1: string, value2: string) { + return moment(value1).diff(value2, "days"); + } + + #checkDate(date: string | null) { + if (date === null) return true; + return /^\d{4}-\d{2}-\d{2}$/.test(date); + } +} + +/** + * 日期时间范围(也就是时间戳范围) + */ +export class DateTimeRange extends RangeBase { + protected validate() { return this.#checkDatetime(this.begin) && this.#checkDatetime(this.end); } + + protected typename() { return "tsrange"; } + + protected valueCompare(value1: string, value2: string) { + return moment(value1).diff(value2, "seconds"); + } + + #checkDatetime(datetime: string | null) { + if (datetime === null) return true; + return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(datetime); + } +} +