Web console: Fix maxRowsPerSegment validation in hashed compaction spec (#11308)

* allow defining of maxRowsPerSegment for now

* use common util

* update snapshots

* fix test

* fix e2e test
This commit is contained in:
Vadim Ogievetsky 2021-05-27 16:36:42 -07:00 committed by GitHub
parent e5633d7842
commit 31c811d894
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 194 additions and 41 deletions

View File

@ -57,7 +57,11 @@ export class HashedPartitionsSpec implements PartitionsSpec {
readonly type: string; readonly type: string;
static async read(page: playwright.Page): Promise<HashedPartitionsSpec> { static async read(page: playwright.Page): Promise<HashedPartitionsSpec> {
const numShards = await getLabeledInputAsNumber(page, HashedPartitionsSpec.NUM_SHARDS); // The shards control may not be visible in that case this is not an error, it is simply not set (null)
let numShards: number | null = null;
try {
numShards = await getLabeledInputAsNumber(page, HashedPartitionsSpec.NUM_SHARDS);
} catch {}
return new HashedPartitionsSpec({ numShards }); return new HashedPartitionsSpec({ numShards });
} }

View File

@ -50,7 +50,8 @@ describe('AutoForm', () => {
}, },
{ name: 'testNotDefined', type: 'string', defined: false }, { name: 'testNotDefined', type: 'string', defined: false },
{ name: 'testAdvanced', type: 'string', hideInMore: true }, { name: 'testHide', type: 'string', hide: true },
{ name: 'testHideInMore', type: 'string', hideInMore: true },
]} ]}
model={String} model={String}
onChange={() => {}} onChange={() => {}}

View File

