Merge branch 'next' into develop

This commit is contained in:
xilesun
2026-04-10 10:16:02 +09:00
15 changed files with 277 additions and 44 deletions
+8
View File
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v2.0.35](https://github.com/nocobase/nocobase/compare/v2.0.34...v2.0.35) - 2026-04-09
### 🐛 Bug Fixes
- **[client]** Fixed an issue where the record pickup popup from a sub-tables selection button could not correctly resolve parent item variable values. ([#8996](https://github.com/nocobase/nocobase/pull/8996)) by @gchust
- **[Collection field: Code]** Correct the UI interface of the code fields ([#9061](https://github.com/nocobase/nocobase/pull/9061)) by @2013xile
## [v2.0.34](https://github.com/nocobase/nocobase/compare/v2.0.33...v2.0.34) - 2026-04-08
### 🎉 New Features
+8
View File
@@ -5,6 +5,14 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html)。
## [v2.0.35](https://github.com/nocobase/nocobase/compare/v2.0.34...v2.0.35) - 2026-04-09
### 🐛 修复
- **[client]** 修复子表格的数据选择按钮打开的弹窗中无法正确解析上级项变量值的问题。 ([#8996](https://github.com/nocobase/nocobase/pull/8996)) by @gchust
- **[数据表字段:代码]** 修正代码字段的 UI 类型 ([#9061](https://github.com/nocobase/nocobase/pull/9061)) by @2013xile
## [v2.0.34](https://github.com/nocobase/nocobase/compare/v2.0.33...v2.0.34) - 2026-04-08
### 🎉 新特性
@@ -81,4 +81,18 @@ describe('query builder', () => {
}),
).toThrow('Invalid aggregation function: if(1=2,sleep(1),sleep(3)) and sum');
});
it('should sanitize invalid order direction', () => {
const { queryOptions } = buildQuery(db, db.getCollection('users'), {
orders: [
{
field: ['createdAt'],
alias: 'createdAt',
order: `ASC'); SELECT pg_sleep(1)--` as any,
},
],
});
expect(queryOptions.order).toEqual([[db.sequelize.col('users.created_at'), 'ASC']]);
});
});
@@ -9,6 +9,21 @@
import { Database } from '../../database';
import { createMockDatabase } from '../../mock-database';
import { QueryFormatter, Col } from '../../query/formatter';
class TestQueryFormatter extends QueryFormatter {
formatDate(field: Col) {
return field;
}
formatUnixTimestamp(field: string) {
return this.sequelize.col(field);
}
resolveTimezone(timezone?: string) {
return this.getTimezoneByOffset(timezone);
}
}
describe('query formatter', () => {
let db: Database;
@@ -55,6 +70,14 @@ describe('query formatter', () => {
await db.close();
});
it('should ignore invalid timezone header values', async () => {
const formatter = new TestQueryFormatter(db.sequelize);
expect(formatter.resolveTimezone(`UTC' || current_user || '`)).toBeUndefined();
expect(formatter.resolveTimezone('+05:30')).toBeTruthy();
expect(formatter.resolveTimezone('Asia/Tokyo')).toBe('Asia/Tokyo');
});
it('should format query dimensions by field type', async () => {
const repo = db.getRepository('query_format_test');
const dialect = db.sequelize.getDialect();
@@ -197,4 +197,29 @@ describe('repository query', () => {
expect(result).toMatchObject([{ name: 'u2', age: 20 }]);
});
it('should default invalid order direction to ASC', async () => {
await db.getRepository('users').create({
values: [
{ id: 1, name: 'u1', age: 10, createdAt: '2023-02-02' },
{ id: 2, name: 'u2', age: 20, createdAt: '2023-01-01' },
],
});
const result = await db.getRepository('users').query({
dimensions: [{ field: ['createdAt'], alias: 'createdAt' }],
orders: [
{
field: ['createdAt'],
alias: 'createdAt',
order: `DESC'); SELECT pg_sleep(1)--` as any,
},
],
});
expect(result).toHaveLength(2);
expect(result[0].createdAt).toBeInstanceOf(Date);
expect(result[1].createdAt).toBeInstanceOf(Date);
expect(result[0].createdAt.getTime()).toBeLessThanOrEqual(result[1].createdAt.getTime());
});
});
+2 -1
View File
@@ -24,6 +24,7 @@ import { QueryField, QueryOptions } from './types';
type QuerySelection = { field: QueryField; alias?: string };
const ALLOWED_AGG_FUNCS = ['sum', 'count', 'avg', 'min', 'max'];
const ALLOWED_ORDER_DIRECTIONS = ['ASC', 'DESC'];
function createQueryFormatter(database: Database): QueryFormatter {
switch (database.sequelize.getDialect()) {
@@ -189,7 +190,7 @@ export function buildQuery(database: Database, collection: Collection, options:
const order: Order = orders.map((item: any) => {
const alias = sequelize.getQueryInterface().quoteIdentifier(item.alias);
const name = hasAgg ? sequelize.literal(alias) : sequelize.col(item.field as string);
let sort = (item.order || 'ASC').toUpperCase();
let sort = ALLOWED_ORDER_DIRECTIONS.includes(item.order?.toUpperCase()) ? item.order.toUpperCase() : 'ASC';
if (item.nulls === 'first') {
sort += ' NULLS FIRST';
}
@@ -25,11 +25,16 @@ export abstract class QueryFormatter {
return format;
}
protected getTimezoneByOffset(offset: string) {
if (!/^[+-]\d{1,2}:\d{2}$/.test(offset)) {
protected getTimezoneByOffset(offset?: string) {
if (!offset) {
return;
}
if (moment.tz.zone(offset)) {
return offset;
}
if (!/^[+-]\d{1,2}:\d{2}$/.test(offset)) {
return;
}
const offsetMinutes = moment.duration(offset).asMinutes();
return moment.tz.names().find((timezone) => {
return moment.tz(timezone).utcOffset() === offsetMinutes;
@@ -22,10 +22,11 @@ export class MySQLQueryFormatter extends QueryFormatter {
formatDate(field: Col, format: string, timezone?: string) {
const fmt = this.convertFormat(format);
if (timezone) {
const resolvedTimezone = this.getTimezoneByOffset(timezone);
if (resolvedTimezone) {
return this.sequelize.fn(
'date_format',
this.sequelize.fn('convert_tz', field, process.env.TZ || 'UTC', timezone),
this.sequelize.fn('convert_tz', field, process.env.TZ || 'UTC', resolvedTimezone),
fmt,
);
}
@@ -35,15 +36,16 @@ export class MySQLQueryFormatter extends QueryFormatter {
formatUnixTimestamp(field: string, format: string, accuracy: 'second' | 'millisecond' = 'second', timezone?: string) {
const fmt = this.convertFormat(format);
const quoted = this.sequelize.getQueryInterface().quoteIdentifiers(field);
const resolvedTimezone = this.getTimezoneByOffset(timezone);
const timestamp =
accuracy === 'millisecond'
? this.sequelize.fn('from_unixtime', this.sequelize.literal(`ROUND(${quoted} / 1000)`))
: this.sequelize.fn('from_unixtime', this.sequelize.col(field));
if (timezone) {
if (resolvedTimezone) {
return this.sequelize.fn(
'date_format',
this.sequelize.fn('convert_tz', timestamp, process.env.TZ || 'UTC', timezone),
this.sequelize.fn('convert_tz', timestamp, process.env.TZ || 'UTC', resolvedTimezone),
fmt,
);
}
@@ -16,8 +16,8 @@ export class OracleQueryFormatter extends QueryFormatter {
formatDate(field: Col, format: string, timezone?: string) {
const fmt = this.convertFormat(format);
if (timezone) {
const resolvedTimezone = this.getTimezoneByOffset(timezone);
const resolvedTimezone = this.getTimezoneByOffset(timezone);
if (resolvedTimezone) {
const quoted = this.sequelize.getQueryInterface().quoteIdentifiers((field as any).col);
return this.sequelize.fn(
'to_char',
@@ -31,7 +31,8 @@ export class OracleQueryFormatter extends QueryFormatter {
formatUnixTimestamp(field: string, format: string, accuracy: 'second' | 'millisecond' = 'second', timezone?: string) {
const quoted = this.sequelize.getQueryInterface().quoteIdentifiers(field);
const timestamp = accuracy === 'millisecond' ? `to_timestamp(ROUND(${quoted} / 1000))` : `to_timestamp(${quoted})`;
const literal = timezone ? `${timestamp} AT TIME ZONE '${this.getTimezoneByOffset(timezone)}'` : timestamp;
const resolvedTimezone = this.getTimezoneByOffset(timezone);
const literal = resolvedTimezone ? `${timestamp} AT TIME ZONE '${resolvedTimezone}'` : timestamp;
return this.sequelize.fn('to_char', this.sequelize.literal(literal), this.convertFormat(format));
}
}
@@ -16,8 +16,8 @@ export class PostgresQueryFormatter extends QueryFormatter {
formatDate(field: Col, format: string, timezone?: string) {
const fmt = this.convertFormat(format);
if (timezone) {
const resolvedTimezone = this.getTimezoneByOffset(timezone);
const resolvedTimezone = this.getTimezoneByOffset(timezone);
if (resolvedTimezone) {
const quoted = this.sequelize.getQueryInterface().quoteIdentifiers((field as any).col);
return this.sequelize.fn(
'to_char',
@@ -31,7 +31,8 @@ export class PostgresQueryFormatter extends QueryFormatter {
formatUnixTimestamp(field: string, format: string, accuracy: 'second' | 'millisecond' = 'second', timezone?: string) {
const quoted = this.sequelize.getQueryInterface().quoteIdentifiers(field);
const timestamp = accuracy === 'millisecond' ? `to_timestamp(ROUND(${quoted} / 1000))` : `to_timestamp(${quoted})`;
const literal = timezone ? `${timestamp} AT TIME ZONE '${this.getTimezoneByOffset(timezone)}'` : timestamp;
const resolvedTimezone = this.getTimezoneByOffset(timezone);
const literal = resolvedTimezone ? `${timestamp} AT TIME ZONE '${resolvedTimezone}'` : timestamp;
return this.sequelize.fn('to_char', this.sequelize.literal(literal), this.convertFormat(format));
}
}
@@ -22,7 +22,7 @@ export class SQLiteQueryFormatter extends QueryFormatter {
formatDate(field: Col, format: string, timezone?: string) {
const fmt = this.convertFormat(format);
if (timezone) {
if (timezone && /^[+-]\d{1,2}:\d{2}$/.test(timezone)) {
return this.sequelize.fn('strftime', fmt, field, this.getOffsetExpression(timezone));
}
return this.sequelize.fn('strftime', fmt, field);
@@ -33,7 +33,7 @@ export class SQLiteQueryFormatter extends QueryFormatter {
const base =
accuracy === 'millisecond' ? this.sequelize.literal(`ROUND(${quoted} / 1000)`) : this.sequelize.col(field);
const args: any[] = [base, 'unixepoch'];
if (timezone) {
if (timezone && /^[+-]\d{1,2}:\d{2}$/.test(timezone)) {
args.push(this.getOffsetExpression(timezone));
}
return this.sequelize.fn('strftime', this.convertFormat(format), this.sequelize.fn('DATETIME', ...args));
@@ -208,4 +208,86 @@ describe('observer', () => {
expect(screen.getByText('Count: 0')).toBeInTheDocument();
expect(screen.queryByText('Count: 1')).not.toBeInTheDocument();
});
it('should flush pending update without TDZ error when context becomes active before timer callback runs', async () => {
vi.useFakeTimers();
try {
const model = observable({ count: 0 });
const pageActive = observable.ref(false);
const tabActive = observable.ref(true);
const context = {
pageActive,
tabActive,
};
(useFlowContext as any).mockReturnValue(context);
const Component = observer(() => <div>Count: {model.count}</div>);
render(<Component />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
act(() => {
model.count++;
pageActive.value = true;
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getByText('Count: 1')).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
it('should cleanup pending timer and listener on unmount', async () => {
vi.useFakeTimers();
try {
const model = observable({ count: 0 });
const pageActive = observable.ref(false);
const tabActive = observable.ref(true);
const renderSpy = vi.fn();
const context = {
pageActive,
tabActive,
};
(useFlowContext as any).mockReturnValue(context);
const Component = observer(() => {
renderSpy(model.count);
return <div>Count: {model.count}</div>;
});
const { unmount } = render(<Component />);
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
act(() => {
model.count++;
});
unmount();
act(() => {
pageActive.value = true;
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(renderSpy).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
@@ -10,7 +10,7 @@
import { observer as originalObserver, IObserverOptions, ReactFC } from '@formily/reactive-react';
import React, { useMemo, useEffect, useRef } from 'react';
import { useFlowContext } from '../FlowContextProvider';
import { autorun } from '@formily/reactive';
import { reaction } from '@formily/reactive';
import { FlowEngineContext } from '..';
type ObserverComponentProps<P, Options extends IObserverOptions> = Options extends {
@@ -30,12 +30,67 @@ export const observer = <P, Options extends IObserverOptions = IObserverOptions>
const ctxRef = useRef(ctx);
ctxRef.current = ctx;
// Store the pending disposer to avoid creating multiple listeners
// 保存延迟更新的监听器,避免重复创建监听。
const pendingDisposerRef = useRef<(() => void) | null>(null);
// 保存延迟创建监听器的定时器,避免组件卸载后仍继续调度。
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Cleanup on unmount
/**
* 清理挂起的可见性监听器。
*
* @example
* ```typescript
* clearPendingDisposer();
* ```
*/
const clearPendingDisposer = () => {
if (pendingDisposerRef.current) {
pendingDisposerRef.current();
pendingDisposerRef.current = null;
}
};
/**
* 清理挂起的定时器。
*
* @example
* ```typescript
* clearPendingTimer();
* ```
*/
const clearPendingTimer = () => {
if (pendingTimerRef.current) {
clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = null;
}
};
/**
* 判断当前页面与标签页是否允许立即更新。
*
* @returns 当前上下文是否处于可更新状态。
* @example
* ```typescript
* if (isContextActive()) {
* updater();
* }
* ```
*/
const isContextActive = () => {
const pageActive = getPageActive(ctxRef.current);
const tabActive = ctxRef.current?.tabActive?.value;
return pageActive !== false && tabActive !== false;
};
// 组件卸载时统一清理所有挂起任务,避免异步回调在卸载后继续运行。
useEffect(() => {
return () => {
if (pendingTimerRef.current) {
clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = null;
}
if (pendingDisposerRef.current) {
pendingDisposerRef.current();
pendingDisposerRef.current = null;
@@ -47,38 +102,45 @@ export const observer = <P, Options extends IObserverOptions = IObserverOptions>
() =>
originalObserver(Component, {
scheduler(updater) {
const pageActive = getPageActive(ctxRef.current);
const tabActive = ctxRef.current?.tabActive?.value;
if (!isContextActive()) {
if (pendingTimerRef.current || pendingDisposerRef.current) {
return;
}
// 通过异步任务打断同步调度,避免连续触发时形成递归更新。
pendingTimerRef.current = setTimeout(() => {
pendingTimerRef.current = null;
if (pageActive === false || tabActive === false) {
// Avoid stack overflow
setTimeout(() => {
// If there is already a pending updater, do nothing
if (pendingDisposerRef.current) {
return;
}
// Delay the update until the page and tab become active
const disposer = autorun(() => {
if (
ctxRef.current?.pageActive?.value &&
(ctxRef.current?.tabActive?.value === true || ctxRef.current?.tabActive?.value === undefined)
) {
if (isContextActive()) {
updater();
return;
}
// 只监听组合后的“是否可更新”状态,条件恢复后执行一次并立即销毁。
pendingDisposerRef.current = reaction(
() => isContextActive(),
(active) => {
if (!active) {
return;
}
clearPendingDisposer();
updater();
disposer?.();
pendingDisposerRef.current = null;
}
});
pendingDisposerRef.current = disposer;
},
{
name: 'FlowObserverPendingUpdate',
},
);
});
return;
}
// If we are updating immediately, clear any pending disposer
if (pendingDisposerRef.current) {
pendingDisposerRef.current();
pendingDisposerRef.current = null;
}
clearPendingTimer();
clearPendingDisposer();
updater();
},
@@ -30,6 +30,7 @@ export class CodeFieldInterface extends CollectionFieldInterface {
'x-component': 'CodeEditor',
},
};
availableTypes = ['text'];
properties = {
...defaultProps,
'uiSchema.x-component-props.language': {
+3 -3
View File
@@ -15694,7 +15694,7 @@ dateformat@^3.0.0:
resolved "https://registry.npmmirror.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
dayjs-timezone-iana-plugin@=0.1.0:
dayjs-timezone-iana-plugin@0.1.0, dayjs-timezone-iana-plugin@=0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/dayjs-timezone-iana-plugin/-/dayjs-timezone-iana-plugin-0.1.0.tgz#216613f6ec80106ab8be025cf5935018c901e997"
integrity sha512-xc8cIZmi4oKr2nfu41I/FDWZKa8n8YaRMxSz9MrpXTNo8c6ZsjZuIoy5RPNmLXPqntFuITWI8obB7lUA+CdzGQ==
@@ -35483,7 +35483,7 @@ yargs@~3.10.0:
decamelize "^1.0.0"
window-size "0.1.0"
yauzl@=2.10.0, yauzl@^2.10.0, yauzl@^2.4.2:
yauzl@2.10.0, yauzl@=2.10.0, yauzl@^2.10.0, yauzl@^2.4.2:
version "2.10.0"
resolved "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
@@ -35499,7 +35499,7 @@ yauzl@^3.1.3, yauzl@^3.2.0:
buffer-crc32 "~0.2.3"
pend "~1.2.0"
yazl@=2.5.1:
yazl@2.5.1, yazl@=2.5.1:
version "2.5.1"
resolved "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz#a3d65d3dd659a5b0937850e8609f22fffa2b5c35"
integrity sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==