console: support volatile functions (close #6228)

Close https://github.com/hasura/graphql-engine/issues/6228

GitOrigin-RevId: 814c38846f49abd8c5ee48129e61d1a00b81a41e
This commit is contained in:
Aleksandra Sikora 2021-01-13 05:21:49 +01:00 committed by hasura-bot
parent 3373bbc748
commit 5aedaace28
19 changed files with 405 additions and 145 deletions

View File

@ -100,6 +100,7 @@ and be accessible according to the permissions that were configured for the role
- console: fix allow-list not getting added to metadata/allow_list.yaml in CLI mode (close #6374)
- console: misc bug fixes (close #4785, #6330, #6288)
- console: allow setting table custom name (#212)
- console: support tracking VOLATILE functions as mutations or queries (close #6228)
- cli: add missing global flags for seed command (#5565)
- cli: allow seeds as alias for seed command (#5693)
- cli: fix action timeouts not being picked up in metadata operations (#6220)

View File

@ -6,6 +6,21 @@ export const baseUrl = Cypress.config('baseUrl');
export const getIndexRoute = (sourceName = 'default', schemaName = 'public') =>
`/data/${sourceName}/schema/${schemaName}/`;
export const createVolatileFunction = (name: string) => {
return {
type: 'run_sql',
args: {
sql: `CREATE OR REPLACE FUNCTION public.${name}()
RETURNS SETOF text_result
LANGUAGE sql
AS $function$
SELECT * FROM text_result;
$function$`,
cascade: false,
},
};
};
export const dataTypes = [
'serial',
'bigserial',
@ -163,7 +178,7 @@ export const trackCreateFunctionTable = () => {
};
};
export const createTableForSessionVarTest = () => {
export const createSampleTable = () => {
return {
type: 'run_sql',
source: 'default',
@ -176,7 +191,7 @@ export const createTableForSessionVarTest = () => {
};
};
export const getTrackSessionVarTestTableQuery = () => {
export const getTrackSampleTableQuery = () => {
return {
type: 'pg_track_table',
source: 'default',
@ -196,7 +211,7 @@ export const dropTable = (table = 'post', cascade = false) => {
{
type: 'run_sql',
args: {
sql: `DROP table ${table}${cascade ? ' CASCADE;' : ';'}`,
sql: `DROP table "public"."${table}"${cascade ? ' CASCADE;' : ';'}`,
cascade,
},
},

View File

@ -10,8 +10,9 @@ import {
trackCreateFunctionTable,
getCreateTestFunctionQuery,
getTrackTestFunctionQuery,
createTableForSessionVarTest,
getTrackSessionVarTestTableQuery,
createSampleTable,
getTrackSampleTableQuery,
createVolatileFunction,
} from '../../../helpers/dataHelpers';
import {
@ -20,7 +21,6 @@ import {
validateCFunc,
validateUntrackedFunc,
ResultType,
createFunctionRequest,
trackFunctionRequest,
} from '../../validators/validators';
import { setPromptValue } from '../../../helpers/common';
@ -73,19 +73,12 @@ export const testSessVariable = () => {
// Round about way to create a function
const fN = 'customFunctionWithSessionArg'.toLowerCase(); // for reading
dataRequest(createTableForSessionVarTest(), ResultType.SUCCESS, 'query');
dataRequest(createSampleTable(), ResultType.SUCCESS, 'query');
cy.wait(5000);
dataRequest(
getTrackSessionVarTestTableQuery(),
ResultType.SUCCESS,
'metadata'
);
dataRequest(getTrackSampleTableQuery(), ResultType.SUCCESS, 'metadata');
cy.wait(5000);
createFunctionRequest(
testCustomFunctionSQLWithSessArg(fN),
ResultType.SUCCESS
);
dataRequest(testCustomFunctionSQLWithSessArg(fN), ResultType.SUCCESS);
cy.wait(1500);
trackFunctionRequest(getTrackFnPayload(fN), ResultType.SUCCESS);
@ -122,6 +115,7 @@ export const testSessVariable = () => {
);
dropTableRequest(dropTable('text_result', true), ResultType.SUCCESS);
cy.wait(2000);
cy.visit(`data/default/schema/public/`);
};
export const verifyPermissionTab = () => {
@ -148,3 +142,41 @@ export const deleteCustomFunction = () => {
dropTableRequest(dropTable(), ResultType.SUCCESS);
cy.wait(5000);
};
export const trackVolatileFunction = () => {
const fN = 'customVolatileFunc'.toLowerCase();
dataRequest(createSampleTable(), ResultType.SUCCESS);
cy.wait(1500);
dataRequest(getTrackSampleTableQuery(), ResultType.SUCCESS, 'metadata');
dataRequest(createVolatileFunction(fN), ResultType.SUCCESS);
cy.wait(1500);
cy.visit(`data/default/schema/public`);
cy.get(getElementFromAlias(`add-track-function-${fN}`)).click();
cy.get(getElementFromAlias('track-as-mutation')).click();
cy.wait(500);
cy.url().should(
'eq',
`${baseUrl}/data/default/schema/public/functions/${fN}/modify`
);
dropTableRequest(dropTable('text_result', true), ResultType.SUCCESS);
};
export const trackVolatileFunctionAsQuery = () => {
const fN = 'customVolatileFunc'.toLowerCase();
dataRequest(createSampleTable(), ResultType.SUCCESS);
cy.wait(1500);
dataRequest(getTrackSampleTableQuery(), ResultType.SUCCESS, 'metadata');
dataRequest(createVolatileFunction(fN), ResultType.SUCCESS);
cy.wait(1500);
cy.visit(`data/default/schema/public`);
cy.get(getElementFromAlias(`add-track-function-${fN}`)).click();
cy.get(getElementFromAlias('track-as-query')).click();
cy.wait(100);
cy.get(getElementFromAlias('track-as-query-confirm')).click();
cy.wait(500);
cy.url().should(
'eq',
`${baseUrl}/data/default/schema/public/functions/${fN}/modify`
);
dropTableRequest(dropTable('text_result', true), ResultType.SUCCESS);
};

View File

@ -8,6 +8,8 @@ import {
trackFunction,
verifyPermissionTab,
testSessVariable,
trackVolatileFunction,
trackVolatileFunctionAsQuery,
} from './spec';
import { getIndexRoute } from '../../../helpers/dataHelpers';
@ -31,6 +33,8 @@ export const runCreateCustomFunctionsTableTests = () => {
it('Verify permission tab', verifyPermissionTab);
it('Delete custom function', deleteCustomFunction);
it('Test custom function with Session Argument', testSessVariable);
it('Tracks VOLATILE function as mutation', trackVolatileFunction);
it('Tracks VOLATILE function as query', trackVolatileFunctionAsQuery);
});
};

View File

@ -206,25 +206,6 @@ export const dataRequest = (
});
};
export const createFunctionRequest = (
reqBody: RequestBody,
result: ResultType
) => {
const requestOptions = makeDataAPIOptions(
dataApiUrl,
adminSecret,
reqBody,
'query'
);
cy.request(requestOptions).then(response => {
if (result === ResultType.SUCCESS) {
expect(response.body.result_type === 'CommandOk').to.be.true;
} else {
expect(response.body.result_type === 'CommandOk').to.be.false;
}
});
};
export const trackFunctionRequest = (
reqBody: RequestBody,
result: ResultType

View File

@ -0,0 +1,12 @@
import React from 'react';
import styles from './styles.scss';
type NoteProps = {
type: 'warn';
};
export const Note: React.FC<NoteProps> = ({ type, children }) => {
return (
<section className={`${styles.note} ${styles[type]}`}>{children}</section>
);
};

View File

@ -0,0 +1,11 @@
.note {
background: #fff;
width: 100%;
border: 1px solid #d7d7d7;
padding: 16px;
border-radius: 4px;
}
.warn {
border-left: 3px solid #fec53d;
}

View File

@ -103,10 +103,14 @@ const addExistingTableSql = (name, customSchema, skipRouting = false) => {
};
};
const addExistingFunction = (name, customSchema, skipRouting = false) => {
const addExistingFunction = (
name,
config,
customSchema,
skipRouting = false
) => {
return (dispatch, getState) => {
dispatch({ type: MAKING_REQUEST });
dispatch(showSuccessNotification('Adding an function...'));
const currentSchema = customSchema
? customSchema
: getState().tables.currentSchema;
@ -115,7 +119,8 @@ const addExistingFunction = (name, customSchema, skipRouting = false) => {
const requestBodyUp = getTrackFunctionQuery(
name,
currentSchema,
currentDataSource
currentDataSource,
config
);
const requestBodyDown = getUntrackFunctionQuery(
name,

View File

@ -138,11 +138,11 @@ class ModifyCustomFunction extends React.Component {
? { isRequesting, isDeleting, isUntracking, isFetching }
: null;
const { migrationMode, dispatch } = this.props;
const { migrationMode, dispatch, currentSource } = this.props;
const functionBaseUrl = getFunctionBaseRoute(
schema,
this.props.currentSource,
currentSource,
functionName
);
@ -230,6 +230,7 @@ class ModifyCustomFunction extends React.Component {
sql={functionDefinition}
dispatch={dispatch}
data-test="modify-view"
source={currentSource}
>
Modify
</RawSqlButton>

View File

@ -15,7 +15,6 @@ import { getRunSqlQuery } from '../../../Common/utils/v1QueryUtils';
import {
getUntrackFunctionQuery,
getTrackFunctionQuery,
getTrackFunctionV2Query,
} from '../../../../metadata/queryUtils';
import { makeRequest } from '../../RemoteSchema/Actions';
@ -127,7 +126,7 @@ const unTrackCustomFunction = () => {
return (dispatch, getState) => {
const currentSchema = getState().tables.currentSchema;
const currentDataSource = getState().tables.currentDataSource;
const functionName = getState().functions.functionName;
const { functionName, configuration } = getState().functions;
const migrationName = 'remove_custom_function_' + functionName;
const payload = getUntrackFunctionQuery(
@ -138,7 +137,8 @@ const unTrackCustomFunction = () => {
const downPayload = getTrackFunctionQuery(
functionName,
currentSchema,
currentDataSource
currentDataSource,
configuration
);
const requestMsg = 'Deleting custom function...';
@ -186,25 +186,25 @@ const updateSessVar = session_argument => {
currentSchema,
currentDataSource
);
const retrackPayloadDown = getTrackFunctionV2Query(
const retrackPayloadDown = getTrackFunctionQuery(
functionName,
currentSchema,
currentDataSource,
{
...(oldConfiguration && oldConfiguration),
},
currentDataSource
}
);
// retrack with sess arg config
const retrackPayloadUp = getTrackFunctionV2Query(
const retrackPayloadUp = getTrackFunctionQuery(
functionName,
currentSchema,
currentDataSource,
{
...(session_argument && {
session_argument,
}),
},
currentDataSource
}
);
const untrackPayloadDown = getUntrackFunctionQuery(

View File

@ -17,7 +17,7 @@ import { dataSource } from '../../../../dataSources';
import { getRunSqlQuery } from '../../../Common/utils/v1QueryUtils';
import { getDownQueryComments } from '../../../../utils/migration/utils';
import {
getTrackFunctionV2Query,
getTrackFunctionQuery,
getTrackTableQuery,
} from '../../../../metadata/queryUtils';
import globals from '../../../../Globals';
@ -49,7 +49,7 @@ const trackAllItems = (sql, isMigration, migrationName) => (
objects.forEach(({ type, name, schema }) => {
let req = {};
if (type === 'function') {
req = getTrackFunctionV2Query(name, schema, {}, source);
req = getTrackFunctionQuery(name, schema, source, {});
} else {
req = getTrackTableQuery({ name, schema }, source);
}

View File

@ -0,0 +1,211 @@
import React, { useReducer } from 'react';
import { isEmpty } from '../../../Common/utils/jsUtils';
import { Dispatch } from '../../../../types';
import { addExistingFunction } from '../Add/AddExistingTableViewActions';
import Button from '../../../Common/Button';
import RawSqlButton from '../Common/Components/RawSqlButton';
import { Note } from '../../../Common/Note';
import styles from './styles.scss';
import { PGFunction } from '../../../../dataSources/services/postgresql/types';
type VolatileFuncNoteProps = {
showQueryNote: boolean;
onCancelClick: () => void;
onTrackAsMutationClick: () => void;
onTrackAsQueryClick: () => void;
onTrackAsQueryConfirmClick: () => void;
};
const VolatileFuncNote: React.FC<VolatileFuncNoteProps> = ({
showQueryNote,
onCancelClick,
onTrackAsMutationClick,
onTrackAsQueryClick,
onTrackAsQueryConfirmClick,
}) => {
return (
<Note type="warn">
This function will be tracked as a <b>Mutation</b> because the function is{' '}
<b>VOLATILE</b>
<div className={styles.buttonsSection}>
<Button
color="yellow"
onClick={onTrackAsMutationClick}
data-test="track-as-mutation"
>
Track as Mutation
</Button>
<Button onClick={onCancelClick}>Cancel</Button>
<button
className={`${styles.btnBlank} ${styles.queryButton}`}
onClick={onTrackAsQueryClick}
data-test="track-as-query"
>
Track as Query
</button>
</div>
{showQueryNote ? (
<div className={styles.nestedBox}>
<b>Are you sure?</b> Queries are supposed to be read-only and as such
are recommended to be <b>STABLE</b> or <b>IMMUTABLE</b>.
<div className={styles.buttonsSection}>
<Button
onClick={onTrackAsQueryConfirmClick}
data-test="track-as-query-confirm"
>
Track as Query
</Button>
</div>
</div>
) : null}
</Note>
);
};
type State = {
volatileNoteOpen: boolean;
queryWarningOpen: boolean;
};
const defaultState: State = {
volatileNoteOpen: false,
queryWarningOpen: false,
};
type Action =
| 'click-track-volatile-func'
| 'click-track-volatile-func-as-query'
| 'track-volatile-func'
| 'cancel';
const reducer = (state: State, action: Action): State => {
switch (action) {
case 'click-track-volatile-func':
return {
...state,
volatileNoteOpen: true,
};
case 'click-track-volatile-func-as-query':
return {
...state,
volatileNoteOpen: true,
queryWarningOpen: true,
};
case 'track-volatile-func':
case 'cancel':
return defaultState;
default:
return state;
}
};
type TrackableEntryProps = {
readOnlyMode: boolean;
dispatch: Dispatch;
func: PGFunction;
index: number;
source: string;
};
const TrackableEntry: React.FC<TrackableEntryProps> = ({
readOnlyMode,
dispatch,
func,
index,
source,
}) => {
const [state, dispatchR] = useReducer(reducer, defaultState);
const isVolatile = func.function_type.toLowerCase() === 'volatile';
const handleTrackFn = (e: React.MouseEvent) => {
e.preventDefault();
if (isVolatile) {
dispatchR('click-track-volatile-func');
} else {
dispatch(addExistingFunction(func.function_name));
}
};
const handleTrackFunction = (type: 'query' | 'mutation') => {
dispatchR('track-volatile-func');
dispatch(
addExistingFunction(func.function_name, {
exposed_as: type,
})
);
};
return (
<div className={styles.padd_bottom} key={`untracked-function-${index}`}>
{!readOnlyMode ? (
<div className={styles.display_inline}>
<Button
data-test={`add-track-function-${func.function_name}`}
className={`${styles.display_inline} btn btn-xs btn-default`}
onClick={handleTrackFn}
>
Track
</Button>
</div>
) : null}
<div className={`${styles.display_inline} ${styles.add_mar_left_mid}`}>
<RawSqlButton
dataTestId={`view-function-${func.function_name}`}
customStyles={styles.display_inline}
sql={func.function_definition}
dispatch={dispatch}
source={source}
>
View
</RawSqlButton>
</div>
<div className={`${styles.display_inline} ${styles.add_mar_left}`}>
<span>{func.function_name}</span>
</div>
{isVolatile && state.volatileNoteOpen && (
<div className={styles.volatileNote}>
<VolatileFuncNote
showQueryNote={state.queryWarningOpen}
onCancelClick={() => dispatchR('cancel')}
onTrackAsMutationClick={() => handleTrackFunction('mutation')}
onTrackAsQueryClick={() =>
dispatchR('click-track-volatile-func-as-query')
}
onTrackAsQueryConfirmClick={() => handleTrackFunction('query')}
/>
</div>
)}
</div>
);
};
type FunctionsListProps = {
funcs: PGFunction[];
readOnlyMode: boolean;
dispatch: Dispatch;
source: string;
};
export const TrackableFunctionsList: React.FC<FunctionsListProps> = ({
funcs,
...props
}) => {
const noTrackableFunctions = isEmpty(funcs);
if (noTrackableFunctions) {
return (
<div key="no-untracked-fns">
<div>There are no untracked functions</div>
</div>
);
}
return (
<>
{funcs.map((p, i) => (
<TrackableEntry key={p.function_name} {...props} func={p} index={i} />
))}
</>
);
};

View File

@ -9,7 +9,6 @@ import {
setTableName,
addExistingTableSql,
addAllUntrackedTablesSql,
addExistingFunction,
} from '../Add/AddExistingTableViewActions';
import {
updateSchemaInfo,
@ -50,6 +49,7 @@ import {
getDataSources,
} from '../../../../metadata/selector';
import { RightContainer } from '../../../Common/Layout/RightContainer';
import { TrackableFunctionsList } from './FunctionsList';
const DeleteSchemaButton = ({ dispatch, migrationMode, currentDataSource }) => {
const successCb = () => {
@ -635,74 +635,6 @@ class Schema extends Component {
};
const getUntrackedFunctionsSection = () => {
const noTrackableFunctions = isEmpty(trackableFuncs);
const getTrackableFunctionsList = () => {
const trackableFunctionList = [];
if (noTrackableFunctions) {
trackableFunctionList.push(
<div key="no-untracked-fns">
<div>There are no untracked functions</div>
</div>
);
} else {
trackableFuncs.forEach((p, i) => {
const getTrackBtn = () => {
if (readOnlyMode) {
return null;
}
const handleTrackFn = e => {
e.preventDefault();
dispatch(addExistingFunction(p.function_name));
};
return (
<div className={styles.display_inline}>
<Button
data-test={`add-track-function-${p.function_name}`}
className={`${styles.display_inline} btn btn-xs btn-default`}
onClick={handleTrackFn}
>
Track
</Button>
</div>
);
};
trackableFunctionList.push(
<div
className={styles.padd_bottom}
key={`untracked-function-${i}`}
>
{getTrackBtn()}
<div
className={`${styles.display_inline} ${styles.add_mar_left_mid}`}
>
<RawSqlButton
dataTestId={`view-function-${p.function_name}`}
customStyles={styles.display_inline}
sql={p.function_definition}
dispatch={dispatch}
>
View
</RawSqlButton>
</div>
<div
className={`${styles.display_inline} ${styles.add_mar_left}`}
>
<span>{p.function_name}</span>
</div>
</div>
);
});
}
return trackableFunctionList;
};
const heading = getSectionHeading(
'Untracked custom functions',
'Custom functions that are not exposed over the GraphQL API',
@ -717,7 +649,12 @@ class Schema extends Component {
testId={'toggle-trackable-functions'}
>
<div className={`${styles.padd_left_remove} col-xs-12`}>
{getTrackableFunctionsList()}
<TrackableFunctionsList
dispatch={dispatch}
funcs={trackableFuncs}
readOnlyMode={readOnlyMode}
source={currentDataSource}
/>
</div>
<div className={styles.clear_fix} />
</CollapsibleToggle>
@ -752,6 +689,7 @@ class Schema extends Component {
customStyles={styles.display_inline}
sql={p.function_definition}
dispatch={dispatch}
source={currentDataSource}
>
View
</RawSqlButton>

View File

@ -0,0 +1,48 @@
@import '../../../Common/Common';
.btnBlank {
background: transparent;
border: none;
color: #4d4d4d;
padding: 5px 10px;
font-size: 13px;
font-weight: 400;
display: inline-block;
margin-bottom: 0;
text-align: center;
white-space: nowrap;
vertical-align: middle;
border-radius: 4px;
user-select: none;
}
.queryButton {
margin-left: auto;
}
.buttonsSection {
display: flex;
margin-top: 10px;
align-items: center;
button:first-of-type {
margin-right: 10px;
}
}
.nestedBox {
background: #fff;
width: 100%;
border: 1px solid #d7d7d7;
padding: 16px;
border-radius: 4px;
margin-top: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.08);
}
.volatileNote {
width: 420px;
padding: 8px 0;
}

View File

@ -16,6 +16,7 @@ const ComputedFields = (props: ComputedFieldsProps) => {
nonTrackableFunctions,
trackableFunctions,
schemaList,
currentSource,
} = props;
const allFunctions = nonTrackableFunctions.concat(trackableFunctions);
@ -33,6 +34,7 @@ const ComputedFields = (props: ComputedFieldsProps) => {
functions={allFunctions} // TODO: fix cross schema functions
schemaList={schemaList}
dispatch={dispatch}
source={currentSource}
/>
</React.Fragment>
);
@ -51,6 +53,7 @@ const mapStateToProps = (state: ReduxState, ownProps: OwnProps) => {
return {
tableSchema: ownProps.tableSchema,
currentSchema: state.tables.currentSchema,
currentSource: state.tables.currentDataSource,
nonTrackableFunctions: state.tables.nonTrackablePostgresFunctions || [],
trackableFunctions: state.tables.postgresFunctions || [],
schemaList: state.tables.schemaList,

View File

@ -17,6 +17,7 @@ const ComputedFieldsEditor = ({
functions,
schemaList,
dispatch,
source,
}) => {
const computedFields = table.computed_fields;
@ -162,6 +163,7 @@ const ComputedFieldsEditor = ({
customStyles={`${styles.display_inline} ${styles.add_mar_left}`}
sql={computedFieldFunctionDefinition}
dispatch={dispatch}
source={source}
>
Modify
</RawSqlButton>
@ -304,6 +306,7 @@ const ComputedFieldsEditor = ({
customStyles={`${styles.display_inline} ${styles.add_mar_left}`}
sql={''}
dispatch={dispatch}
source={source}
>
Create new
</RawSqlButton>

View File

@ -785,8 +785,6 @@ const trackableFunctionsWhere = `
AND has_variadic = FALSE
AND returns_set = TRUE
AND return_type_type = 'c'
AND(function_type ILIKE '%STABLE%'
OR function_type ILIKE '%IMMUTABLE%')
`;
const nonTrackableFunctionsWhere = `
@ -794,10 +792,6 @@ AND NOT (
has_variadic = false
AND returns_set = TRUE
AND return_type_type = 'c'
AND (
function_type ilike '%stable%'
OR function_type ilike '%immutable%'
)
)
`;

View File

@ -3,6 +3,7 @@ export type PGFunction = {
function_schema: string;
function_definition: string;
return_type_type: string;
function_type: string;
};
export interface PostgresTable {

View File

@ -536,24 +536,24 @@ export const addExistingTableOrView = (
export const getTrackFunctionQuery = (
name: string,
schema: string,
source: string
) => getMetadataQuery('track_function', source, { function: { name, schema } });
export const getTrackFunctionV2Query = (
name: string,
schema: string,
configuration: Record<string, string>,
source: string
) =>
getMetadataQuery(
'track_function',
source,
{
function: { name, schema },
configuration,
},
{ version: 2 }
);
source: string,
configuration?: Record<string, any>
) => {
if (configuration) {
return getMetadataQuery(
'track_function',
source,
{
function: { name, schema },
configuration,
},
{ version: 2 }
);
}
return getMetadataQuery('track_function', source, {
function: { name, schema },
});
};
export const getUntrackFunctionQuery = (
name: string,