pgadmin4/web/pgadmin/static/js/SchemaView/SchemaState/common.js

389 lines
11 KiB
JavaScript

/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import diffArray from 'diff-arrays-of-objects';
import _ from 'lodash';
import gettext from 'sources/gettext';
import { memoizeFn } from 'sources/utils';
import {
minMaxValidator, numberValidator, integerValidator, emptyValidator,
checkUniqueCol, isEmptyString
} from 'sources/validators';
import BaseUISchema from '../base_schema.ui';
import { isModeSupportedByField, isObjectEqual, isValueEqual } from '../common';
export const SCHEMA_STATE_ACTIONS = {
INIT: 'init',
SET_VALUE: 'set_value',
ADD_ROW: 'add_row',
DELETE_ROW: 'delete_row',
MOVE_ROW: 'move_row',
RERENDER: 'rerender',
CLEAR_DEFERRED_QUEUE: 'clear_deferred_queue',
DEFERRED_DEPCHANGE: 'deferred_depchange',
BULK_UPDATE: 'bulk_update',
};
// Remove cid key added by prepareData
const cleanCid = (coll, keepCid=false) => (
(!coll || keepCid) ? coll : coll.map(
(o) => _.pickBy(o, (v, k) => (k !== 'cid'))
)
);
export function getCollectionDiffInEditMode(
field, origVal, sessVal, keepCid, parseChanges
) {
let change = {};
const id = field.id;
const collIdAttr = field.schema.idAttribute;
const origColl = _.get(origVal, id) || [];
const sessColl = _.get(sessVal, id) || [];
/*
* Use 'diffArray' package to get the array diff and extract the
* info. 'cid' attribute is used to identify the rows uniquely.
*/
const changeDiff = diffArray(
origColl, sessColl || [], 'cid', { compareFunction: isObjectEqual }
);
if(changeDiff.added.length > 0) {
change['added'] = cleanCid(changeDiff.added, keepCid);
}
if(changeDiff.removed.length > 0) {
change['deleted'] = cleanCid(changeDiff.removed.map((row) => {
// Deleted records must be from the original data, not the newly added.
return _.find(_.get(origVal, field.id), ['cid', row.cid]);
}), keepCid);
}
if(changeDiff.updated.length > 0) {
/*
* There is a change in collection. Parse further deep to figure
* out the exact details.
*/
let changed = [];
for(const changedRow of changeDiff.updated) {
const rowIndxSess = _.findIndex(
_.get(sessVal, id), (row) => (row.cid === changedRow.cid)
);
const rowIndxOrig = _.findIndex(
_.get(origVal, id), (row) => (row.cid==changedRow.cid)
);
const finalChangedRow = parseChanges(
field.schema, _.get(origVal, [id, rowIndxOrig]),
_.get(sessVal, [id, rowIndxSess])
);
if(_.isEmpty(finalChangedRow)) {
continue;
}
/*
* If the 'id' attribute value is present, then only changed keys
* can be passed. Otherwise, passing all the keys is useful.
*/
const idAttrValue = _.get(sessVal, [id, rowIndxSess, collIdAttr]);
if(_.isUndefined(idAttrValue)) {
changed.push({ ...changedRow, ...finalChangedRow });
} else {
changed.push({ [collIdAttr]: idAttrValue, ...finalChangedRow });
}
}
if(changed.length > 0) {
change['changed'] = cleanCid(changed, keepCid);
}
}
return change;
}
export function getSchemaDataDiff(
topSchema, initData, sessData, mode, keepCid,
stringify=false, includeSkipChange=true
) {
const isEditMode = mode === 'edit';
// This will be executed recursively as data can be nested.
let parseChanges = (schema, origVal, sessVal) => {
let levelChanges = {};
parseChanges.depth =
_.isUndefined(parseChanges.depth) ? 0 : (parseChanges.depth + 1);
/* The comparator and setter */
const attrChanged = (id, change, force=false) => {
if(isValueEqual(_.get(origVal, id), _.get(sessVal, id)) && !force) {
return;
}
change = change || _.get(sessVal, id);
if(stringify && (_.isArray(change) || _.isObject(change))) {
change = JSON.stringify(change);
}
/*
* Null values are not passed in URL params, pass it as an empty string.
* Nested values does not need this.
*/
if(_.isNull(change) && parseChanges.depth === 0) {
change = '';
}
levelChanges[id] = change;
};
schema.fields.forEach((field) => {
// Never include data from the field in the changes, marked as
// 'excluded'.
if (field.exclude) return;
/*
* If skipChange is true, then field will not be considered for changed
* data. This is helpful when 'Save' or 'Reset' should not be enabled on
* this field change alone. No change in other behaviour.
*/
if(field.skipChange && !includeSkipChange) return;
/*
* At this point the schema assignments like top may not have been done,
* so - check if mode is supported by this field, or not.
*/
if (!isModeSupportedByField(field, {mode})) return;
if(
typeof(field.type) === 'string' && field.type.startsWith('nested-')
) {
/*
* Even if its nested, state is on same hierarchical level.
* Find the changes and merge.
*/
levelChanges = {
...levelChanges,
...parseChanges(field.schema, origVal, sessVal),
};
} else if(isEditMode && !_.isEqual(
_.get(origVal, field.id), _.get(sessVal, field.id)
)) {
/*
* Check for changes only if in edit mode, otherwise - everything can
* go through comparator
*/
if(field.type === 'collection') {
const change = getCollectionDiffInEditMode(
field, origVal, sessVal, keepCid, parseChanges
);
if(Object.keys(change).length > 0) {
attrChanged(field.id, change, true);
}
} else {
attrChanged(field.id);
}
} else if(!isEditMode) {
if(field.type === 'collection') {
const origColl = _.get(origVal, field.id) || [];
const sessColl = _.get(sessVal, field.id) || [];
let changeDiff = diffArray(
origColl, sessColl, 'cid', {compareFunction: isObjectEqual}
);
// Check the updated changes,when:
// 1. These are the fixed rows.
// 2. 'canReorder' flag is set to true.
if((
!_.isUndefined(field.fixedRows) && changeDiff.updated.length > 0
) || (
_.isUndefined(field.fixedRows) && (
changeDiff.added.length > 0 || changeDiff.removed.length > 0 ||
changeDiff.updated.length > 0
)
) || (
field.canReorder && _.differenceBy(origColl, sessColl, 'cid')
)) {
attrChanged(
field.id, cleanCid(_.get(sessVal, field.id), keepCid), true
);
return;
}
if(field.canReorder) {
changeDiff = diffArray(origColl, sessColl);
if(changeDiff.updated.length > 0) {
attrChanged(
field.id, cleanCid(_.get(sessVal, field.id), keepCid), true
);
}
}
} else {
attrChanged(field.id);
}
}
});
parseChanges.depth--;
return levelChanges;
};
let res = parseChanges(topSchema, initData, sessData);
return res;
}
export function validateCollectionSchema(
field, sessData, accessPath, setError
) {
const rows = sessData[field.id] || [];
const currPath = accessPath.concat(field.id);
// Loop through data.
for(const [rownum, row] of rows.entries()) {
if(validateSchema(
field.schema, row, setError, currPath.concat(rownum), field.label
)) {
return true;
}
}
// Validate duplicate rows.
const dupInd = checkUniqueCol(rows, field.uniqueCol);
if(dupInd > 0) {
const uniqueColNames = _.filter(
field.schema.fields, (uf) => field.uniqueCol.indexOf(uf.id) > -1
).map((uf)=>uf.label).join(', ');
if (isEmptyString(field.label)) {
setError(currPath, gettext('%s must be unique.', uniqueColNames));
} else {
setError(
currPath,
gettext('%s in %s must be unique.', uniqueColNames, field.label)
);
}
return true;
}
return false;
}
export function validateSchema(
schema, sessData, setError, accessPath=[], collLabel=null
) {
sessData = sessData || {};
for(const field of schema.fields) {
// Skip id validation
if(schema.idAttribute === field.id) {
continue;
}
// If the field is has nested schema, then validate the child schema.
if(field.schema && (field.schema instanceof BaseUISchema)) {
if (!field.schema.top) field.schema.top = schema;
// A collection is an array.
if(field.type === 'collection') {
if (validateCollectionSchema(field, sessData, accessPath, setError))
return true;
}
// A nested schema ? Recurse
else if(validateSchema(field.schema, sessData, setError, accessPath)) {
return true;
}
} else {
// Normal field, default validations.
const value = sessData[field.id];
const fieldPath = accessPath.concat(field.id);
const setErrorOnMessage = (message) => {
if (message) {
setError(fieldPath, message);
return true;
}
return false;
};
if(field.noEmpty) {
const label = (
collLabel && gettext('%s in %s', field.label, collLabel)
) || field.noEmptyLabel || field.label;
if (setErrorOnMessage(emptyValidator(label, value)))
return true;
}
if(field.type === 'int') {
if (setErrorOnMessage(
integerValidator(field.label, value) ||
minMaxValidator(field.label, value, field.min, field.max)
))
return true;
} else if(field.type === 'numeric') {
if (setErrorOnMessage(
numberValidator(field.label, value) ||
minMaxValidator(field.label, value, field.min, field.max)
))
return true;
}
}
}
return schema.validate(
sessData, (id, message) => setError(accessPath.concat(id), message)
);
}
export const getDepChange = (currPath, newState, oldState, action) => {
if(action.depChange) {
newState = action.depChange(currPath, newState, {
type: action.type,
path: action.path,
value: action.value,
oldState: _.cloneDeep(oldState),
listener: action.listener,
});
}
return newState;
};
// It will help us generating the flat path, and it will return the same
// object for the same path, which will help with the React componet rendering,
// as it uses `Object.is(...)` for the comparison of the arguments.
export const flatPathGenerator = (separator = '.' ) => {
const flatPathMap = new Map;
const setter = memoizeFn((path) => {
const flatPath = path.join(separator);
flatPathMap.set(flatPath, path);
return flatPath;
});
const getter = (flatPath) => {
return flatPathMap.get(flatPath);
};
return {
flatPath: setter,
path: getter,
// Get the same object every time.
cached: (path) => (getter(setter(path))),
};
};