@ -56,6 +56,7 @@ export interface Field<M> {
disabled?: Functor<M, boolean>; disabled?: Functor<M, boolean>;
defined?: Functor<M, boolean>; defined?: Functor<M, boolean>;
required?: Functor<M, boolean>; required?: Functor<M, boolean>;
hide?: Functor<M, boolean>;
hideInMore?: Functor<M, boolean>; hideInMore?: Functor<M, boolean>;
valueAdjustment?: (value: any) => any; valueAdjustment?: (value: any) => any;
adjustment?: (model: M) => M; adjustment?: (model: M) => M;
@ -456,10 +457,15 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
let shouldShowMore = false; let shouldShowMore = false;
const shownFields = fields.filter(field => { const shownFields = fields.filter(field => {
if (AutoForm.evaluateFunctor(field.defined, model, true)) { if (AutoForm.evaluateFunctor(field.defined, model, true)) {
if (AutoForm.evaluateFunctor(field.hide, model, false)) {
return false;
}
if (AutoForm.evaluateFunctor(field.hideInMore, model, false)) { if (AutoForm.evaluateFunctor(field.hideInMore, model, false)) {
shouldShowMore = true; shouldShowMore = true;
return showMore; return showMore;
} }
return true; return true;
} else { } else {
return false; return false;

View File

@ -67,6 +67,7 @@ interface InternalValue {
interface JsonInputProps { interface JsonInputProps {
value: any; value: any;
onChange: (value: any) => void; onChange: (value: any) => void;
onError?: (error: Error) => void;
placeholder?: string; placeholder?: string;
focus?: boolean; focus?: boolean;
width?: string; width?: string;
@ -75,7 +76,7 @@ interface JsonInputProps {
} }
export const JsonInput = React.memo(function JsonInput(props: JsonInputProps) { export const JsonInput = React.memo(function JsonInput(props: JsonInputProps) {
const { onChange, placeholder, focus, width, height, value, issueWithValue } = props; const { onChange, onError, placeholder, focus, width, height, value, issueWithValue } = props;
const [internalValue, setInternalValue] = useState<InternalValue>(() => ({ const [internalValue, setInternalValue] = useState<InternalValue>(() => ({
value, value,
stringified: stringifyJson(value), stringified: stringifyJson(value),
@ -120,7 +121,9 @@ export const JsonInput = React.memo(function JsonInput(props: JsonInputProps) {
stringified: inputJson, stringified: inputJson,
}); });
if (!error) { if (error) {
onError?.(error);
} else {
onChange(value); onChange(value);
} }

View File

@ -86,6 +86,28 @@ exports[`CompactionDialog matches snapshot with compactionConfig (dynamic partit
</p> </p>
</React.Fragment>, </React.Fragment>,
"name": "tuningConfig.partitionsSpec.targetRowsPerSegment", "name": "tuningConfig.partitionsSpec.targetRowsPerSegment",
"placeholder": "(defaults to 500000)",
"type": "number",
"zeroMeansUndefined": true,
},
Object {
"defined": [Function],
"info": <React.Fragment>
<p>
Target number of rows to include in a partition, should be a number that targets segments of 500MB~1GB.
</p>
<p>
<Unknown>
maxRowsPerSegment
</Unknown>
is an alias for
<Unknown>
targetRowsPerSegment
</Unknown>
. Only one of these properties can be used.
</p>
</React.Fragment>,
"name": "tuningConfig.partitionsSpec.maxRowsPerSegment",
"type": "number", "type": "number",
"zeroMeansUndefined": true, "zeroMeansUndefined": true,
}, },
@ -335,6 +357,28 @@ exports[`CompactionDialog matches snapshot with compactionConfig (hashed partiti
</p> </p>
</React.Fragment>, </React.Fragment>,
"name": "tuningConfig.partitionsSpec.targetRowsPerSegment", "name": "tuningConfig.partitionsSpec.targetRowsPerSegment",
"placeholder": "(defaults to 500000)",
"type": "number",
"zeroMeansUndefined": true,
},
Object {
"defined": [Function],
"info": <React.Fragment>
<p>
Target number of rows to include in a partition, should be a number that targets segments of 500MB~1GB.
</p>
<p>
<Unknown>
maxRowsPerSegment
</Unknown>
is an alias for
<Unknown>
targetRowsPerSegment
</Unknown>
. Only one of these properties can be used.
</p>
</React.Fragment>,
"name": "tuningConfig.partitionsSpec.maxRowsPerSegment",
"type": "number", "type": "number",
"zeroMeansUndefined": true, "zeroMeansUndefined": true,
}, },
@ -584,6 +628,28 @@ exports[`CompactionDialog matches snapshot with compactionConfig (single_dim par
</p> </p>
</React.Fragment>, </React.Fragment>,
"name": "tuningConfig.partitionsSpec.targetRowsPerSegment", "name": "tuningConfig.partitionsSpec.targetRowsPerSegment",
"placeholder": "(defaults to 500000)",
"type": "number",
"zeroMeansUndefined": true,
},
Object {
"defined": [Function],
"info": <React.Fragment>
<p>
Target number of rows to include in a partition, should be a number that targets segments of 500MB~1GB.
</p>
<p>
<Unknown>
maxRowsPerSegment
</Unknown>
is an alias for
<Unknown>
targetRowsPerSegment
</Unknown>
. Only one of these properties can be used.
</p>
</React.Fragment>,
"name": "tuningConfig.partitionsSpec.maxRowsPerSegment",
"type": "number", "type": "number",
"zeroMeansUndefined": true, "zeroMeansUndefined": true,
}, },
@ -833,6 +899,28 @@ exports[`CompactionDialog matches snapshot without compactionConfig 1`] = `
</p> </p>
</React.Fragment>, </React.Fragment>,
"name": "tuningConfig.partitionsSpec.targetRowsPerSegment", "name": "tuningConfig.partitionsSpec.targetRowsPerSegment",
"placeholder": "(defaults to 500000)",
"type": "number",
"zeroMeansUndefined": true,
},
Object {
"defined": [Function],
"info": <React.Fragment>
<p>
Target number of rows to include in a partition, should be a number that targets segments of 500MB~1GB.
</p>
<p>
<Unknown>
maxRowsPerSegment
</Unknown>
is an alias for
<Unknown>
targetRowsPerSegment
</Unknown>
. Only one of these properties can be used.
</p>
</React.Fragment>,
"name": "tuningConfig.partitionsSpec.maxRowsPerSegment",
"type": "number", "type": "number",
"zeroMeansUndefined": true, "zeroMeansUndefined": true,
}, },

View File

@ -42,12 +42,10 @@ export const CompactionDialog = React.memo(function CompactionDialog(props: Comp
tuningConfig: { partitionsSpec: { type: 'dynamic' } }, tuningConfig: { partitionsSpec: { type: 'dynamic' } },
}, },
); );
const [jsonError, setJsonError] = useState<Error | undefined>();
const issueWithCurrentConfig = AutoForm.issueWithModel(currentConfig, COMPACTION_CONFIG_FIELDS); const issueWithCurrentConfig = AutoForm.issueWithModel(currentConfig, COMPACTION_CONFIG_FIELDS);
function handleSubmit() { const disableSubmit = Boolean(jsonError || issueWithCurrentConfig);
if (issueWithCurrentConfig) return;
onSave(currentConfig);
}
return ( return (
<Dialog <Dialog
@ -68,7 +66,11 @@ export const CompactionDialog = React.memo(function CompactionDialog(props: Comp
) : ( ) : (
<JsonInput <JsonInput
value={currentConfig} value={currentConfig}
onChange={setCurrentConfig} onChange={v => {
setCurrentConfig(v);
setJsonError(undefined);
}}
onError={setJsonError}
issueWithValue={value => AutoForm.issueWithModel(value, COMPACTION_CONFIG_FIELDS)} issueWithValue={value => AutoForm.issueWithModel(value, COMPACTION_CONFIG_FIELDS)}
height="100%" height="100%"
/> />
@ -81,8 +83,8 @@ export const CompactionDialog = React.memo(function CompactionDialog(props: Comp
<Button <Button
text="Submit" text="Submit"
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
onClick={handleSubmit} disabled={disableSubmit}
disabled={Boolean(issueWithCurrentConfig)} onClick={() => onSave(currentConfig)}
/> />
</div> </div>
</div> </div>

View File

@ -5,6 +5,7 @@ exports[`coordinator dynamic config matches snapshot 1`] = `
className="coordinator-dynamic-config-dialog" className="coordinator-dynamic-config-dialog"
onClose={[Function]} onClose={[Function]}
onSave={[Function]} onSave={[Function]}
saveDisabled={false}
title="Coordinator dynamic config" title="Coordinator dynamic config"
> >
<p> <p>

View File

@ -46,6 +46,7 @@ export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDyn
const { onClose } = props; const { onClose } = props;
const [currentTab, setCurrentTab] = useState<FormJsonTabs>('form'); const [currentTab, setCurrentTab] = useState<FormJsonTabs>('form');
const [dynamicConfig, setDynamicConfig] = useState<CoordinatorDynamicConfig>({}); const [dynamicConfig, setDynamicConfig] = useState<CoordinatorDynamicConfig>({});
const [jsonError, setJsonError] = useState<Error | undefined>();
const [historyRecordsState] = useQueryManager<null, any[]>({ const [historyRecordsState] = useQueryManager<null, any[]>({
processQuery: async () => { processQuery: async () => {
@ -100,6 +101,7 @@ export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDyn
return ( return (
<SnitchDialog <SnitchDialog
className="coordinator-dynamic-config-dialog" className="coordinator-dynamic-config-dialog"
saveDisabled={Boolean(jsonError)}
onSave={saveConfig} onSave={saveConfig}
onClose={onClose} onClose={onClose}
title="Coordinator dynamic config" title="Coordinator dynamic config"
@ -121,7 +123,15 @@ export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDyn
onChange={setDynamicConfig} onChange={setDynamicConfig}
/> />
) : ( ) : (
<JsonInput value={dynamicConfig} onChange={setDynamicConfig} height="50vh" /> <JsonInput
value={dynamicConfig}
height="50vh"
onChange={v => {
setDynamicConfig(v);
setJsonError(undefined);
}}
onError={setJsonError}
/>
)} )}
</SnitchDialog> </SnitchDialog>
); );

View File

@ -58,6 +58,11 @@ export const LookupEditDialog = React.memo(function LookupEditDialog(props: Look
} = props; } = props;
const [currentTab, setCurrentTab] = useState<FormJsonTabs>('form'); const [currentTab, setCurrentTab] = useState<FormJsonTabs>('form');
const [updateVersionOnSubmit, setUpdateVersionOnSubmit] = useState(true); const [updateVersionOnSubmit, setUpdateVersionOnSubmit] = useState(true);
const [jsonError, setJsonError] = useState<Error | undefined>();
const disableSubmit = Boolean(
jsonError || isLookupInvalid(lookupName, lookupVersion, lookupTier, lookupSpec),
);
return ( return (
<Dialog <Dialog
@ -122,10 +127,12 @@ export const LookupEditDialog = React.memo(function LookupEditDialog(props: Look
) : ( ) : (
<JsonInput <JsonInput
value={lookupSpec} value={lookupSpec}
height="80vh"
onChange={m => { onChange={m => {
onChange('spec', m); onChange('spec', m);
setJsonError(undefined);
}} }}
height="80vh" onError={setJsonError}
/> />
)} )}
</div> </div>
@ -135,10 +142,10 @@ export const LookupEditDialog = React.memo(function LookupEditDialog(props: Look
<Button <Button
text="Submit" text="Submit"
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
disabled={disableSubmit}
onClick={() => { onClick={() => {
onSubmit(updateVersionOnSubmit && isEdit); onSubmit(updateVersionOnSubmit && isEdit);
}} }}
disabled={isLookupInvalid(lookupName, lookupVersion, lookupTier, lookupSpec)}
/> />
</div> </div>
</div> </div>

View File

@ -70,9 +70,11 @@ export const COMPACTION_CONFIG_FIELDS: Field<CompactionConfig>[] = [
name: 'tuningConfig.partitionsSpec.targetRowsPerSegment', name: 'tuningConfig.partitionsSpec.targetRowsPerSegment',
type: 'number', type: 'number',
zeroMeansUndefined: true, zeroMeansUndefined: true,
placeholder: `(defaults to 500000)`,
defined: (t: CompactionConfig) => defined: (t: CompactionConfig) =>
deepGet(t, 'tuningConfig.partitionsSpec.type') === 'hashed' && deepGet(t, 'tuningConfig.partitionsSpec.type') === 'hashed' &&
!deepGet(t, 'tuningConfig.partitionsSpec.numShards'), !deepGet(t, 'tuningConfig.partitionsSpec.numShards') &&
!deepGet(t, 'tuningConfig.partitionsSpec.maxRowsPerSegment'),
info: ( info: (
<> <>
<p> <p>
@ -86,12 +88,34 @@ export const COMPACTION_CONFIG_FIELDS: Field<CompactionConfig>[] = [
</> </>
), ),
}, },
{
name: 'tuningConfig.partitionsSpec.maxRowsPerSegment',
type: 'number',
zeroMeansUndefined: true,
defined: (t: CompactionConfig) =>
deepGet(t, 'tuningConfig.partitionsSpec.type') === 'hashed' &&
!deepGet(t, 'tuningConfig.partitionsSpec.numShards') &&
!deepGet(t, 'tuningConfig.partitionsSpec.targetRowsPerSegment'),
info: (
<>
<p>
Target number of rows to include in a partition, should be a number that targets segments
of 500MB~1GB.
</p>
<p>
<Code>maxRowsPerSegment</Code> is an alias for <Code>targetRowsPerSegment</Code>. Only one
of these properties can be used.
</p>
</>
),
},
{ {
name: 'tuningConfig.partitionsSpec.numShards', name: 'tuningConfig.partitionsSpec.numShards',
type: 'number', type: 'number',
zeroMeansUndefined: true, zeroMeansUndefined: true,
defined: (t: CompactionConfig) => defined: (t: CompactionConfig) =>
deepGet(t, 'tuningConfig.partitionsSpec.type') === 'hashed' && deepGet(t, 'tuningConfig.partitionsSpec.type') === 'hashed' &&
!deepGet(t, 'tuningConfig.partitionsSpec.maxRowsPerSegment') &&
!deepGet(t, 'tuningConfig.partitionsSpec.targetRowsPerSegment'), !deepGet(t, 'tuningConfig.partitionsSpec.targetRowsPerSegment'),
info: ( info: (
<> <>

View File

@ -22,6 +22,7 @@ import copy from 'copy-to-clipboard';
import { SqlExpression, SqlFunction, SqlLiteral, SqlRef } from 'druid-query-toolkit'; import { SqlExpression, SqlFunction, SqlLiteral, SqlRef } from 'druid-query-toolkit';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import hasOwnProp from 'has-own-prop'; import hasOwnProp from 'has-own-prop';
import * as JSONBig from 'json-bigint-native';
import numeral from 'numeral'; import numeral from 'numeral';
import React from 'react'; import React from 'react';
import { Filter, FilterRender } from 'react-table'; import { Filter, FilterRender } from 'react-table';
@ -382,3 +383,15 @@ export function moveElement<T>(items: readonly T[], fromIndex: number, toIndex:
return items.slice(); return items.slice();
} }
} }
export function stringifyValue(value: unknown): string {
switch (typeof value) {
case 'object':
if (!value) return String(value);
if (typeof (value as any).toISOString === 'function') return (value as any).toISOString();
return JSONBig.stringify(value);
default:
return String(value);
}
}

View File

@ -27,13 +27,12 @@ import {
SqlRef, SqlRef,
trimString, trimString,
} from 'druid-query-toolkit'; } from 'druid-query-toolkit';
import * as JSONBig from 'json-bigint-native';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import ReactTable from 'react-table'; import ReactTable from 'react-table';
import { BracedText, TableCell } from '../../../components'; import { BracedText, TableCell } from '../../../components';
import { ShowValueDialog } from '../../../dialogs/show-value-dialog/show-value-dialog'; import { ShowValueDialog } from '../../../dialogs/show-value-dialog/show-value-dialog';
import { copyAndAlert, deepSet, filterMap, prettyPrintSql } from '../../../utils'; import { copyAndAlert, deepSet, filterMap, prettyPrintSql, stringifyValue } from '../../../utils';
import { BasicAction, basicActionsToMenu } from '../../../utils/basic-action'; import { BasicAction, basicActionsToMenu } from '../../../utils/basic-action';
import { ColumnRenameInput } from './column-rename-input/column-rename-input'; import { ColumnRenameInput } from './column-rename-input/column-rename-input';
@ -44,18 +43,6 @@ function isComparable(x: unknown): boolean {
return x !== null && x !== '' && !isNaN(Number(x)); return x !== null && x !== '' && !isNaN(Number(x));
} }
function stringifyValue(value: unknown): string {
switch (typeof value) {
case 'object':
if (!value) return String(value);
if (typeof (value as any).toISOString === 'function') return (value as any).toISOString();
return JSONBig.stringify(value);
default:
return String(value);
}
}
interface Pagination { interface Pagination {
page: number; page: number;
pageSize: number; pageSize: number;

View File

@ -32,11 +32,20 @@ describe('QueryView', () => {
expect(sqlView).toMatchSnapshot(); expect(sqlView).toMatchSnapshot();
}); });
it('trimSemicolon', () => { it('.trimSemicolon', () => {
expect(QueryView.trimSemicolon('SELECT * FROM tbl;')).toEqual('SELECT * FROM tbl'); expect(QueryView.trimSemicolon('SELECT * FROM tbl;')).toEqual('SELECT * FROM tbl');
expect(QueryView.trimSemicolon('SELECT * FROM tbl; ')).toEqual('SELECT * FROM tbl '); expect(QueryView.trimSemicolon('SELECT * FROM tbl; ')).toEqual('SELECT * FROM tbl ');
expect(QueryView.trimSemicolon('SELECT * FROM tbl; --hello ')).toEqual( expect(QueryView.trimSemicolon('SELECT * FROM tbl; --hello ')).toEqual(
'SELECT * FROM tbl --hello ', 'SELECT * FROM tbl --hello ',
); );
}); });
it('.formatStr', () => {
expect(QueryView.formatStr(null, 'csv')).toEqual('"null"');
expect(QueryView.formatStr('hello\nworld', 'csv')).toEqual('"hello world"');
expect(QueryView.formatStr(123, 'csv')).toEqual('"123"');
expect(QueryView.formatStr(new Date('2021-01-02T03:04:05.678Z'), 'csv')).toEqual(
'"2021-01-02T03:04:05.678Z"',
);
});
}); });

View File

@ -48,6 +48,7 @@ import {
QueryState, QueryState,
RowColumn, RowColumn,
SemiJoinQueryExplanation, SemiJoinQueryExplanation,
stringifyValue,
} from '../../utils'; } from '../../utils';
import { isEmptyContext, QueryContext } from '../../utils/query-context'; import { isEmptyContext, QueryContext } from '../../utils/query-context';
import { QueryRecord, QueryRecordUtil } from '../../utils/query-history'; import { QueryRecord, QueryRecordUtil } from '../../utils/query-history';
@ -142,19 +143,16 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
} }
} }
static formatStr(s: string | number, format: 'csv' | 'tsv') { static formatStr(s: null | string | number | Date, format: 'csv' | 'tsv') {
// stringify and remove line break
const str = stringifyValue(s).replace(/(?:\r\n|\r|\n)/g, ' ');
if (format === 'csv') { if (format === 'csv') {
// remove line break, single quote => double quote, handle ',' // csv: single quote => double quote, handle ','
return `"${String(s) return `"${str.replace(/"/g, '""')}"`;
.replace(/(?:\r\n|\r|\n)/g, ' ')
.replace(/"/g, '""')}"`;
} else { } else {
// tsv // tsv: single quote => double quote, \t => ''
// remove line break, single quote => double quote, \t => '' return str.replace(/\t/g, '').replace(/"/g, '""');
return String(s)
.replace(/(?:\r\n|\r|\n)/g, ' ')
.replace(/\t/g, '')
.replace(/"/g, '""');
} }
} }