mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 10:26:25 +08:00
Base WIP
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { SortBuild, TailKey } from './sort';
|
||||
|
||||
type ValueType = 'numeric' | 'date' | 'bool' | 'text';
|
||||
|
||||
// Hard cap on decoded cursor size so a tampered cursor can't force a large
|
||||
// JSON parse. Real cursors are <1KB (a handful of field values).
|
||||
const MAX_CURSOR_DECODED_BYTES = 4096;
|
||||
|
||||
/*
|
||||
* Null-safe cursor encoder. The previous encoder used a literal string
|
||||
* sentinel `__null__` for NULLs, which could collide with real cell
|
||||
* values. This encoder never sees NULL because sort expressions are
|
||||
* sentinel-wrapped (see sort.ts). It also represents ±Infinity
|
||||
* explicitly so JSON round-tripping is lossless.
|
||||
*/
|
||||
|
||||
export function makeCursor(sorts: SortBuild[], tailKeys: TailKey[]) {
|
||||
const types = new Map<string, ValueType>();
|
||||
for (const s of sorts) types.set(s.key, s.valueType);
|
||||
for (const k of tailKeys) types.set(k, 'text');
|
||||
|
||||
return {
|
||||
encodeCursor(values: Array<[string, unknown]>): string {
|
||||
const payload: Record<string, string> = {};
|
||||
for (const [k, v] of values) {
|
||||
payload[k] = encodeValue(v, types.get(k) ?? 'text');
|
||||
}
|
||||
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
},
|
||||
|
||||
decodeCursor(
|
||||
cursor: string,
|
||||
fieldNames: string[],
|
||||
): Record<string, string> {
|
||||
let parsed: Record<string, string>;
|
||||
try {
|
||||
parsed = JSON.parse(
|
||||
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||
);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid cursor');
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new BadRequestException('Invalid cursor payload');
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const name of fieldNames) {
|
||||
if (!(name in parsed)) {
|
||||
throw new BadRequestException(`Cursor missing field: ${name}`);
|
||||
}
|
||||
out[name] = parsed[name];
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
parseCursor(decoded: Record<string, string>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, raw] of Object.entries(decoded)) {
|
||||
out[k] = decodeValue(raw, types.get(k) ?? 'text');
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function encodeValue(value: unknown, type: ValueType): string {
|
||||
if (type === 'numeric') {
|
||||
if (value === null || value === undefined) return '';
|
||||
const n = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (n === Number.POSITIVE_INFINITY || String(value) === 'Infinity') {
|
||||
return 'inf';
|
||||
}
|
||||
if (n === Number.NEGATIVE_INFINITY || String(value) === '-Infinity') {
|
||||
return '-inf';
|
||||
}
|
||||
if (Number.isNaN(n)) return '';
|
||||
return String(n);
|
||||
}
|
||||
if (type === 'date') {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
const s = String(value);
|
||||
if (s === 'infinity') return 'inf';
|
||||
if (s === '-infinity') return '-inf';
|
||||
return s;
|
||||
}
|
||||
if (type === 'bool') {
|
||||
return value ? '1' : '0';
|
||||
}
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function decodeValue(raw: string, type: ValueType): unknown {
|
||||
if (type === 'numeric') {
|
||||
if (raw === 'inf') return Number.POSITIVE_INFINITY;
|
||||
if (raw === '-inf') return Number.NEGATIVE_INFINITY;
|
||||
if (raw === '') return null;
|
||||
return parseFloat(raw);
|
||||
}
|
||||
if (type === 'date') {
|
||||
if (raw === 'inf') return 'infinity';
|
||||
if (raw === '-inf') return '-infinity';
|
||||
if (raw === '') return null;
|
||||
return raw;
|
||||
}
|
||||
if (type === 'bool') {
|
||||
return raw === '1';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { SelectQueryBuilder } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { BaseRow } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import {
|
||||
CursorPaginationResult,
|
||||
executeWithCursorPagination,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { FilterNode, SearchSpec, SortSpec } from './schema.zod';
|
||||
import { buildWhere, PropertySchema } from './predicate';
|
||||
import { buildSorts, CURSOR_TAIL_KEYS, SortBuild } from './sort';
|
||||
import { buildSearch } from './search';
|
||||
import { makeCursor } from './cursor';
|
||||
|
||||
export type EngineListOpts = {
|
||||
filter?: FilterNode;
|
||||
sorts?: SortSpec[];
|
||||
search?: SearchSpec;
|
||||
schema: PropertySchema;
|
||||
pagination: PaginationOptions;
|
||||
};
|
||||
|
||||
/*
|
||||
* Top-level orchestrator. Callers (repos, services) provide a base
|
||||
* Kysely query already scoped to the target base + workspace + alive
|
||||
* rows; this adds search/filter/sort clauses and runs cursor pagination.
|
||||
*/
|
||||
export async function runListQuery(
|
||||
base: SelectQueryBuilder<DB, 'baseRows', any>,
|
||||
opts: EngineListOpts,
|
||||
): Promise<CursorPaginationResult<BaseRow>> {
|
||||
let qb = base;
|
||||
|
||||
if (opts.search) {
|
||||
const spec = opts.search;
|
||||
qb = qb.where((eb) => buildSearch(eb, spec));
|
||||
}
|
||||
|
||||
if (opts.filter) {
|
||||
const filter = opts.filter;
|
||||
qb = qb.where((eb) => buildWhere(eb, filter, opts.schema));
|
||||
}
|
||||
|
||||
const sortBuilds: SortBuild[] =
|
||||
opts.sorts && opts.sorts.length > 0
|
||||
? buildSorts(opts.sorts, opts.schema)
|
||||
: [];
|
||||
|
||||
for (const sb of sortBuilds) {
|
||||
qb = qb.select(sb.expression.as(sb.key)) as SelectQueryBuilder<
|
||||
DB,
|
||||
'baseRows',
|
||||
any
|
||||
>;
|
||||
}
|
||||
|
||||
const cursor = makeCursor(sortBuilds, CURSOR_TAIL_KEYS);
|
||||
|
||||
const fields = [
|
||||
...sortBuilds.map((sb) => ({
|
||||
expression: sb.expression,
|
||||
direction: sb.direction,
|
||||
key: sb.key,
|
||||
})),
|
||||
{
|
||||
expression: 'position' as const,
|
||||
direction: 'asc' as const,
|
||||
key: 'position' as const,
|
||||
},
|
||||
{
|
||||
expression: 'id' as const,
|
||||
direction: 'asc' as const,
|
||||
key: 'id' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return executeWithCursorPagination(qb as any, {
|
||||
perPage: opts.pagination.limit,
|
||||
cursor: opts.pagination.cursor,
|
||||
beforeCursor: opts.pagination.beforeCursor,
|
||||
fields: fields as any,
|
||||
encodeCursor: cursor.encodeCursor as any,
|
||||
decodeCursor: cursor.decodeCursor as any,
|
||||
parseCursor: cursor.parseCursor as any,
|
||||
}) as unknown as Promise<CursorPaginationResult<BaseRow>>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { sql, RawBuilder } from 'kysely';
|
||||
|
||||
/*
|
||||
* Parameterised extractors wrapping the SQL helper functions installed
|
||||
* by the bases-hardening migration. PropertyId always binds as a
|
||||
* parameter — never string-interpolated. These replace every
|
||||
* `sql.raw('cells->>...')` site in the old repo.
|
||||
*/
|
||||
|
||||
export function textCell(propertyId: string): RawBuilder<string> {
|
||||
return sql<string>`base_cell_text(cells, ${propertyId}::uuid)`;
|
||||
}
|
||||
|
||||
export function numericCell(propertyId: string): RawBuilder<number> {
|
||||
return sql<number>`base_cell_numeric(cells, ${propertyId}::uuid)`;
|
||||
}
|
||||
|
||||
export function dateCell(propertyId: string): RawBuilder<Date> {
|
||||
return sql<Date>`base_cell_timestamptz(cells, ${propertyId}::uuid)`;
|
||||
}
|
||||
|
||||
export function boolCell(propertyId: string): RawBuilder<boolean> {
|
||||
return sql<boolean>`base_cell_bool(cells, ${propertyId}::uuid)`;
|
||||
}
|
||||
|
||||
export function arrayCell(propertyId: string): RawBuilder<unknown> {
|
||||
return sql<unknown>`base_cell_array(cells, ${propertyId}::uuid)`;
|
||||
}
|
||||
|
||||
export function escapeIlike(value: string): string {
|
||||
return value.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export {
|
||||
MAX_FILTER_DEPTH,
|
||||
MAX_FILTER_NODES,
|
||||
MAX_SORTS,
|
||||
conditionSchema,
|
||||
filterGroupSchema,
|
||||
filterNodeSchema,
|
||||
listQuerySchema,
|
||||
operatorSchema,
|
||||
searchSchema,
|
||||
sortSpecSchema,
|
||||
sortsSchema,
|
||||
validateFilterTree,
|
||||
} from './schema.zod';
|
||||
export type {
|
||||
Condition,
|
||||
FilterGroup,
|
||||
FilterNode,
|
||||
ListQuery,
|
||||
Operator,
|
||||
SearchSpec,
|
||||
SortSpec,
|
||||
} from './schema.zod';
|
||||
|
||||
export {
|
||||
PropertyKind,
|
||||
SYSTEM_COLUMN,
|
||||
isSystemType,
|
||||
propertyKind,
|
||||
} from './kinds';
|
||||
export type { PropertyKindValue } from './kinds';
|
||||
|
||||
export { buildWhere } from './predicate';
|
||||
export type { PropertySchema } from './predicate';
|
||||
|
||||
export { buildSorts, CURSOR_TAIL_KEYS } from './sort';
|
||||
export type { SortBuild, TailKey } from './sort';
|
||||
|
||||
export { makeCursor } from './cursor';
|
||||
|
||||
export { buildSearch } from './search';
|
||||
|
||||
export { runListQuery } from './engine';
|
||||
export type { EngineListOpts } from './engine';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BasePropertyType } from '../base.schemas';
|
||||
|
||||
export const PropertyKind = {
|
||||
TEXT: 'text',
|
||||
NUMERIC: 'numeric',
|
||||
DATE: 'date',
|
||||
BOOL: 'bool',
|
||||
SELECT: 'select',
|
||||
MULTI: 'multi',
|
||||
PERSON: 'person',
|
||||
FILE: 'file',
|
||||
SYS_USER: 'sys_user',
|
||||
} as const;
|
||||
|
||||
export type PropertyKindValue = (typeof PropertyKind)[keyof typeof PropertyKind];
|
||||
|
||||
export function propertyKind(type: string): PropertyKindValue | null {
|
||||
switch (type) {
|
||||
case BasePropertyType.TEXT:
|
||||
case BasePropertyType.URL:
|
||||
case BasePropertyType.EMAIL:
|
||||
return PropertyKind.TEXT;
|
||||
case BasePropertyType.NUMBER:
|
||||
return PropertyKind.NUMERIC;
|
||||
case BasePropertyType.DATE:
|
||||
case BasePropertyType.CREATED_AT:
|
||||
case BasePropertyType.LAST_EDITED_AT:
|
||||
return PropertyKind.DATE;
|
||||
case BasePropertyType.CHECKBOX:
|
||||
return PropertyKind.BOOL;
|
||||
case BasePropertyType.SELECT:
|
||||
case BasePropertyType.STATUS:
|
||||
return PropertyKind.SELECT;
|
||||
case BasePropertyType.MULTI_SELECT:
|
||||
return PropertyKind.MULTI;
|
||||
case BasePropertyType.PERSON:
|
||||
return PropertyKind.PERSON;
|
||||
case BasePropertyType.FILE:
|
||||
return PropertyKind.FILE;
|
||||
case BasePropertyType.LAST_EDITED_BY:
|
||||
return PropertyKind.SYS_USER;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// System property type → camelCase column name on `base_rows`.
|
||||
// Kysely camel-case plugin maps to snake_case in SQL.
|
||||
export const SYSTEM_COLUMN: Record<string, 'createdAt' | 'updatedAt' | 'lastUpdatedById'> = {
|
||||
[BasePropertyType.CREATED_AT]: 'createdAt',
|
||||
[BasePropertyType.LAST_EDITED_AT]: 'updatedAt',
|
||||
[BasePropertyType.LAST_EDITED_BY]: 'lastUpdatedById',
|
||||
};
|
||||
|
||||
export function isSystemType(type: string): boolean {
|
||||
return type in SYSTEM_COLUMN;
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { Expression, ExpressionBuilder, sql, SqlBool } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { BaseProperty } from '@docmost/db/types/entity.types';
|
||||
import { Condition, FilterNode } from './schema.zod';
|
||||
import { PropertyKind, propertyKind, SYSTEM_COLUMN } from './kinds';
|
||||
import {
|
||||
arrayCell,
|
||||
boolCell,
|
||||
dateCell,
|
||||
escapeIlike,
|
||||
numericCell,
|
||||
textCell,
|
||||
} from './extractors';
|
||||
|
||||
export type PropertySchema = Map<
|
||||
string,
|
||||
Pick<BaseProperty, 'id' | 'type' | 'typeOptions'>
|
||||
>;
|
||||
|
||||
type Eb = ExpressionBuilder<DB, 'baseRows'>;
|
||||
|
||||
const TRUE = sql<SqlBool>`TRUE`;
|
||||
const FALSE = sql<SqlBool>`FALSE`;
|
||||
|
||||
export function buildWhere(
|
||||
eb: Eb,
|
||||
node: FilterNode,
|
||||
schema: PropertySchema,
|
||||
): Expression<SqlBool> {
|
||||
if ('children' in node) {
|
||||
if (node.children.length === 0) return TRUE;
|
||||
const built = node.children.map((c) => buildWhere(eb, c, schema));
|
||||
return node.op === 'and' ? eb.and(built) : eb.or(built);
|
||||
}
|
||||
return buildCondition(eb, node, schema);
|
||||
}
|
||||
|
||||
function buildCondition(
|
||||
eb: Eb,
|
||||
cond: Condition,
|
||||
schema: PropertySchema,
|
||||
): Expression<SqlBool> {
|
||||
const prop = schema.get(cond.propertyId);
|
||||
if (!prop) return FALSE;
|
||||
|
||||
const sysCol = SYSTEM_COLUMN[prop.type];
|
||||
if (sysCol) return systemCondition(eb, sysCol, prop.type, cond);
|
||||
|
||||
const kind = propertyKind(prop.type);
|
||||
if (!kind) return FALSE;
|
||||
|
||||
switch (kind) {
|
||||
case PropertyKind.TEXT:
|
||||
return textCondition(eb, cond);
|
||||
case PropertyKind.NUMERIC:
|
||||
return numericCondition(eb, cond);
|
||||
case PropertyKind.DATE:
|
||||
return dateCondition(eb, cond);
|
||||
case PropertyKind.BOOL:
|
||||
return boolCondition(eb, cond);
|
||||
case PropertyKind.SELECT:
|
||||
return selectCondition(eb, cond);
|
||||
case PropertyKind.MULTI:
|
||||
return multiCondition(eb, cond);
|
||||
case PropertyKind.PERSON:
|
||||
return personCondition(eb, cond, prop);
|
||||
case PropertyKind.FILE:
|
||||
return arrayOfIdsCondition(eb, cond);
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
// --- per-kind handlers ------------------------------------------------
|
||||
|
||||
function textCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = textCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '=', ''),
|
||||
]);
|
||||
case 'isNotEmpty':
|
||||
return eb.and([
|
||||
eb(expr as any, 'is not', null),
|
||||
eb(expr as any, '!=', ''),
|
||||
]);
|
||||
case 'eq':
|
||||
return val == null ? FALSE : eb(expr as any, '=', String(val));
|
||||
case 'neq':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', String(val)),
|
||||
]);
|
||||
case 'contains':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb(expr as any, 'ilike', `%${escapeIlike(String(val))}%`);
|
||||
case 'ncontains':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, 'not ilike', `%${escapeIlike(String(val))}%`),
|
||||
]);
|
||||
case 'startsWith':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb(expr as any, 'ilike', `${escapeIlike(String(val))}%`);
|
||||
case 'endsWith':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb(expr as any, 'ilike', `%${escapeIlike(String(val))}`);
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function numericCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = numericCell(cond.propertyId);
|
||||
const raw = cond.value;
|
||||
const num = raw == null ? null : Number(raw);
|
||||
const bad = num == null || Number.isNaN(num);
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb(expr as any, 'is', null);
|
||||
case 'isNotEmpty':
|
||||
return eb(expr as any, 'is not', null);
|
||||
case 'eq':
|
||||
return bad ? FALSE : eb(expr as any, '=', num);
|
||||
case 'neq':
|
||||
return bad
|
||||
? FALSE
|
||||
: eb.or([eb(expr as any, 'is', null), eb(expr as any, '!=', num)]);
|
||||
case 'gt':
|
||||
return bad ? FALSE : eb(expr as any, '>', num);
|
||||
case 'gte':
|
||||
return bad ? FALSE : eb(expr as any, '>=', num);
|
||||
case 'lt':
|
||||
return bad ? FALSE : eb(expr as any, '<', num);
|
||||
case 'lte':
|
||||
return bad ? FALSE : eb(expr as any, '<=', num);
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function dateCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = dateCell(cond.propertyId);
|
||||
const raw = cond.value;
|
||||
const bad = raw == null || raw === '';
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb(expr as any, 'is', null);
|
||||
case 'isNotEmpty':
|
||||
return eb(expr as any, 'is not', null);
|
||||
case 'eq':
|
||||
return bad ? FALSE : eb(expr as any, '=', String(raw));
|
||||
case 'neq':
|
||||
return bad
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', String(raw)),
|
||||
]);
|
||||
case 'before':
|
||||
return bad ? FALSE : eb(expr as any, '<', String(raw));
|
||||
case 'after':
|
||||
return bad ? FALSE : eb(expr as any, '>', String(raw));
|
||||
case 'onOrBefore':
|
||||
return bad ? FALSE : eb(expr as any, '<=', String(raw));
|
||||
case 'onOrAfter':
|
||||
return bad ? FALSE : eb(expr as any, '>=', String(raw));
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function boolCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = boolCell(cond.propertyId);
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb(expr as any, 'is', null);
|
||||
case 'isNotEmpty':
|
||||
return eb(expr as any, 'is not', null);
|
||||
case 'eq':
|
||||
return cond.value == null
|
||||
? FALSE
|
||||
: eb(expr as any, '=', Boolean(cond.value));
|
||||
case 'neq':
|
||||
return cond.value == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', Boolean(cond.value)),
|
||||
]);
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function selectCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
// Cell stores a single option UUID as string. Use text extractor.
|
||||
const expr = textCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '=', ''),
|
||||
]);
|
||||
case 'isNotEmpty':
|
||||
return eb.and([
|
||||
eb(expr as any, 'is not', null),
|
||||
eb(expr as any, '!=', ''),
|
||||
]);
|
||||
case 'eq':
|
||||
return val == null ? FALSE : eb(expr as any, '=', String(val));
|
||||
case 'neq':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', String(val)),
|
||||
]);
|
||||
case 'any': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return FALSE;
|
||||
return eb(expr as any, 'in', arr);
|
||||
}
|
||||
case 'none': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return TRUE;
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, 'not in', arr),
|
||||
]);
|
||||
}
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function multiCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
return arrayOfIdsCondition(eb, cond);
|
||||
}
|
||||
|
||||
function personCondition(
|
||||
eb: Eb,
|
||||
cond: Condition,
|
||||
prop: Pick<BaseProperty, 'id' | 'type' | 'typeOptions'>,
|
||||
): Expression<SqlBool> {
|
||||
// Person cells may be stored as a single uuid or an array of uuids depending
|
||||
// on the property's `allowMultiple` option. Normalise to array semantics via
|
||||
// `base_cell_array` when it's stored as an array, else text.
|
||||
const allowMultiple = !!(prop.typeOptions as any)?.allowMultiple;
|
||||
if (allowMultiple) return arrayOfIdsCondition(eb, cond);
|
||||
|
||||
const expr = textCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '=', ''),
|
||||
]);
|
||||
case 'isNotEmpty':
|
||||
return eb.and([
|
||||
eb(expr as any, 'is not', null),
|
||||
eb(expr as any, '!=', ''),
|
||||
]);
|
||||
case 'eq':
|
||||
return val == null ? FALSE : eb(expr as any, '=', String(val));
|
||||
case 'neq':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', String(val)),
|
||||
]);
|
||||
case 'any': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return FALSE;
|
||||
return eb(expr as any, 'in', arr);
|
||||
}
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function arrayOfIdsCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = arrayCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
sql<SqlBool>`jsonb_array_length(${expr}) = 0`,
|
||||
]);
|
||||
case 'isNotEmpty':
|
||||
return eb.and([
|
||||
eb(expr as any, 'is not', null),
|
||||
sql<SqlBool>`jsonb_array_length(${expr}) > 0`,
|
||||
]);
|
||||
case 'any': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return FALSE;
|
||||
return sql<SqlBool>`${expr} ?| ${arr}`;
|
||||
}
|
||||
case 'all': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return TRUE;
|
||||
// `::text::jsonb` because postgres.js auto-detects JSON-shaped strings
|
||||
// as jsonb and re-encodes them, producing a jsonb *string* instead of
|
||||
// an array. Without the text hop, the containment check never matches.
|
||||
return sql<SqlBool>`${expr} @> ${JSON.stringify(arr)}::text::jsonb`;
|
||||
}
|
||||
case 'none': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return TRUE;
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
sql<SqlBool>`NOT (${expr} ?| ${arr})`,
|
||||
]);
|
||||
}
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function systemCondition(
|
||||
eb: Eb,
|
||||
column: 'createdAt' | 'updatedAt' | 'lastUpdatedById',
|
||||
propertyType: string,
|
||||
cond: Condition,
|
||||
): Expression<SqlBool> {
|
||||
const ref = eb.ref(column);
|
||||
const val = cond.value;
|
||||
|
||||
// lastEditedBy — UUID column; behaves like select (uuid equality, in, isEmpty).
|
||||
if (propertyType === 'lastEditedBy') {
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb(ref, 'is', null);
|
||||
case 'isNotEmpty':
|
||||
return eb(ref, 'is not', null);
|
||||
case 'eq':
|
||||
return val == null ? FALSE : eb(ref, '=', String(val));
|
||||
case 'neq':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([eb(ref, 'is', null), eb(ref, '!=', String(val))]);
|
||||
case 'any': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return FALSE;
|
||||
return eb(ref, 'in', arr);
|
||||
}
|
||||
case 'none': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return TRUE;
|
||||
return eb.or([eb(ref, 'is', null), eb(ref, 'not in', arr)]);
|
||||
}
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
// createdAt / updatedAt — timestamptz columns (NOT NULL).
|
||||
const bad = val == null || val === '';
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return FALSE;
|
||||
case 'isNotEmpty':
|
||||
return TRUE;
|
||||
case 'eq':
|
||||
return bad ? FALSE : eb(ref, '=', String(val));
|
||||
case 'neq':
|
||||
return bad ? FALSE : eb(ref, '!=', String(val));
|
||||
case 'before':
|
||||
return bad ? FALSE : eb(ref, '<', String(val));
|
||||
case 'after':
|
||||
return bad ? FALSE : eb(ref, '>', String(val));
|
||||
case 'onOrBefore':
|
||||
return bad ? FALSE : eb(ref, '<=', String(val));
|
||||
case 'onOrAfter':
|
||||
return bad ? FALSE : eb(ref, '>=', String(val));
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
// --- utilities --------------------------------------------------------
|
||||
|
||||
function asStringArray(val: unknown): string[] {
|
||||
if (val == null) return [];
|
||||
if (Array.isArray(val)) return val.filter((v) => v != null).map(String);
|
||||
return [String(val)];
|
||||
}
|
||||
|
||||
export { TRUE as TRUE_EXPR, FALSE as FALSE_EXPR };
|
||||
@@ -0,0 +1,100 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const MAX_FILTER_DEPTH = 5;
|
||||
export const MAX_FILTER_NODES = 50;
|
||||
export const MAX_SORTS = 5;
|
||||
|
||||
const uuid = z.uuid();
|
||||
|
||||
export const operatorSchema = z.enum([
|
||||
'eq',
|
||||
'neq',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'contains',
|
||||
'ncontains',
|
||||
'startsWith',
|
||||
'endsWith',
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'before',
|
||||
'after',
|
||||
'onOrBefore',
|
||||
'onOrAfter',
|
||||
'any',
|
||||
'none',
|
||||
'all',
|
||||
]);
|
||||
|
||||
export type Operator = z.infer<typeof operatorSchema>;
|
||||
|
||||
export const conditionSchema = z.object({
|
||||
propertyId: uuid,
|
||||
op: operatorSchema,
|
||||
value: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type Condition = z.infer<typeof conditionSchema>;
|
||||
|
||||
export type FilterNode = Condition | FilterGroup;
|
||||
export type FilterGroup = {
|
||||
op: 'and' | 'or';
|
||||
children: FilterNode[];
|
||||
};
|
||||
|
||||
// Recursive Zod schema for grouped filter trees.
|
||||
export const filterNodeSchema: z.ZodType<FilterNode> = z.lazy(() =>
|
||||
z.union([conditionSchema, filterGroupSchema]),
|
||||
);
|
||||
|
||||
export const filterGroupSchema: z.ZodType<FilterGroup> = z.lazy(() =>
|
||||
z.object({
|
||||
op: z.enum(['and', 'or']),
|
||||
children: z.array(filterNodeSchema),
|
||||
}),
|
||||
);
|
||||
|
||||
// Count nodes + max depth to prevent pathological trees from reaching SQL.
|
||||
export function validateFilterTree(node: FilterNode): void {
|
||||
let nodes = 0;
|
||||
const walk = (n: FilterNode, depth: number) => {
|
||||
if (depth > MAX_FILTER_DEPTH) {
|
||||
throw new Error(`Filter tree exceeds max depth ${MAX_FILTER_DEPTH}`);
|
||||
}
|
||||
nodes += 1;
|
||||
if (nodes > MAX_FILTER_NODES) {
|
||||
throw new Error(`Filter tree exceeds max node count ${MAX_FILTER_NODES}`);
|
||||
}
|
||||
if ('children' in n) {
|
||||
for (const c of n.children) walk(c, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(node, 0);
|
||||
}
|
||||
|
||||
export const sortSpecSchema = z.object({
|
||||
propertyId: uuid,
|
||||
direction: z.enum(['asc', 'desc']),
|
||||
});
|
||||
|
||||
export type SortSpec = z.infer<typeof sortSpecSchema>;
|
||||
|
||||
export const sortsSchema = z.array(sortSpecSchema).max(MAX_SORTS);
|
||||
|
||||
export const searchSchema = z.object({
|
||||
query: z.string().min(1).max(500),
|
||||
mode: z.enum(['trgm', 'fts']).default('trgm'),
|
||||
});
|
||||
|
||||
export type SearchSpec = z.infer<typeof searchSchema>;
|
||||
|
||||
// Top-level request DTO shape. The row controller DTO composes this.
|
||||
export const listQuerySchema = z.object({
|
||||
filter: filterGroupSchema.optional(),
|
||||
sorts: sortsSchema.optional(),
|
||||
search: searchSchema.optional(),
|
||||
});
|
||||
|
||||
export type ListQuery = z.infer<typeof listQuerySchema>;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Expression, ExpressionBuilder, sql, SqlBool } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { SearchSpec } from './schema.zod';
|
||||
|
||||
type Eb = ExpressionBuilder<DB, 'baseRows'>;
|
||||
|
||||
/*
|
||||
* `search_text` and `search_tsv` are maintained by the base_rows search
|
||||
* trigger installed in the bases-hardening migration. Both columns are
|
||||
* indexed — pg_trgm GIN for ILIKE and standard GIN for tsvector.
|
||||
*/
|
||||
|
||||
export function buildSearch(eb: Eb, spec: SearchSpec): Expression<SqlBool> {
|
||||
const q = spec.query.trim();
|
||||
if (!q) return sql<SqlBool>`TRUE`;
|
||||
|
||||
if (spec.mode === 'fts') {
|
||||
// Accent-insensitive match via f_unaccent (same helper the search
|
||||
// trigger uses when populating search_tsv / search_text).
|
||||
return sql<SqlBool>`search_tsv @@ plainto_tsquery('english', f_unaccent(${q}))`;
|
||||
}
|
||||
|
||||
// trigram ILIKE mode (default). escape %/_/\\ in user input so wildcards
|
||||
// can't be injected.
|
||||
const escaped = q.replace(/[%_\\]/g, '\\$&');
|
||||
return sql<SqlBool>`search_text ILIKE ${'%' + escaped + '%'}`;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { RawBuilder, sql } from 'kysely';
|
||||
import { BaseProperty } from '@docmost/db/types/entity.types';
|
||||
import { SortSpec } from './schema.zod';
|
||||
import { PropertyKind, SYSTEM_COLUMN, propertyKind } from './kinds';
|
||||
import {
|
||||
boolCell,
|
||||
dateCell,
|
||||
numericCell,
|
||||
textCell,
|
||||
} from './extractors';
|
||||
import { PropertySchema } from './predicate';
|
||||
|
||||
/*
|
||||
* Builds sort expressions with sentinel wrapping so NULLs compare
|
||||
* deterministically at the end of the sort order. This avoids the
|
||||
* `__null__` string sentinel bug in the old cursor encoder: because the
|
||||
* sort expression never returns NULL, the cursor simply stores the
|
||||
* extracted value and keyset comparisons work natively.
|
||||
*/
|
||||
|
||||
export type SortBuild = {
|
||||
key: string; // alias used in cursor (s0, s1, ...)
|
||||
expression: RawBuilder<any>; // COALESCE-wrapped expression with sentinel
|
||||
direction: 'asc' | 'desc';
|
||||
valueType: 'numeric' | 'date' | 'text' | 'bool';
|
||||
};
|
||||
|
||||
export type TailKey = 'position' | 'id';
|
||||
|
||||
export const CURSOR_TAIL_KEYS: TailKey[] = ['position', 'id'];
|
||||
|
||||
export function buildSorts(
|
||||
sorts: SortSpec[],
|
||||
schema: PropertySchema,
|
||||
): SortBuild[] {
|
||||
const out: SortBuild[] = [];
|
||||
for (let i = 0; i < sorts.length; i++) {
|
||||
const s = sorts[i];
|
||||
const prop = schema.get(s.propertyId);
|
||||
if (!prop) continue;
|
||||
|
||||
const key = `s${i}`;
|
||||
const dir = s.direction;
|
||||
|
||||
const sysCol = SYSTEM_COLUMN[prop.type];
|
||||
if (sysCol) {
|
||||
out.push({
|
||||
key,
|
||||
expression: sql`${sql.ref(sysCol)}`,
|
||||
direction: dir,
|
||||
valueType: prop.type === 'lastEditedBy' ? 'text' : 'date',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = propertyKind(prop.type);
|
||||
if (!kind) continue;
|
||||
|
||||
out.push(wrapWithSentinel(s.propertyId, kind, dir, key));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wrapWithSentinel(
|
||||
propertyId: string,
|
||||
kind: Exclude<ReturnType<typeof propertyKind>, null>,
|
||||
direction: 'asc' | 'desc',
|
||||
key: string,
|
||||
): SortBuild {
|
||||
if (kind === PropertyKind.NUMERIC) {
|
||||
const sentinel =
|
||||
direction === 'asc'
|
||||
? sql`'Infinity'::numeric`
|
||||
: sql`'-Infinity'::numeric`;
|
||||
return {
|
||||
key,
|
||||
expression: sql`COALESCE(${numericCell(propertyId)}, ${sentinel})`,
|
||||
direction,
|
||||
valueType: 'numeric',
|
||||
};
|
||||
}
|
||||
if (kind === PropertyKind.DATE) {
|
||||
const sentinel =
|
||||
direction === 'asc'
|
||||
? sql`'infinity'::timestamptz`
|
||||
: sql`'-infinity'::timestamptz`;
|
||||
return {
|
||||
key,
|
||||
expression: sql`COALESCE(${dateCell(propertyId)}, ${sentinel})`,
|
||||
direction,
|
||||
valueType: 'date',
|
||||
};
|
||||
}
|
||||
if (kind === PropertyKind.BOOL) {
|
||||
// false < true. ASC NULLS LAST => null → true; DESC NULLS LAST => null → false.
|
||||
const sentinel = direction === 'asc' ? sql`TRUE` : sql`FALSE`;
|
||||
return {
|
||||
key,
|
||||
expression: sql`COALESCE(${boolCell(propertyId)}, ${sentinel})`,
|
||||
direction,
|
||||
valueType: 'bool',
|
||||
};
|
||||
}
|
||||
// TEXT / SELECT / MULTI / PERSON / FILE — sort by raw extracted text.
|
||||
const sentinel = direction === 'asc' ? sql`chr(1114111)` : sql`''`;
|
||||
return {
|
||||
key,
|
||||
expression: sql`COALESCE(${textCell(propertyId)}, ${sentinel})`,
|
||||
direction,
|
||||
valueType: 'text',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user