Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: PMM-10893 Add basic handling for postgres explain #1451

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions pmm-app/src/pmm-qan/panel/components/Details/Details.hooks.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import { useContext, useEffect, useState } from 'react';
import { QueryAnalyticsProvider } from 'pmm-qan/panel/provider/provider';
import { Databases } from 'shared/core';
import DetailsService from './Details.service';
import { DatabasesType, QueryExampleResponseItem } from './Details.types';

export const useDetails = (): [boolean, QueryExampleResponseItem[], DatabasesType] => {
export const useDetails = (): [boolean, QueryExampleResponseItem[], DatabasesType | undefined] => {
const {
panelState: {
queryId, groupBy, from, to, labels,
},
} = useContext(QueryAnalyticsProvider);
const [loading, setLoading] = useState<boolean>(false);
const [examples, setExamples] = useState<QueryExampleResponseItem[]>([]);
const [databaseType, setDatabaseType] = useState<DatabasesType>(Databases.mysql);
const [databaseType, setDatabaseType] = useState<DatabasesType>();

useEffect(() => {
(async () => {
Expand Down
2 changes: 1 addition & 1 deletion pmm-app/src/pmm-qan/panel/components/Details/Details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const DetailsSection: FC = () => {
const metadataToShow = metadata ? showMetadata(metadata) : null;
const [activeTab, changeActiveTab] = useState(TabKeys[openDetailsTab]);
const showTablesTab = databaseType !== Databases.mongodb && groupBy === 'queryid' && !totals;
const showExplainTab = databaseType !== Databases.postgresql && groupBy === 'queryid' && !totals;
const showExplainTab = groupBy === 'queryid' && !totals;
const showExamplesTab = groupBy === 'queryid' && !totals;
const showPlanTab = databaseType === Databases.postgresql && groupBy === 'queryid' && !totals;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ReactJSON } from 'shared/components/Elements/ReactJSON/ReactJSON';
import { Databases, logger } from 'shared/core';
import { Highlight } from 'shared/components/Hightlight/Highlight';
import ParseError from './ParseError/ParseError';
import { QueryExampleResponseItem } from '../Details.types';

export const getExample = (databaseType) => (example: any): any => {
if (databaseType === Databases.mongodb) {
Expand All @@ -22,3 +23,6 @@ export const getExample = (databaseType) => (example: any): any => {
</Highlight>
);
};

export const extractExample = (example: QueryExampleResponseItem) => example.example
|| example.explain_fingerprint;
12 changes: 4 additions & 8 deletions pmm-app/src/pmm-qan/panel/components/Details/Example/Example.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import React, { FC } from 'react';
import { Overlay } from 'shared/components/Elements/Overlay/Overlay';
import { getExample } from './Example.tools';
import { extractExample, getExample } from './Example.tools';
import { ExampleInterface } from './Example.types';
import { Messages } from '../Details.messages';
import { OVERLAY_LOADER_SIZE } from '../Details.constants';

const Example: FC<ExampleInterface> = ({ databaseType, examples, loading }) => {
const isExample = examples && examples.filter((example) => example.example).length;
const isExample = examples && examples.filter(extractExample).length;
const examplesList = examples.map(extractExample).filter(Boolean).map(getExample(databaseType));

return (
<Overlay isPending={loading} size={OVERLAY_LOADER_SIZE}>
{isExample && !loading
? examples
.filter(({ example }) => example)
.map((example) => example.example)
.map(getExample(databaseType))
: null}
{isExample && !loading ? examplesList : null}
{!isExample ? <pre>{Messages.noExamplesFound}</pre> : null}
</Overlay>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { logger } from 'shared/core/logger';
import { ActionResult, getActionResult } from 'shared/components/Actions';
import { Databases } from 'shared/core';
import { mongodbMethods, mysqlMethods } from '../database-models';
import { mongodbMethods, mysqlMethods, postgresqlMethods } from '../database-models';
import { DatabasesType, QueryExampleResponseItem } from '../Details.types';
import { ClassicExplainInterface, FetchExplainsResult } from './Explain.types';

Expand All @@ -23,15 +23,17 @@ export const processClassicExplain = (classic): ClassicExplainInterface => {
.filter(Boolean)
.map((title) => ({ Header: title, key: title, accessor: title }));

const rowsList = data.map((item) => item
.split('|')
.map((e) => (String(e) ? e.trim() : ''))
.filter(Boolean)
.reduce((acc, row, index) => {
acc[headerList[index].accessor] = row;
const rowsList = data.map((item) =>
item
.split('|')
.map((e) => (String(e) ? e.trim() : ''))
.filter(Boolean)
.reduce((acc, row, index) => {
acc[headerList[index].accessor] = row;

return acc;
}, {}));
return acc;
}, {}),
);

return { columns: headerList, rows: rowsList };
};
Expand Down Expand Up @@ -72,11 +74,29 @@ export const fetchExplains = async (
const hasExample = !!example?.example;

try {
if (databaseType === Databases.postgresql && (hasPlaceholders || hasExample)) {
const payload = {
serviceId: example.service_id,
queryId,
values: placeholders || [],
};

const explain = await postgresqlMethods.getExplain(payload).then(getActionResult);

const classicExplain = parseExplain(explain);

return {
jsonExplain: actionResult,
classicExplain: { ...explain, value: classicExplain },
visualExplain: actionResult,
};
}

if (databaseType === Databases.mysql && (hasPlaceholders || hasExample)) {
const payload = {
example,
queryId,
placeholders,
values: placeholders,
};

const [classicResult, jsonResult] = await Promise.all([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const ExplainPlaceholders: React.FC<ExplainPlaceholdersProps> = ({
};

if (!initialized && example) {
return <PlaceholdersForm onSubmit={handlePlaceholderSubmit} example={example} />;
return <PlaceholdersForm database={databaseType} onSubmit={handlePlaceholderSubmit} example={example} />;
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import { getStyles } from './PlaceholdersForm.styles';
import { PlaceholdersFormProps, PlaceholdersFormValues } from './PlaceholdersForm.types';
import { prepareInputs } from './PlaceholdersForm.utils';

const PlaceholdersForm: React.FC<PlaceholdersFormProps> = ({ onSubmit, example }) => {
const PlaceholdersForm: React.FC<PlaceholdersFormProps> = ({ database, onSubmit, example }) => {
// recreate initial values if example changes to reset the form
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialValues = useMemo<PlaceholdersFormValues>(() => ({ placeholders: [] }), [example]);
const styles = useStyles(getStyles);
const placeholders = prepareInputs(example.placeholders_count || 0);
const placeholders = prepareInputs(example.placeholders_count || 0, database);

return (
<Form initialValues={initialValues} onSubmit={onSubmit}>
Expand All @@ -24,6 +24,7 @@ const PlaceholdersForm: React.FC<PlaceholdersFormProps> = ({ onSubmit, example }
<div className={styles.container}>
<div className={styles.follow}>{Messages.follow}</div>
<PrepareExplainFingerPrint
database={database}
placeholders={values.placeholders}
fingerprint={example.explain_fingerprint || ''}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { QueryExampleResponseItem } from 'pmm-qan/panel/components/Details/Details.types';
import { DatabasesType, QueryExampleResponseItem } from 'pmm-qan/panel/components/Details/Details.types';

export interface PlaceholdersFormProps {
database: DatabasesType;
example: QueryExampleResponseItem;
onSubmit: (values: PlaceholdersFormValues) => void;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { times } from 'lodash';
import { DatabasesType } from '../../Details.types';
import { PlaceholderInput } from './PlaceholdersForm.types';

export const prepareInputs = (count: number): PlaceholderInput[] => times(count, (idx) => ({
label: `:${idx + 1}`,
fieldName: `placeholders.${idx}`,
}));
export const prepareInputs = (count: number, database: DatabasesType): PlaceholderInput[] => times(
count, (idx) => ({
label: getPlaceholder(idx, database),
fieldName: `placeholders.${idx}`,
}),
);

export const getPlaceholder = (idx: number, database: DatabasesType) => (database === 'postgresql' ? `$${idx + 1}` : `:${idx + 1}`);
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,24 @@ where

describe('QueryFingerprint component::', () => {
it('renders with empty query', () => {
render(<QueryFingerprint fingerprint="" placeholders={[]} />);
render(<QueryFingerprint database="mysql" fingerprint="" placeholders={[]} />);
expect(screen.getByTestId('highlight-code').textContent).toEqual('');
});

it('renders with query without placeholders', () => {
render(<QueryFingerprint fingerprint={QUERY_WITHOUT_PLACEHOLDERS} placeholders={[]} />);
render(<QueryFingerprint database="mysql" fingerprint={QUERY_WITHOUT_PLACEHOLDERS} placeholders={[]} />);
expect(screen.getByTestId('highlight-code').textContent).toEqual(QUERY_WITHOUT_PLACEHOLDERS);
});

it('renders with query with placeholders not filled out', () => {
render(<QueryFingerprint fingerprint={RAW_QUERY_WITH_PLACEHOLDERS} placeholders={[]} />);
render(<QueryFingerprint database="mysql" fingerprint={RAW_QUERY_WITH_PLACEHOLDERS} placeholders={[]} />);
expect(screen.getByTestId('highlight-code').textContent).toEqual(QUERY_WITH_PLACEHOLDERS);
});

it('renders with placeholders (string)', () => {
render(
<QueryFingerprint
database="mysql"
fingerprint={RAW_QUERY_WITH_PLACEHOLDERS}
placeholders={['\'placeholder_1\'', '(\'placeholder_2\')']}
/>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { Highlight } from 'shared/components/Hightlight/Highlight';
import { QueryFingerprintProps } from './QueryFingerprint.types';
import { replacePlaceholders } from './QueryFingerprint.utils';

const QueryFingerprint: React.FC<QueryFingerprintProps> = ({ fingerprint, placeholders }) => {
const formatted = replacePlaceholders(fingerprint, placeholders);
const QueryFingerprint: React.FC<QueryFingerprintProps> = ({ database, fingerprint, placeholders }) => {
const formatted = replacePlaceholders(database, fingerprint, placeholders);

return (
<Highlight key={formatted} language="sql">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { DatabasesType } from '../../Details.types';

export interface QueryFingerprintProps {
database: DatabasesType;
placeholders: string[];
fingerprint: string;
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
import sqlFormatter from 'sql-formatter';
import { DatabasesType } from '../../Details.types';

export const replacePlaceholders = (fingerprint: string, placeholders: string[] = []): string => {
export const replacePlaceholders = (
database: DatabasesType,
fingerprint: string,
placeholders: string[] = [],
): string => {
let replaced = fingerprint || '';

placeholders.forEach((value, idx) => {
const fingerprintIdx = idx + 1;

if (value && replaced.includes(`::${fingerprintIdx}`)) {
replaced = replaced.replace(new RegExp(`::${fingerprintIdx}`, 'g'), value);
if (value && replaced.includes(getPlaceholderArray(database, idx))) {
replaced = replaced.replace(new RegExp(getPlaceholderArray(database, idx), 'g'), value);
} else if (value) {
replaced = replaced.replace(new RegExp(`:${fingerprintIdx}`, 'g'), value);
replaced = replaced.replace(new RegExp(getPlaceholder(database, idx), 'g'), value);
}
});

return sqlFormatter.format(replaced);
};

export const getPlaceholder = (database: DatabasesType, idx: number): string => {
if (database === 'mysql') {
return `:${idx + 1}`;
}

if (database === 'postgresql') {
return `\\$${idx + 1}`;
}

return '';
};

export const getPlaceholderArray = (database: DatabasesType, idx: number): string => {
if (database === 'mysql') {
return `::${idx + 1}`;
}

if (database === 'postgresql') {
// todo: check
return `\\$\\$${idx + 1}`;
}

return '';
};
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ export const mysqlMethods = {
return result.action_id;
},

getExplainJSON: async ({ example, queryId, placeholders }) => {
getExplainJSON: async ({ example, queryId, values }) => {
try {
const payload = getExplainPayload(example, queryId, placeholders);
const payload = getExplainPayload(example, queryId, values);

const result = await MysqlDatabaseService.getTraditionalExplainJSONMysql(payload);

Expand All @@ -58,9 +58,9 @@ export const mysqlMethods = {
}
},

getExplainTraditional: async ({ example, queryId, placeholders }) => {
getExplainTraditional: async ({ example, queryId, values }) => {
try {
const payload = getExplainPayload(example, queryId, placeholders);
const payload = getExplainPayload(example, queryId, values);

const result = await MysqlDatabaseService.getTraditionalExplainMysql(payload);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { PostgreSQLExplain } from './postgresql.types';
import PostgresqlDatabaseService from './service';

export const postgresqlMethods = {
getExplain: async ({ values, queryId, serviceId }: PostgreSQLExplain): Promise<string> => {
const payload = {
queryId,
serviceId,
values,
};

const result = await PostgresqlDatabaseService.getPostgreSQLExplain(payload);

return result.action_id;
},
getShowCreateTables: async ({ example, tableName, database }) => {
if (!tableName) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export interface PostgreSQLExplainBody {
pmmAgentId?: string;
serviceId: string;
queryId: string;
values: string[];
database?: string;
}

export interface PostgreSQLExplainResponse {
action_id: string;
pmm_agent_id: string;
}

export interface PostgreSQLExplain {
serviceId: string;
queryId: string;
values: string[];
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { apiRequestManagement } from 'shared/components/helpers/api';
import { PostgreSQLExplainBody, PostgreSQLExplainResponse } from './postgresql.types';

export default {
getPostgreSQLIndex(body) {
Expand All @@ -7,4 +8,10 @@ export default {
getShowCreateTablePostgreSQL(body) {
return apiRequestManagement.post<any, any>('/Actions/StartPostgreSQLShowCreateTable', body);
},
getPostgreSQLExplain(body: PostgreSQLExplainBody) {
return apiRequestManagement.post<PostgreSQLExplainResponse, PostgreSQLExplainBody>(
'/Actions/StartPostgreSQLExplain',
body,
);
},
};