mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-10 15:03:12 +08:00
Compare commits
47 Commits
fix/ob-ora
...
fix/oceanb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ba3216fa8 | ||
|
|
fca3f0cffb | ||
|
|
20ff4f3687 | ||
|
|
73ce3039ce | ||
|
|
c27a038fc5 | ||
|
|
c3c4efc6ec | ||
|
|
61296bc855 | ||
|
|
78da0076cf | ||
|
|
279b38fec3 | ||
|
|
0e348fcca4 | ||
|
|
7d79fae84d | ||
|
|
f642e0325d | ||
|
|
43a408f1c9 | ||
|
|
b97b5cf6e2 | ||
|
|
6c269a99cf | ||
|
|
24841f2dd9 | ||
|
|
c2e60c1e52 | ||
|
|
7a87d43bdc | ||
|
|
5fa1c90674 | ||
|
|
c79225ef27 | ||
|
|
0bd8fdea1f | ||
|
|
18dbd043ae | ||
|
|
a7535dec2b | ||
|
|
dbd4b7f763 | ||
|
|
68eacbbcf7 | ||
|
|
cecefce84d | ||
|
|
2406c1ff71 | ||
|
|
a4a0ac25cf | ||
|
|
fbb23f0b9a | ||
|
|
016e2bc70e | ||
|
|
070c2328e0 | ||
|
|
c117da42e4 | ||
|
|
950856d86f | ||
|
|
93a3240862 | ||
|
|
1116a3736b | ||
|
|
19af57e46b | ||
|
|
401c33c505 | ||
|
|
8c968c1a87 | ||
|
|
4fee58999d | ||
|
|
bd0752df96 | ||
|
|
47a15a6674 | ||
|
|
2a64bad76e | ||
|
|
7ee8698165 | ||
|
|
9ae35e5fec | ||
|
|
b618927454 | ||
|
|
e8ad34d2f0 | ||
|
|
007fdc9a5d |
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, InputNumber, Pagination, Select } from 'antd';
|
import { Button, InputNumber, Pagination, Select } from 'antd';
|
||||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
import { CloseOutlined, LeftOutlined, RightOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
|
||||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||||
|
|
||||||
interface DataGridPaginationState {
|
interface DataGridPaginationState {
|
||||||
@@ -26,12 +26,31 @@ export interface DataGridPaginationBarProps {
|
|||||||
paginationPageText: string;
|
paginationPageText: string;
|
||||||
paginationPageSizeOptions: string[];
|
paginationPageSizeOptions: string[];
|
||||||
showKnownPageCount: boolean;
|
showKnownPageCount: boolean;
|
||||||
|
manualTotalCountAvailable?: boolean;
|
||||||
|
totalCountLoading?: boolean;
|
||||||
onPageChange?: (page: number, size: number) => void;
|
onPageChange?: (page: number, size: number) => void;
|
||||||
onPageSizeChange: (value: string) => void;
|
onPageSizeChange: (value: string) => void;
|
||||||
onV2PageStep: (direction: 'previous' | 'next') => void;
|
onV2PageStep: (direction: 'previous' | 'next') => void;
|
||||||
|
onToggleTotalCount?: () => void;
|
||||||
translate?: DataGridPaginationTranslate;
|
translate?: DataGridPaginationTranslate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findToolbarTotalCountButton = (
|
||||||
|
trigger: HTMLElement,
|
||||||
|
labels: string[],
|
||||||
|
): HTMLButtonElement | null => {
|
||||||
|
const root = trigger.closest('.data-grid-root') || trigger.ownerDocument?.body;
|
||||||
|
if (!root) return null;
|
||||||
|
const normalizedLabels = labels.map((label) => String(label || '').trim()).filter(Boolean);
|
||||||
|
const buttons = Array.from(root.querySelectorAll('button')) as HTMLButtonElement[];
|
||||||
|
return buttons.find((button) => {
|
||||||
|
if (button === trigger) return false;
|
||||||
|
if (button.disabled) return false;
|
||||||
|
const text = String(button.textContent || '').replace(/\s+/g, ' ').trim();
|
||||||
|
return normalizedLabels.some((label) => text === label || text.includes(label));
|
||||||
|
}) || null;
|
||||||
|
};
|
||||||
|
|
||||||
const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
||||||
isV2Ui,
|
isV2Ui,
|
||||||
pagination,
|
pagination,
|
||||||
@@ -42,9 +61,12 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
|||||||
paginationPageText,
|
paginationPageText,
|
||||||
paginationPageSizeOptions,
|
paginationPageSizeOptions,
|
||||||
showKnownPageCount,
|
showKnownPageCount,
|
||||||
|
manualTotalCountAvailable = false,
|
||||||
|
totalCountLoading = false,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onPageSizeChange,
|
onPageSizeChange,
|
||||||
onV2PageStep,
|
onV2PageStep,
|
||||||
|
onToggleTotalCount,
|
||||||
translate = defaultTranslate,
|
translate = defaultTranslate,
|
||||||
}) => {
|
}) => {
|
||||||
const [jumpPage, setJumpPage] = React.useState<number | null>(pagination?.current ?? null);
|
const [jumpPage, setJumpPage] = React.useState<number | null>(pagination?.current ?? null);
|
||||||
@@ -58,6 +80,37 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countTotalLabel = translate('data_grid.toolbar.count_total');
|
||||||
|
const cancelCountLabel = translate('data_grid.toolbar.cancel_count');
|
||||||
|
const effectiveTotalCountLoading = totalCountLoading || Boolean(pagination.totalCountLoading);
|
||||||
|
const shouldShowTotalCountButton = Boolean(
|
||||||
|
onToggleTotalCount
|
||||||
|
|| manualTotalCountAvailable
|
||||||
|
|| pagination.totalCountLoading
|
||||||
|
|| pagination.totalKnown === false,
|
||||||
|
);
|
||||||
|
const handleToggleTotalCount = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (onToggleTotalCount) {
|
||||||
|
onToggleTotalCount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Backward-compatible bridge for existing DataGridShell callers: the top toolbar already owns
|
||||||
|
// the total-count handler, but it can be horizontally scrolled out of view on large toolbars.
|
||||||
|
// Trigger that existing button so the pagination bar can expose the action without duplicating data-flow state.
|
||||||
|
const toolbarButton = findToolbarTotalCountButton(event.currentTarget, [countTotalLabel, cancelCountLabel]);
|
||||||
|
toolbarButton?.click();
|
||||||
|
};
|
||||||
|
const totalCountButton = shouldShowTotalCountButton ? (
|
||||||
|
<Button
|
||||||
|
data-grid-pagination-total-count="true"
|
||||||
|
size="small"
|
||||||
|
icon={effectiveTotalCountLoading ? <CloseOutlined /> : <VerticalAlignBottomOutlined />}
|
||||||
|
onClick={handleToggleTotalCount}
|
||||||
|
>
|
||||||
|
{effectiveTotalCountLoading ? cancelCountLabel : countTotalLabel}
|
||||||
|
</Button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const maxJumpPage = showKnownPageCount ? Math.max(1, paginationTotalPages) : null;
|
const maxJumpPage = showKnownPageCount ? Math.max(1, paginationTotalPages) : null;
|
||||||
const normalizedJumpPage = Number.isFinite(Number(jumpPage)) && Number(jumpPage) > 0
|
const normalizedJumpPage = Number.isFinite(Number(jumpPage)) && Number(jumpPage) > 0
|
||||||
? (maxJumpPage !== null
|
? (maxJumpPage !== null
|
||||||
@@ -132,6 +185,7 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
|||||||
<div className="data-grid-pagination-summary" aria-live="polite">
|
<div className="data-grid-pagination-summary" aria-live="polite">
|
||||||
<span className="data-grid-pagination-summary-value">{paginationV2SummaryText}</span>
|
<span className="data-grid-pagination-summary-value">{paginationV2SummaryText}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{totalCountButton}
|
||||||
<Button
|
<Button
|
||||||
data-grid-v2-pagination-prev="true"
|
data-grid-v2-pagination-prev="true"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -174,6 +228,7 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
|
|||||||
<span className="data-grid-pagination-kicker">{translate('data_grid.pagination.result_set')}</span>
|
<span className="data-grid-pagination-kicker">{translate('data_grid.pagination.result_set')}</span>
|
||||||
<span className="data-grid-pagination-summary-value">{paginationSummaryText}</span>
|
<span className="data-grid-pagination-summary-value">{paginationSummaryText}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{totalCountButton}
|
||||||
{showSequentialPagination ? sequentialPaginationControl : (
|
{showSequentialPagination ? sequentialPaginationControl : (
|
||||||
<Pagination
|
<Pagination
|
||||||
current={pagination.current}
|
current={pagination.current}
|
||||||
|
|||||||
@@ -24,6 +24,141 @@ const sameEditorPosition = (left: any, right: any): boolean => (
|
|||||||
&& Number(left?.column) === Number(right?.column)
|
&& Number(left?.column) === Number(right?.column)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const stripSqlIdentifierQuotes = (value: string): string => {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (!text) return '';
|
||||||
|
if ((text.startsWith('`') && text.endsWith('`'))
|
||||||
|
|| (text.startsWith('"') && text.endsWith('"'))
|
||||||
|
|| (text.startsWith('[') && text.endsWith(']'))) {
|
||||||
|
return text.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitSqlIdentifierPath = (raw: string): string[] => (
|
||||||
|
String(raw || '')
|
||||||
|
.split('.')
|
||||||
|
.map(stripSqlIdentifierQuotes)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolveIdentifierWindowAtColumn = (
|
||||||
|
lineContent: string,
|
||||||
|
column: number,
|
||||||
|
): { start: number; end: number; text: string } | null => {
|
||||||
|
const text = String(lineContent || '');
|
||||||
|
if (!text) return null;
|
||||||
|
const isIdentChar = (ch: string) => /[A-Za-z0-9_$`"\[\].]/.test(ch || '');
|
||||||
|
let offset = Math.max(0, Math.min(text.length - 1, Number(column || 1) - 2));
|
||||||
|
if (!isIdentChar(text[offset] || '')) {
|
||||||
|
if (offset > 0 && isIdentChar(text[offset - 1] || '')) {
|
||||||
|
offset -= 1;
|
||||||
|
} else if (offset + 1 < text.length && isIdentChar(text[offset + 1] || '')) {
|
||||||
|
offset += 1;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let start = offset;
|
||||||
|
while (start > 0 && isIdentChar(text[start - 1] || '')) start -= 1;
|
||||||
|
let end = offset + 1;
|
||||||
|
while (end < text.length && isIdentChar(text[end] || '')) end += 1;
|
||||||
|
return start < end ? { start, end, text: text.slice(start, end).trim() } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLikelyTableReferenceIdentifier = (
|
||||||
|
lineContent: string,
|
||||||
|
identifierStart: number,
|
||||||
|
): boolean => {
|
||||||
|
const beforeIdentifier = String(lineContent || '').slice(0, Math.max(0, identifierStart));
|
||||||
|
return /\b(?:from|join|update|into|delete\s+from|alter\s+table|drop\s+table|truncate\s+table)\s*$/i.test(beforeIdentifier);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOceanBaseOracleConnection = (connection: any): boolean => {
|
||||||
|
const config = connection?.config || {};
|
||||||
|
return String(config.type || '').trim().toLowerCase() === 'oceanbase'
|
||||||
|
&& String(config.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle';
|
||||||
|
};
|
||||||
|
|
||||||
|
const installOceanBaseOracleNavigationFallback = (editor: any) => {
|
||||||
|
const editorDomNode = editor?.getDomNode?.();
|
||||||
|
if (!editorDomNode || editor.__gonaviObOracleNavigationFallbackInstalled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.defineProperty(editor, '__gonaviObOracleNavigationFallbackInstalled', {
|
||||||
|
value: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMouseDownCapture = (event: MouseEvent) => {
|
||||||
|
if (event.button !== 0 || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useStore.getState();
|
||||||
|
const activeTab = (store.tabs || []).find((tab: any) => tab.id === store.activeTabId);
|
||||||
|
if (!activeTab || activeTab.type !== 'query') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const connectionId = String(activeTab.connectionId || store.activeContext?.connectionId || '').trim();
|
||||||
|
if (!connectionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const connection = (store.connections || []).find((item: any) => item.id === connectionId);
|
||||||
|
if (!isOceanBaseOracleConnection(connection)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = editor.getTargetAtClientPoint?.(event.clientX, event.clientY);
|
||||||
|
const position = target?.position;
|
||||||
|
if (!position) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const model = editor.getModel?.();
|
||||||
|
const lineContent = String(model?.getLineContent?.(position.lineNumber) || '');
|
||||||
|
const identifier = resolveIdentifierWindowAtColumn(lineContent, position.column);
|
||||||
|
if (!identifier || !identifier.text.includes('.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isLikelyTableReferenceIdentifier(lineContent, identifier.start)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = splitSqlIdentifierPath(identifier.text);
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const schemaName = parts[parts.length - 2];
|
||||||
|
const tableName = parts[parts.length - 1];
|
||||||
|
if (!schemaName || !tableName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation?.();
|
||||||
|
store.setActiveContext?.({ connectionId, dbName: schemaName });
|
||||||
|
store.addTab?.({
|
||||||
|
id: `${connectionId}-${schemaName}-table-${tableName}`,
|
||||||
|
title: tableName,
|
||||||
|
type: 'table',
|
||||||
|
connectionId,
|
||||||
|
dbName: schemaName,
|
||||||
|
tableName,
|
||||||
|
initialViewMode: 'fields',
|
||||||
|
initialViewModeRequestId: String(Date.now()),
|
||||||
|
objectType: 'table',
|
||||||
|
returnToTabId: activeTab.id || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
editorDomNode.addEventListener('mousedown', handleMouseDownCapture, true);
|
||||||
|
editor.onDidDispose?.(() => {
|
||||||
|
editorDomNode.removeEventListener('mousedown', handleMouseDownCapture, true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const patchQueryEditorAiInlineRightArrowFallback = (editor: any, monaco: any) => {
|
const patchQueryEditorAiInlineRightArrowFallback = (editor: any, monaco: any) => {
|
||||||
const originalAddCommand = editor?.addCommand?.bind?.(editor);
|
const originalAddCommand = editor?.addCommand?.bind?.(editor);
|
||||||
if (!originalAddCommand || !monaco?.KeyCode?.RightArrow) {
|
if (!originalAddCommand || !monaco?.KeyCode?.RightArrow) {
|
||||||
@@ -154,6 +289,7 @@ const MonacoEditor: React.FC<MonacoEditorProps> = ({
|
|||||||
}, [beforeMount]);
|
}, [beforeMount]);
|
||||||
|
|
||||||
const handleMount: OnMount = useCallback((editor, monaco) => {
|
const handleMount: OnMount = useCallback((editor, monaco) => {
|
||||||
|
installOceanBaseOracleNavigationFallback(editor);
|
||||||
patchQueryEditorAiInlineRightArrowFallback(editor, monaco);
|
patchQueryEditorAiInlineRightArrowFallback(editor, monaco);
|
||||||
onMount?.(editor, monaco);
|
onMount?.(editor, monaco);
|
||||||
}, [onMount]);
|
}, [onMount]);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const legacyLiterals = [
|
|||||||
'选择连接',
|
'选择连接',
|
||||||
'选择数据库',
|
'选择数据库',
|
||||||
'最大返回行数',
|
'最大返回行数',
|
||||||
|
'最大行数:100',
|
||||||
'最大行数:500',
|
'最大行数:500',
|
||||||
'最大行数:1000',
|
'最大行数:1000',
|
||||||
'最大行数:5000',
|
'最大行数:5000',
|
||||||
@@ -64,6 +65,7 @@ describe('QueryEditorToolbar i18n', () => {
|
|||||||
expect(source).toContain("import { useOptionalI18n } from '../i18n/provider';");
|
expect(source).toContain("import { useOptionalI18n } from '../i18n/provider';");
|
||||||
expect(source).toContain('const i18n = useOptionalI18n();');
|
expect(source).toContain('const i18n = useOptionalI18n();');
|
||||||
expect(source).toContain('const t = i18n?.t ?? defaultTranslate;');
|
expect(source).toContain('const t = i18n?.t ?? defaultTranslate;');
|
||||||
|
expect(source).toContain("{ label: '100', value: 100 }");
|
||||||
|
|
||||||
for (const key of requiredKeys) {
|
for (const key of requiredKeys) {
|
||||||
expect(source).toContain(key);
|
expect(source).toContain(key);
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
|
|||||||
value={maxRows}
|
value={maxRows}
|
||||||
onChange={(val) => onMaxRowsChange(Number(val))}
|
onChange={(val) => onMaxRowsChange(Number(val))}
|
||||||
options={[
|
options={[
|
||||||
|
{ label: '100', value: 100 },
|
||||||
{ label: t("query_editor.max_rows.option_500"), value: 500 },
|
{ label: t("query_editor.max_rows.option_500"), value: 500 },
|
||||||
{ label: t("query_editor.max_rows.option_1000"), value: 1000 },
|
{ label: t("query_editor.max_rows.option_1000"), value: 1000 },
|
||||||
{ label: t("query_editor.max_rows.option_5000"), value: 5000 },
|
{ label: t("query_editor.max_rows.option_5000"), value: 5000 },
|
||||||
|
|||||||
@@ -26,6 +26,25 @@ describe('queryResultPagination', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats query-editor injected LIMIT as a capped page rather than an uncounted total', () => {
|
||||||
|
const page = createInitialQueryResultPagination({
|
||||||
|
executedSql: 'SELECT id, name FROM users LIMIT 500',
|
||||||
|
exportSql: 'SELECT id, name FROM users',
|
||||||
|
dbType: 'mysql',
|
||||||
|
returnedRowCount: 500,
|
||||||
|
fallbackPageSize: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page).toMatchObject({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 500,
|
||||||
|
total: 500,
|
||||||
|
totalKnown: true,
|
||||||
|
baseSql: 'SELECT id, name FROM users',
|
||||||
|
exportAllSql: 'SELECT id, name FROM users',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('builds the next page SQL with one lookahead row', () => {
|
it('builds the next page SQL with one lookahead row', () => {
|
||||||
expect(buildQueryResultPageSql({
|
expect(buildQueryResultPageSql({
|
||||||
baseSql: 'SELECT id FROM users',
|
baseSql: 'SELECT id FROM users',
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ const normalizePositiveInteger = (value: unknown): number => {
|
|||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeSqlForComparison = (sql: string): string => (
|
||||||
|
String(sql || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/;+\s*$/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
const parseTopLevelLimit = (sql: string): LimitInfo | null => {
|
const parseTopLevelLimit = (sql: string): LimitInfo | null => {
|
||||||
const { main } = splitSqlTail(sql);
|
const { main } = splitSqlTail(sql);
|
||||||
const limitPos = findTopLevelKeyword(main, 'limit');
|
const limitPos = findTopLevelKeyword(main, 'limit');
|
||||||
@@ -60,6 +68,14 @@ const stripExplicitLimitForExport = (sql: string): string => {
|
|||||||
return splitSqlTail(sql).main.trim();
|
return splitSqlTail(sql).main.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wasLimitAppliedByQueryEditorCap = (executedSql: string, exportSql: string): boolean => {
|
||||||
|
const executed = String(executedSql || '').trim();
|
||||||
|
const exportable = String(exportSql || '').trim();
|
||||||
|
if (!executed || !exportable) return false;
|
||||||
|
if (normalizeSqlForComparison(executed) === normalizeSqlForComparison(exportable)) return false;
|
||||||
|
return normalizeSqlForComparison(stripExplicitLimitForExport(executed)) === normalizeSqlForComparison(stripExplicitLimitForExport(exportable));
|
||||||
|
};
|
||||||
|
|
||||||
const resolveWrappedBaseSql = (dbType: string, baseSql: string): string => {
|
const resolveWrappedBaseSql = (dbType: string, baseSql: string): string => {
|
||||||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||||||
const base = baseSql.trim();
|
const base = baseSql.trim();
|
||||||
@@ -146,11 +162,14 @@ export const createInitialQueryResultPagination = (params: {
|
|||||||
const exportAllSql = exportSql && getLeadingKeyword(exportSql) === 'select'
|
const exportAllSql = exportSql && getLeadingKeyword(exportSql) === 'select'
|
||||||
? stripExplicitLimitForExport(exportSql)
|
? stripExplicitLimitForExport(exportSql)
|
||||||
: stripExplicitLimitForExport(executedSql);
|
: stripExplicitLimitForExport(executedSql);
|
||||||
const totalState = resolveQueryResultPaginationTotal({
|
const autoLimitCap = current === 1 && wasLimitAppliedByQueryEditorCap(executedSql, exportSql);
|
||||||
current,
|
const totalState = autoLimitCap
|
||||||
pageSize,
|
? { total: returnedRowCount, totalKnown: true }
|
||||||
rowCount: returnedRowCount,
|
: resolveQueryResultPaginationTotal({
|
||||||
});
|
current,
|
||||||
|
pageSize,
|
||||||
|
rowCount: returnedRowCount,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current,
|
current,
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ describe('formatSqlExecutionError', () => {
|
|||||||
expect(formatted).toContain('Raw error: driver returned unexpected status 123');
|
expect(formatted).toContain('Raw error: driver returned unexpected status 123');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recognizes driver bad connection during SQL execution as timeout semantics', () => {
|
||||||
|
const formatted = formatSqlExecutionError('第 1 条语句执行失败:driver: bad connection');
|
||||||
|
|
||||||
|
expect(formatted).toContain('Semantic meaning: Query timed out or was canceled');
|
||||||
|
expect(formatted).toContain('Raw error: 第 1 条语句执行失败:driver: bad connection');
|
||||||
|
});
|
||||||
|
|
||||||
it('recognizes localized connection-timeout wrappers as timeout semantics', () => {
|
it('recognizes localized connection-timeout wrappers as timeout semantics', () => {
|
||||||
const translate = (key: string, params?: Record<string, unknown>) => {
|
const translate = (key: string, params?: Record<string, unknown>) => {
|
||||||
if (key === 'query_editor.sql_error.wrapper.semantic_line') {
|
if (key === 'query_editor.sql_error.wrapper.semantic_line') {
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
|
|||||||
/context cancelled/i,
|
/context cancelled/i,
|
||||||
/timeout/i,
|
/timeout/i,
|
||||||
/timed out/i,
|
/timed out/i,
|
||||||
|
/driver:\s*bad connection/i,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ func TestNormalizeRunConfig_OceanBaseOracleAddsCurrentSchemaInit(t *testing.T) {
|
|||||||
if runConfig.Database != "OBORCL" {
|
if runConfig.Database != "OBORCL" {
|
||||||
t.Fatalf("expected OceanBase Oracle service name to stay OBORCL, got %q", runConfig.Database)
|
t.Fatalf("expected OceanBase Oracle service name to stay OBORCL, got %q", runConfig.Database)
|
||||||
}
|
}
|
||||||
|
if runConfig.Timeout != 0 {
|
||||||
|
t.Fatalf("expected default OceanBase Oracle timeout to stay unset, got %d", runConfig.Timeout)
|
||||||
|
}
|
||||||
values, err := url.ParseQuery(runConfig.ConnectionParams)
|
values, err := url.ParseQuery(runConfig.ConnectionParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected connection params parse error: %v", err)
|
t.Fatalf("unexpected connection params parse error: %v", err)
|
||||||
@@ -30,17 +33,21 @@ func TestNormalizeRunConfig_OceanBaseOracleAddsCurrentSchemaInit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeRunConfig_OceanBaseOraclePreservesExistingInit(t *testing.T) {
|
func TestNormalizeRunConfig_OceanBaseOraclePreservesExplicitTimeoutAndExistingInit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
config := connection.ConnectionConfig{
|
config := connection.ConnectionConfig{
|
||||||
Type: "oceanbase",
|
Type: "oceanbase",
|
||||||
Database: "OBORCL",
|
Database: "OBORCL",
|
||||||
OceanBaseProtocol: "oracle",
|
OceanBaseProtocol: "oracle",
|
||||||
|
Timeout: 45,
|
||||||
ConnectionParams: "init=ALTER+SESSION+SET+NLS_DATE_FORMAT%3D%27YYYY-MM-DD%27&timeout=10s",
|
ConnectionParams: "init=ALTER+SESSION+SET+NLS_DATE_FORMAT%3D%27YYYY-MM-DD%27&timeout=10s",
|
||||||
}
|
}
|
||||||
runConfig := normalizeRunConfig(config, "sbdev")
|
runConfig := normalizeRunConfig(config, "sbdev")
|
||||||
|
|
||||||
|
if runConfig.Timeout != 45 {
|
||||||
|
t.Fatalf("expected explicit timeout to be preserved, got %d", runConfig.Timeout)
|
||||||
|
}
|
||||||
values, err := url.ParseQuery(runConfig.ConnectionParams)
|
values, err := url.ParseQuery(runConfig.ConnectionParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected connection params parse error: %v", err)
|
t.Fatalf("unexpected connection params parse error: %v", err)
|
||||||
|
|||||||
@@ -417,9 +417,16 @@ func buildObjectSequenceMetadataQueries(dbType string, dbName string) []objectMe
|
|||||||
switch dbType {
|
switch dbType {
|
||||||
case "oracle", "dameng":
|
case "oracle", "dameng":
|
||||||
if strings.TrimSpace(dbName) == "" {
|
if strings.TrimSpace(dbName) == "" {
|
||||||
return []objectMetadataQuerySpec{{sql: "SELECT SEQUENCE_NAME AS sequence_name FROM USER_SEQUENCES ORDER BY SEQUENCE_NAME"}}
|
return []objectMetadataQuerySpec{
|
||||||
|
{sql: "SELECT * FROM USER_SEQUENCES ORDER BY SEQUENCE_NAME"},
|
||||||
|
{sql: "SELECT OBJECT_NAME AS sequence_name FROM USER_OBJECTS WHERE OBJECT_TYPE = 'SEQUENCE' ORDER BY OBJECT_NAME"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
owner := strings.ToUpper(safeDbName)
|
||||||
|
return []objectMetadataQuerySpec{
|
||||||
|
{sql: fmt.Sprintf("SELECT * FROM ALL_SEQUENCES WHERE SEQUENCE_OWNER = '%s' ORDER BY SEQUENCE_NAME", owner)},
|
||||||
|
{sql: fmt.Sprintf("SELECT OWNER AS schema_name, OBJECT_NAME AS sequence_name FROM ALL_OBJECTS WHERE OWNER = '%s' AND OBJECT_TYPE = 'SEQUENCE' ORDER BY OBJECT_NAME", owner)},
|
||||||
}
|
}
|
||||||
return []objectMetadataQuerySpec{{sql: fmt.Sprintf("SELECT SEQUENCE_OWNER AS schema_name, SEQUENCE_NAME AS sequence_name FROM ALL_SEQUENCES WHERE SEQUENCE_OWNER = '%s' ORDER BY SEQUENCE_NAME", strings.ToUpper(safeDbName))}}
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
39
internal/app/methods_db_objects_sequence_test.go
Normal file
39
internal/app/methods_db_objects_sequence_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildObjectSequenceMetadataQueriesOracleUsesWildcardAndObjectFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
specs := buildObjectSequenceMetadataQueries("oracle", "sbdev")
|
||||||
|
if len(specs) != 2 {
|
||||||
|
t.Fatalf("expected 2 Oracle sequence metadata queries, got %d", len(specs))
|
||||||
|
}
|
||||||
|
if !strings.Contains(specs[0].sql, "SELECT * FROM ALL_SEQUENCES") {
|
||||||
|
t.Fatalf("expected ALL_SEQUENCES wildcard query, got %q", specs[0].sql)
|
||||||
|
}
|
||||||
|
if strings.Contains(specs[0].sql, "LAST_NUMBER") || strings.Contains(specs[0].sql, "ORDER_FLAG") {
|
||||||
|
t.Fatalf("query should not enumerate optional sequence columns: %q", specs[0].sql)
|
||||||
|
}
|
||||||
|
if !strings.Contains(specs[1].sql, "ALL_OBJECTS") || !strings.Contains(specs[1].sql, "OBJECT_TYPE = 'SEQUENCE'") {
|
||||||
|
t.Fatalf("expected ALL_OBJECTS fallback query, got %q", specs[1].sql)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildObjectSequenceMetadataQueriesOracleUserFallbacks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
specs := buildObjectSequenceMetadataQueries("oracle", "")
|
||||||
|
if len(specs) != 2 {
|
||||||
|
t.Fatalf("expected 2 Oracle user sequence metadata queries, got %d", len(specs))
|
||||||
|
}
|
||||||
|
if specs[0].sql != "SELECT * FROM USER_SEQUENCES ORDER BY SEQUENCE_NAME" {
|
||||||
|
t.Fatalf("unexpected USER_SEQUENCES query: %q", specs[0].sql)
|
||||||
|
}
|
||||||
|
if !strings.Contains(specs[1].sql, "USER_OBJECTS") || !strings.Contains(specs[1].sql, "OBJECT_TYPE = 'SEQUENCE'") {
|
||||||
|
t.Fatalf("expected USER_OBJECTS fallback query, got %q", specs[1].sql)
|
||||||
|
}
|
||||||
|
}
|
||||||
363
internal/app/update_cleanup.go
Normal file
363
internal/app/update_cleanup.go
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
stdRuntime "runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
updateLaunchInstallScript = launchUpdateScriptWithCleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func launchUpdateScriptWithCleanup(staged *stagedUpdate) error {
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exePath, _ = filepath.EvalSymlinks(exePath)
|
||||||
|
pid := os.Getpid()
|
||||||
|
|
||||||
|
if stdRuntime.GOOS == "windows" {
|
||||||
|
return launchWindowsUpdateWithCleanup(staged, exePath, pid)
|
||||||
|
}
|
||||||
|
return launchUpdateScript(staged)
|
||||||
|
}
|
||||||
|
|
||||||
|
func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid int) error {
|
||||||
|
if staged == nil {
|
||||||
|
return localizedUpdateError{key: "app.update.backend.message.no_downloaded_package"}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(staged.StagedDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTargetExe := strings.TrimSpace(targetExe)
|
||||||
|
originalSourceDir := strings.TrimSpace(filepath.Dir(staged.FilePath))
|
||||||
|
preparedSource, err := prepareWindowsStagedUpdateAsset(staged.FilePath, staged.StagedDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
staged.FilePath = preparedSource
|
||||||
|
staged.InstallLogPath = buildUpdateInstallLogPath(staged.StagedDir)
|
||||||
|
finalTargetExe := resolveWindowsUpdateFinalTargetPath(currentTargetExe, staged.FilePath)
|
||||||
|
|
||||||
|
cleanupWindowsUpdateArtifacts([]string{
|
||||||
|
originalSourceDir,
|
||||||
|
strings.TrimSpace(filepath.Dir(currentTargetExe)),
|
||||||
|
strings.TrimSpace(filepath.Dir(finalTargetExe)),
|
||||||
|
}, map[string]struct{}{
|
||||||
|
cleanComparablePath(currentTargetExe): {},
|
||||||
|
cleanComparablePath(finalTargetExe): {},
|
||||||
|
cleanComparablePath(staged.FilePath): {},
|
||||||
|
cleanComparablePath(staged.StagedDir): {},
|
||||||
|
})
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
|
||||||
|
content := buildWindowsScriptWithCurrentTargetCleanup(staged.FilePath, finalTargetExe, currentTargetExe, staged.StagedDir, staged.InstallLogPath, pid)
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("启动 Windows 更新脚本:current=%s target=%s script=%s log=%s", currentTargetExe, finalTargetExe, scriptPath, staged.InstallLogPath)
|
||||||
|
cmd := buildWindowsLaunchCommand(scriptPath)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cmd.Process != nil {
|
||||||
|
if err := cmd.Process.Release(); err != nil {
|
||||||
|
logger.Warnf("释放 Windows 更新脚本进程句柄失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveWindowsUpdateFinalTargetPath(currentTarget string, sourcePath string) string {
|
||||||
|
currentTarget = strings.TrimSpace(currentTarget)
|
||||||
|
if currentTarget == "" {
|
||||||
|
return currentTarget
|
||||||
|
}
|
||||||
|
currentName := filepath.Base(currentTarget)
|
||||||
|
sourceName := filepath.Base(strings.TrimSpace(sourcePath))
|
||||||
|
if isVersionedWindowsUpdatePackageName(currentName) && isVersionedWindowsUpdatePackageName(sourceName) {
|
||||||
|
return filepath.Join(filepath.Dir(currentTarget), sourceName)
|
||||||
|
}
|
||||||
|
return currentTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVersionedWindowsUpdatePackageName(name string) bool {
|
||||||
|
trimmed := strings.TrimSpace(name)
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
return strings.HasPrefix(trimmed, "GoNavi-") &&
|
||||||
|
strings.Contains(trimmed, "-Windows-") &&
|
||||||
|
strings.HasSuffix(lower, ".exe")
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareWindowsStagedUpdateAsset(sourcePath string, stagedDir string) (string, error) {
|
||||||
|
sourcePath = strings.TrimSpace(sourcePath)
|
||||||
|
stagedDir = strings.TrimSpace(stagedDir)
|
||||||
|
if sourcePath == "" || stagedDir == "" {
|
||||||
|
return sourcePath, nil
|
||||||
|
}
|
||||||
|
if isUpdateAssetPathInsideStagedDir(sourcePath, stagedDir) {
|
||||||
|
return sourcePath, nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
targetPath := filepath.Join(stagedDir, filepath.Base(sourcePath))
|
||||||
|
if cleanComparablePath(sourcePath) == cleanComparablePath(targetPath) {
|
||||||
|
return sourcePath, nil
|
||||||
|
}
|
||||||
|
_ = os.Remove(targetPath)
|
||||||
|
if err := os.Rename(sourcePath, targetPath); err == nil {
|
||||||
|
return targetPath, nil
|
||||||
|
}
|
||||||
|
if err := copyFileForWindowsUpdate(sourcePath, targetPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = os.Remove(sourcePath)
|
||||||
|
return targetPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFileForWindowsUpdate(sourcePath string, targetPath string) error {
|
||||||
|
in, err := os.Open(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, copyErr := io.Copy(out, in)
|
||||||
|
closeErr := out.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupWindowsUpdateArtifacts(dirs []string, keep map[string]struct{}) {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, dir := range dirs {
|
||||||
|
dir = strings.TrimSpace(dir)
|
||||||
|
if dir == "" || dir == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleanDir := cleanComparablePath(dir)
|
||||||
|
if cleanDir == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[cleanDir]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[cleanDir] = struct{}{}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
path := filepath.Join(dir, entry.Name())
|
||||||
|
cleanPath := cleanComparablePath(path)
|
||||||
|
if cleanPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := keep[cleanPath]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if shouldRemoveWindowsUpdateArtifact(entry.Name(), entry.IsDir()) {
|
||||||
|
if entry.IsDir() {
|
||||||
|
_ = os.RemoveAll(path)
|
||||||
|
} else {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRemoveWindowsUpdateArtifact(name string, isDir bool) bool {
|
||||||
|
trimmed := strings.TrimSpace(name)
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isDir {
|
||||||
|
return strings.HasPrefix(lower, ".gonavi-update-")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(lower, "gonavi-update-") && strings.HasSuffix(lower, ".log") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(trimmed, "GoNavi-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.Contains(trimmed, "-Windows-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanComparablePath(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cleaned := filepath.Clean(path)
|
||||||
|
if cleaned == "." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if stdRuntime.GOOS == "windows" {
|
||||||
|
return strings.ToLower(cleaned)
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWindowsScriptWithCleanup(source, target, stagedDir, logPath string, pid int) string {
|
||||||
|
return buildWindowsScriptWithCurrentTargetCleanup(source, target, target, stagedDir, logPath, pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWindowsScriptWithCurrentTargetCleanup(source, target, currentTarget, stagedDir, logPath string, pid int) string {
|
||||||
|
script := `@echo off
|
||||||
|
setlocal EnableExtensions EnableDelayedExpansion
|
||||||
|
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
|
||||||
|
set "TARGET=__GONAVI_UPDATE_TARGET__"
|
||||||
|
set "CURRENT_TARGET=__GONAVI_CURRENT_TARGET__"
|
||||||
|
set "TARGET_OLD=%TARGET%.old"
|
||||||
|
set "STAGED=__GONAVI_UPDATE_STAGED__"
|
||||||
|
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
|
||||||
|
set PID=__GONAVI_UPDATE_PID__
|
||||||
|
set /a WAIT_PID_SECONDS=0
|
||||||
|
|
||||||
|
call :log updater started
|
||||||
|
if not exist "%SOURCE%" (
|
||||||
|
call :log source file not found: %SOURCE%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
|
||||||
|
for %%I in ("%TARGET%") do set "TARGET_DIR=%%~dpI"
|
||||||
|
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
|
||||||
|
set "SOURCE_EXE="
|
||||||
|
|
||||||
|
if /I "%SOURCE_EXT%"==".zip" (
|
||||||
|
set "EXTRACT_DIR=%STAGED%\_extract"
|
||||||
|
if exist "%EXTRACT_DIR%" (
|
||||||
|
rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
|
||||||
|
)
|
||||||
|
mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
|
||||||
|
if !ERRORLEVEL! NEQ 0 (
|
||||||
|
call :log expand zip failed: %SOURCE%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
|
||||||
|
set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
|
||||||
|
) else (
|
||||||
|
for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
|
||||||
|
if not defined SOURCE_EXE (
|
||||||
|
set "SOURCE_EXE=%%~fF"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not defined SOURCE_EXE (
|
||||||
|
call :log no executable found in portable zip: %SOURCE%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
set "SOURCE_EXE=%SOURCE%"
|
||||||
|
)
|
||||||
|
|
||||||
|
:waitloop
|
||||||
|
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
|
||||||
|
if %ERRORLEVEL%==0 (
|
||||||
|
if !WAIT_PID_SECONDS! GEQ 90 (
|
||||||
|
call :log host process still running after !WAIT_PID_SECONDS! seconds, aborting update
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
timeout /t 1 /nobreak >nul
|
||||||
|
set /a WAIT_PID_SECONDS+=1
|
||||||
|
goto waitloop
|
||||||
|
)
|
||||||
|
call :log host process exited
|
||||||
|
|
||||||
|
timeout /t 3 /nobreak >nul
|
||||||
|
call :log cooldown finished, starting file replace
|
||||||
|
|
||||||
|
:replace_binary
|
||||||
|
if /I "%SOURCE_EXE%"=="%TARGET%" (
|
||||||
|
call :log downloaded executable already at target path, skip replace
|
||||||
|
goto move_done
|
||||||
|
)
|
||||||
|
set /a RETRY=0
|
||||||
|
:move_retry
|
||||||
|
call :log attempt !RETRY!: trying rename-then-copy strategy
|
||||||
|
move /Y "%TARGET%" "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
|
||||||
|
if !ERRORLEVEL!==0 (
|
||||||
|
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||||
|
if !ERRORLEVEL!==0 (
|
||||||
|
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
|
||||||
|
goto move_done
|
||||||
|
)
|
||||||
|
call :log copy after rename failed, restoring old file
|
||||||
|
move /Y "%TARGET_OLD%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||||
|
)
|
||||||
|
|
||||||
|
call :log rename strategy failed, trying direct move
|
||||||
|
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||||
|
if %ERRORLEVEL%==0 goto move_done
|
||||||
|
|
||||||
|
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||||
|
if %ERRORLEVEL%==0 goto move_done
|
||||||
|
|
||||||
|
set /a RETRY+=1
|
||||||
|
if !RETRY! LSS 15 (
|
||||||
|
set /a WAIT=1
|
||||||
|
if !RETRY! GEQ 3 set /a WAIT=2
|
||||||
|
if !RETRY! GEQ 6 set /a WAIT=3
|
||||||
|
if !RETRY! GEQ 9 set /a WAIT=5
|
||||||
|
call :log waiting !WAIT! seconds before retry
|
||||||
|
timeout /t !WAIT! /nobreak >nul
|
||||||
|
goto move_retry
|
||||||
|
)
|
||||||
|
|
||||||
|
call :log replace failed after retries (portable mode, no elevation): check directory write permission or file lock
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:move_done
|
||||||
|
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
|
||||||
|
if /I not "%CURRENT_TARGET%"=="%TARGET%" (
|
||||||
|
if exist "%CURRENT_TARGET%" del /F /Q "%CURRENT_TARGET%" >> "%LOG_FILE%" 2>&1
|
||||||
|
)
|
||||||
|
if exist "%SOURCE%" del /F /Q "%SOURCE%" >> "%LOG_FILE%" 2>&1
|
||||||
|
start "" /D "%TARGET_DIR%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
call :log cmd start failed, trying powershell Start-Process
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%' -WorkingDirectory '%TARGET_DIR%'" >> "%LOG_FILE%" 2>&1
|
||||||
|
if !ERRORLEVEL! NEQ 0 (
|
||||||
|
call :log relaunch failed
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
call :log update finished
|
||||||
|
start "" /MIN cmd.exe /D /C "timeout /t 2 /nobreak >nul & del /F /Q ""%LOG_FILE%"" >nul 2>&1 & rmdir /S /Q ""%STAGED%"" >nul 2>&1"
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:log
|
||||||
|
echo [%date% %time%] %*>>"%LOG_FILE%"
|
||||||
|
exit /b 0
|
||||||
|
`
|
||||||
|
return strings.NewReplacer(
|
||||||
|
"__GONAVI_UPDATE_SOURCE__", source,
|
||||||
|
"__GONAVI_UPDATE_TARGET__", target,
|
||||||
|
"__GONAVI_CURRENT_TARGET__", currentTarget,
|
||||||
|
"__GONAVI_UPDATE_STAGED__", stagedDir,
|
||||||
|
"__GONAVI_UPDATE_LOG__", logPath,
|
||||||
|
"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
|
||||||
|
).Replace(strings.ReplaceAll(script, "\n", "\r\n"))
|
||||||
|
}
|
||||||
113
internal/app/update_cleanup_test.go
Normal file
113
internal/app/update_cleanup_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldRemoveWindowsUpdateArtifact(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
isDir bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "GoNavi-dev-abc-Windows-Amd64.exe", want: true},
|
||||||
|
{name: "GoNavi-0.8.4-Windows-Amd64.zip", want: true},
|
||||||
|
{name: "gonavi-update-windows-123.log", want: true},
|
||||||
|
{name: ".gonavi-update-windows-dev-dev-abc", isDir: true, want: true},
|
||||||
|
{name: "GoNavi-dev-abc-MacOS-Arm64.dmg", want: false},
|
||||||
|
{name: "GoNavi.exe", want: false},
|
||||||
|
{name: "notes.log", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := shouldRemoveWindowsUpdateArtifact(tc.name, tc.isDir); got != tc.want {
|
||||||
|
t.Fatalf("shouldRemoveWindowsUpdateArtifact(%q, %v) = %v, want %v", tc.name, tc.isDir, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupWindowsUpdateArtifactsKeepsCurrentTargetAndRemovesStalePackages(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
currentTarget := filepath.Join(dir, "GoNavi-dev-current-Windows-Amd64.exe")
|
||||||
|
currentPackage := filepath.Join(dir, "GoNavi-dev-new-Windows-Amd64.exe")
|
||||||
|
stalePackage := filepath.Join(dir, "GoNavi-dev-old-Windows-Amd64.exe")
|
||||||
|
staleLog := filepath.Join(dir, "gonavi-update-windows-123.log")
|
||||||
|
staleStage := filepath.Join(dir, ".gonavi-update-windows-dev-old")
|
||||||
|
otherFile := filepath.Join(dir, "notes.txt")
|
||||||
|
|
||||||
|
for _, path := range []string{currentTarget, currentPackage, stalePackage, staleLog, otherFile} {
|
||||||
|
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q) returned error: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(staleStage, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupWindowsUpdateArtifacts([]string{dir}, map[string]struct{}{
|
||||||
|
cleanComparablePath(currentTarget): {},
|
||||||
|
cleanComparablePath(currentPackage): {},
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, path := range []string{currentTarget, currentPackage, otherFile} {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("expected %q to remain: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{stalePackage, staleLog, staleStage} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected %q to be removed, stat err=%v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareWindowsStagedUpdateAssetMovesPackageIntoStagedDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
stagedDir := filepath.Join(dir, ".gonavi-update-windows-dev-new")
|
||||||
|
source := filepath.Join(dir, "GoNavi-dev-new-Windows-Amd64.exe")
|
||||||
|
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := prepareWindowsStagedUpdateAsset(source, stagedDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepareWindowsStagedUpdateAsset returned error: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(stagedDir, filepath.Base(source))
|
||||||
|
if prepared != want {
|
||||||
|
t.Fatalf("expected prepared path %q, got %q", want, prepared)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(source); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected source to be moved away, stat err=%v", err)
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(prepared); err != nil || string(data) != "payload" {
|
||||||
|
t.Fatalf("expected payload in staged file, data=%q err=%v", string(data), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWindowsScriptWithCleanupRemovesLogAndStagedDirectoryAfterSuccess(t *testing.T) {
|
||||||
|
script := buildWindowsScriptWithCleanup(
|
||||||
|
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new\GoNavi-dev-new-Windows-Amd64.exe`,
|
||||||
|
`C:\Users\tester\Desktop\GoNavi-dev-current-Windows-Amd64.exe`,
|
||||||
|
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new`,
|
||||||
|
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new\gonavi-update-windows-123.log`,
|
||||||
|
12345,
|
||||||
|
)
|
||||||
|
|
||||||
|
mustContain := []string{
|
||||||
|
`if exist "%SOURCE%" del /F /Q "%SOURCE%"`,
|
||||||
|
`del /F /Q ""%LOG_FILE%""`,
|
||||||
|
`rmdir /S /Q ""%STAGED%""`,
|
||||||
|
}
|
||||||
|
for _, token := range mustContain {
|
||||||
|
if !strings.Contains(script, token) {
|
||||||
|
t.Fatalf("expected script to contain %q\n%s", token, script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(script, `rmdir /S /Q "%STAGED%" >> "%LOG_FILE%"`) {
|
||||||
|
t.Fatalf("script should not synchronously remove staged dir while logging to it\n%s", script)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ func configureSQLConnectionPool(db *sql.DB, dbType string) {
|
|||||||
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||||
case "sqlite", "duckdb":
|
case "sqlite", "duckdb":
|
||||||
return
|
return
|
||||||
|
case "oracle", "oceanbase":
|
||||||
|
db.SetMaxOpenConns(defaultSQLMaxOpenConns)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxIdleTime(defaultSQLConnMaxIdleTime)
|
||||||
|
db.SetConnMaxLifetime(defaultSQLConnMaxLifetime)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
db.SetMaxOpenConns(defaultSQLMaxOpenConns)
|
db.SetMaxOpenConns(defaultSQLMaxOpenConns)
|
||||||
db.SetMaxIdleConns(0)
|
db.SetMaxIdleConns(0)
|
||||||
|
|||||||
Reference in New Issue
Block a user