mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
ec8ab598cb
Summary: This adds rudimentary support for opening certain SQLite files in Grist. If you have a file such as `landing.db` in Grist, you can convert it to Grist format by doing (either in monorepo or grist-core): ``` yarn run cli -h yarn run cli sqlite -h yarn run cli sqlite gristify landing.db ``` The file is now openable by Grist. To actually do so with the regular Grist server, you'll need to either import it, or convert some doc you don't care about in the `samples/` directory to be a soft link to it (and then force a reload). This implementation is a rudimentary experiment. Here are some awkwardnesses: * Only tables that happen to have a column called `id`, and where the column happens to be an integer, can be opened directly with Grist as it is today. That could be generalized, but it looked more than a Gristathon's worth of work, so I instead used SQLite views. * Grist will handle tables that start with an uncapitalized letter a bit erratically. You can successfully add columns, for example, but removing them will cause sadness - Grist will rename the table in a confused way. * I didn't attempt to deal with column names with spaces etc (though views could deal with those). * I haven't tried to do any fancy type mapping. * Columns with constraints can make adding new rows impossible in Grist, since Grist requires that a row can be added with just a single cell set. Test Plan: added small test Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3502
786 lines
36 KiB
TypeScript
786 lines
36 KiB
TypeScript
/* Helper file to separate ActiveDoc import functions and convert them to TypeScript. */
|
|
|
|
import * as path from 'path';
|
|
import * as _ from 'underscore';
|
|
|
|
import {ColumnDelta, createEmptyActionSummary} from 'app/common/ActionSummary';
|
|
import {ApplyUAResult, DataSourceTransformed, ImportOptions, ImportResult, ImportTableResult,
|
|
MergeOptions, MergeOptionsMap, MergeStrategy, SKIP_TABLE, TransformColumn,
|
|
TransformRule,
|
|
TransformRuleMap} from 'app/common/ActiveDocAPI';
|
|
import {ApiError} from 'app/common/ApiError';
|
|
import {BulkColValues, CellValue, fromTableDataAction, TableRecordValue, UserAction} from 'app/common/DocActions';
|
|
import * as gutil from 'app/common/gutil';
|
|
import {DocStateComparison} from 'app/common/UserAPI';
|
|
import {guessColInfoForImports} from 'app/common/ValueGuesser';
|
|
import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
|
|
import {GristColumn, GristTable} from 'app/plugin/GristTable';
|
|
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
|
|
import {DocSession, OptDocSession} from 'app/server/lib/DocSession';
|
|
import log from 'app/server/lib/log';
|
|
import {globalUploadSet, moveUpload, UploadInfo} from 'app/server/lib/uploads';
|
|
import {buildComparisonQuery} from 'app/server/lib/ExpandedQuery';
|
|
import flatten = require('lodash/flatten');
|
|
|
|
const IMPORT_TRANSFORM_COLUMN_PREFIX = 'gristHelper_Import_';
|
|
|
|
/*
|
|
* AddTableRetValue contains return value of user actions 'AddTable'
|
|
*/
|
|
interface AddTableRetValue {
|
|
table_id: string;
|
|
id: number;
|
|
columns: string[];
|
|
views: object[];
|
|
}
|
|
|
|
interface ReferenceDescription {
|
|
// the table index
|
|
tableIndex: number;
|
|
// the column index
|
|
colIndex: number;
|
|
// the id of the table which is referenced
|
|
refTableId: string;
|
|
}
|
|
|
|
interface FileImportOptions {
|
|
// Suggested name of the import file. It is sometimes used as a suggested table name, e.g. for csv imports.
|
|
originalFilename: string;
|
|
// Containing parseOptions as serialized JSON to pass to the import plugin.
|
|
parseOptions: ParseOptions;
|
|
// Map of table names to their merge options.
|
|
mergeOptionsMap: MergeOptionsMap;
|
|
// Flag to indicate whether table is temporary and hidden or regular.
|
|
isHidden: boolean;
|
|
// Index of original dataSource corresponding to current imported file.
|
|
uploadFileIndex: number;
|
|
// Map of table names to their transform rules.
|
|
transformRuleMap: TransformRuleMap;
|
|
}
|
|
|
|
export class ActiveDocImport {
|
|
constructor(private _activeDoc: ActiveDoc) {}
|
|
/**
|
|
* Imports files, removes previously created temporary hidden tables and creates the new ones
|
|
*/
|
|
public async importFiles(docSession: DocSession, dataSource: DataSourceTransformed,
|
|
parseOptions: ParseOptions, prevTableIds: string[]): Promise<ImportResult> {
|
|
this._activeDoc.startBundleUserActions(docSession);
|
|
await this._removeHiddenTables(docSession, prevTableIds);
|
|
const userId = docSession.authorizer.getUserId();
|
|
const accessId = this._activeDoc.makeAccessId(userId);
|
|
const uploadInfo: UploadInfo = globalUploadSet.getUploadInfo(dataSource.uploadId, accessId);
|
|
return this._importFiles(docSession, uploadInfo, dataSource.transforms, {parseOptions}, true);
|
|
}
|
|
|
|
/**
|
|
* Finishes import files, removes temporary hidden tables, temporary uploaded files and creates
|
|
* the new tables
|
|
*/
|
|
public async finishImportFiles(docSession: DocSession, dataSource: DataSourceTransformed,
|
|
prevTableIds: string[], importOptions: ImportOptions): Promise<ImportResult> {
|
|
this._activeDoc.startBundleUserActions(docSession);
|
|
try {
|
|
await this._removeHiddenTables(docSession, prevTableIds);
|
|
const userId = docSession.authorizer.getUserId();
|
|
const accessId = this._activeDoc.makeAccessId(userId);
|
|
const uploadInfo: UploadInfo = globalUploadSet.getUploadInfo(dataSource.uploadId, accessId);
|
|
const importResult = await this._importFiles(docSession, uploadInfo, dataSource.transforms,
|
|
importOptions, false);
|
|
await globalUploadSet.cleanup(dataSource.uploadId);
|
|
return importResult;
|
|
} finally {
|
|
this._activeDoc.stopBundleUserActions(docSession);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancels import files, removes temporary hidden tables and temporary uploaded files
|
|
*
|
|
* @param {ActiveDoc} activeDoc: Instance of ActiveDoc.
|
|
* @param {number} uploadId: Identifier for the temporary uploaded file(s) to clean up.
|
|
* @param {Array<String>} prevTableIds: Array of tableIds as received from previous `importFiles`
|
|
* call when re-importing with changed `parseOptions`.
|
|
* @returns {Promise} Promise that's resolved when all actions are applied successfully.
|
|
*/
|
|
public async cancelImportFiles(docSession: DocSession,
|
|
uploadId: number,
|
|
prevTableIds: string[]): Promise<void> {
|
|
await this._removeHiddenTables(docSession, prevTableIds);
|
|
this._activeDoc.stopBundleUserActions(docSession);
|
|
await globalUploadSet.cleanup(uploadId);
|
|
}
|
|
|
|
/**
|
|
* Returns a diff of changes that will be applied to the destination table from `transformRule`
|
|
* if the data from `hiddenTableId` is imported with the specified `mergeOptions`.
|
|
*
|
|
* The diff is returned as a `DocStateComparison` of the same doc, with the `rightChanges`
|
|
* containing the updated cell values. Old values are pulled from the destination record (if
|
|
* a match was found), and new values are the result of merging in the new cell values with
|
|
* the merge strategy from `mergeOptions`.
|
|
*
|
|
* No distinction is currently made for added records vs. updated existing records; instead,
|
|
* we treat added records as an updated record in `hiddenTableId` where all the column
|
|
* values changed from blank to the original column values from `hiddenTableId`.
|
|
*
|
|
* @param {string} hiddenTableId Source table.
|
|
* @param {TransformRule} transformRule Transform rule for the original source columns.
|
|
* The destination table id is populated in the rule.
|
|
* @param {MergeOptions} mergeOptions Merge options for how to match source rows
|
|
* with destination records, and how to merge their column values.
|
|
* @returns {Promise<DocStateComparison>} Comparison data for the changes that will occur if
|
|
* `hiddenTableId` is merged into the destination table from `transformRule`.
|
|
*/
|
|
public async generateImportDiff(hiddenTableId: string, {destCols, destTableId}: TransformRule,
|
|
{mergeCols, mergeStrategy}: MergeOptions): Promise<DocStateComparison> {
|
|
// Merge column ids from client have prefixes that need to be stripped.
|
|
mergeCols = stripPrefixes(mergeCols);
|
|
|
|
// Get column differences between `hiddenTableId` and `destTableId` for rows that exist in both tables.
|
|
const srcAndDestColIds: [string, string[]][] = destCols.map(c => [c.colId!, stripPrefixes([c.colId!])]);
|
|
const srcToDestColIds = new Map(srcAndDestColIds);
|
|
const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId!, srcToDestColIds, mergeCols);
|
|
|
|
// Initialize container for updated column values in the expected format (ColumnDelta).
|
|
const updatedRecords: {[colId: string]: ColumnDelta} = {};
|
|
const updatedRecordIds: number[] = [];
|
|
const srcColIds = srcAndDestColIds.map(([srcColId, _destColId]) => srcColId);
|
|
for (const id of srcColIds) {
|
|
updatedRecords[id] = {};
|
|
}
|
|
|
|
// Retrieve the function used to reconcile differences between source and destination.
|
|
const merge = getMergeFunction(mergeStrategy);
|
|
|
|
// Destination columns with a blank formula (i.e. skipped columns).
|
|
const skippedColumnIds = new Set(
|
|
stripPrefixes(destCols.filter(c => c.formula.trim() === '').map(c => c.colId!))
|
|
);
|
|
|
|
const numResultRows = comparisonResult[hiddenTableId + '.id'].length;
|
|
for (let i = 0; i < numResultRows; i++) {
|
|
const srcRowId = comparisonResult[hiddenTableId + '.id'][i] as number;
|
|
|
|
if (comparisonResult[destTableId + '.id'][i] === null) {
|
|
// No match in destination table found for source row, so it must be a new record.
|
|
for (const srcColId of srcColIds) {
|
|
updatedRecords[srcColId][srcRowId] = [[''], [(comparisonResult[`${hiddenTableId}.${srcColId}`][i])]];
|
|
}
|
|
} else {
|
|
// Otherwise, a match was found between source and destination tables.
|
|
for (const srcColId of srcColIds) {
|
|
const matchingDestColId = srcToDestColIds.get(srcColId)![0];
|
|
const srcVal = comparisonResult[`${hiddenTableId}.${srcColId}`][i];
|
|
const destVal = comparisonResult[`${destTableId}.${matchingDestColId}`][i];
|
|
|
|
// Exclude unchanged cell values from the comparison.
|
|
if (srcVal === destVal) { continue; }
|
|
|
|
const shouldSkip = skippedColumnIds.has(matchingDestColId);
|
|
updatedRecords[srcColId][srcRowId] = [
|
|
[destVal],
|
|
// For skipped columns, always use the destination value.
|
|
[shouldSkip ? destVal : merge(srcVal, destVal)]
|
|
];
|
|
}
|
|
}
|
|
|
|
updatedRecordIds.push(srcRowId);
|
|
}
|
|
|
|
return {
|
|
left: {n: 0, h: ''}, // NOTE: left, right, parent, and summary are not used by Importer.
|
|
right: {n: 0, h: ''},
|
|
parent: null,
|
|
summary: 'right',
|
|
details: {
|
|
leftChanges: createEmptyActionSummary(),
|
|
rightChanges: {
|
|
tableRenames: [],
|
|
tableDeltas: {
|
|
[hiddenTableId]: {
|
|
removeRows: [],
|
|
updateRows: updatedRecordIds,
|
|
addRows: [], // Since deltas are relative to the source table, we can't (yet) use this.
|
|
columnRenames: [],
|
|
columnDeltas: updatedRecords,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Import the given upload as new tables in one step. This does not give the user a chance to
|
|
* modify parse options or transforms. The caller is responsible for cleaning up the upload.
|
|
*/
|
|
public async oneStepImport(docSession: OptDocSession, uploadInfo: UploadInfo): Promise<ImportResult> {
|
|
this._activeDoc.startBundleUserActions(docSession);
|
|
try {
|
|
return this._importFiles(docSession, uploadInfo, [], {}, false);
|
|
} finally {
|
|
this._activeDoc.stopBundleUserActions(docSession);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Imports all files as new tables, using the given transform rules and import options.
|
|
* The isHidden flag indicates whether to create temporary hidden tables, or final ones.
|
|
*/
|
|
private async _importFiles(docSession: OptDocSession, upload: UploadInfo, transforms: TransformRuleMap[],
|
|
{parseOptions = {}, mergeOptionMaps = []}: ImportOptions,
|
|
isHidden: boolean): Promise<ImportResult> {
|
|
|
|
// Check that upload size is within the configured limits.
|
|
const limit = (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || Infinity;
|
|
const totalSize = upload.files.reduce((acc, f) => acc + f.size, 0);
|
|
if (totalSize > limit) {
|
|
throw new ApiError(`Imported files must not exceed ${gutil.byteString(limit)}`, 413);
|
|
}
|
|
|
|
// The upload must be within the plugin-accessible directory. Once moved, subsequent calls to
|
|
// moveUpload() will return without having to do anything.
|
|
if (!this._activeDoc.docPluginManager) { throw new Error('no plugin manager available'); }
|
|
await moveUpload(upload, this._activeDoc.docPluginManager.tmpDir());
|
|
|
|
const importResult: ImportResult = {options: parseOptions, tables: []};
|
|
for (const [index, file] of upload.files.entries()) {
|
|
// If we have a better guess for the file's extension, replace it in origName, to ensure
|
|
// that DocPluginManager has access to it to guess the best parser type.
|
|
let origName: string = file.origName;
|
|
if (file.ext) {
|
|
origName = path.basename(origName, path.extname(origName)) + file.ext;
|
|
}
|
|
const res = await this._importFileAsNewTable(docSession, file.absPath, {
|
|
parseOptions,
|
|
mergeOptionsMap: mergeOptionMaps[index] || {},
|
|
isHidden,
|
|
originalFilename: origName,
|
|
uploadFileIndex: index,
|
|
transformRuleMap: transforms[index] || {}
|
|
});
|
|
if (index === 0) {
|
|
// Returned parse options from the first file should be used for all files in one upload.
|
|
importResult.options = parseOptions = res.options;
|
|
}
|
|
importResult.tables.push(...res.tables);
|
|
}
|
|
return importResult;
|
|
}
|
|
|
|
/**
|
|
* Imports the data stored at tmpPath.
|
|
*
|
|
* Currently it starts a python parser as a child process
|
|
* outside the sandbox, and supports xlsx, csv, and perhaps some other formats. It may
|
|
* result in the import of multiple tables, in case of e.g. Excel formats.
|
|
* @param {OptDocSession} docSession: Session instance to use for importing.
|
|
* @param {String} tmpPath: The path from of the original file.
|
|
* @param {FileImportOptions} importOptions: File import options.
|
|
* @returns {Promise<ImportResult>} with `options` property containing parseOptions as serialized JSON as adjusted
|
|
* or guessed by the plugin, and `tables`, which is which is a list of objects with information about
|
|
* tables, such as `hiddenTableId`, `uploadFileIndex`, `origTableName`, `transformSectionRef`, `destTableId`.
|
|
*/
|
|
private async _importFileAsNewTable(docSession: OptDocSession, tmpPath: string,
|
|
importOptions: FileImportOptions): Promise<ImportResult> {
|
|
const {originalFilename, parseOptions, mergeOptionsMap, isHidden, uploadFileIndex,
|
|
transformRuleMap} = importOptions;
|
|
log.info("ActiveDoc._importFileAsNewTable(%s, %s)", tmpPath, originalFilename);
|
|
if (!this._activeDoc.docPluginManager) { throw new Error('no plugin manager available'); }
|
|
const optionsAndData: ParseFileResult =
|
|
await this._activeDoc.docPluginManager.parseFile(tmpPath, originalFilename, parseOptions);
|
|
const options = optionsAndData.parseOptions;
|
|
|
|
const parsedTables = optionsAndData.tables;
|
|
const references = this._encodeReferenceAsInt(parsedTables);
|
|
|
|
const tables: ImportTableResult[] = [];
|
|
const fixedColumnIdsByTable: { [tableId: string]: string[]; } = {};
|
|
|
|
for (const table of parsedTables) {
|
|
const ext = path.extname(originalFilename);
|
|
const basename = path.basename(originalFilename, ext).trim();
|
|
const hiddenTableName = 'GristHidden_import';
|
|
const origTableName = table.table_name ? table.table_name : '';
|
|
const transformRule = transformRuleMap && transformRuleMap.hasOwnProperty(origTableName) ?
|
|
transformRuleMap[origTableName] : null;
|
|
const columnMetadata = cleanColumnMetadata(table.column_metadata, table.table_data, this._activeDoc);
|
|
const result: ApplyUAResult = await this._activeDoc.applyUserActions(docSession,
|
|
[["AddTable", hiddenTableName, columnMetadata]]);
|
|
const retValue: AddTableRetValue = result.retValues[0];
|
|
const hiddenTableId = retValue.table_id; // The sanitized version of the table name.
|
|
const hiddenTableColIds = retValue.columns; // The sanitized names of the columns.
|
|
|
|
// The table_data received from importFile is an array of columns of data, rather than a
|
|
// dictionary, so that it doesn't depend on column names. We instead construct the
|
|
// dictionary once we receive the sanitized column names from AddTable.
|
|
const dataLength = table.table_data[0] ? table.table_data[0].length : 0;
|
|
log.info("Importing table %s, %s rows, from %s", hiddenTableId, dataLength, table.table_name);
|
|
|
|
const rowIdColumn = _.range(1, dataLength + 1);
|
|
const columnValues = _.object(hiddenTableColIds, table.table_data);
|
|
const destTableId = transformRule ? transformRule.destTableId : null;
|
|
const ruleCanBeApplied = (transformRule != null) &&
|
|
_.difference(transformRule.sourceCols, hiddenTableColIds).length === 0;
|
|
await this._activeDoc.applyUserActions(docSession,
|
|
// BulkAddRecord rather than ReplaceTableData so that type guessing is applied to Any columns.
|
|
// Don't use parseStrings, only use the strict parsing in ValueGuesser to make the import lossless.
|
|
[["BulkAddRecord", hiddenTableId, rowIdColumn, columnValues]]);
|
|
|
|
// data parsed and put into hiddenTableId
|
|
// For preview_table (isHidden) do GenImporterView to make views and formulas and cols
|
|
// For final import, call _transformAndFinishImport, which imports file using a transform rule (or blank)
|
|
|
|
let createdTableId: string;
|
|
let transformSectionRef: number = -1; // TODO: we only have this if we genImporterView, is it necessary?
|
|
|
|
if (isHidden) {
|
|
// Generate formula columns, view sections, etc
|
|
const results: ApplyUAResult = await this._activeDoc.applyUserActions(docSession,
|
|
[['GenImporterView', hiddenTableId, destTableId, ruleCanBeApplied ? transformRule : null]]);
|
|
|
|
transformSectionRef = results.retValues[0];
|
|
createdTableId = hiddenTableId;
|
|
|
|
} else {
|
|
if (destTableId === SKIP_TABLE) {
|
|
await this._activeDoc.applyUserActions(docSession, [['RemoveTable', hiddenTableId]]);
|
|
continue;
|
|
}
|
|
// Do final import
|
|
const mergeOptions = mergeOptionsMap[origTableName] ?? null;
|
|
const intoNewTable: boolean = destTableId ? false : true;
|
|
const destTable = destTableId || table.table_name || basename;
|
|
createdTableId = await this._transformAndFinishImport(docSession, hiddenTableId, destTable,
|
|
intoNewTable, ruleCanBeApplied ? transformRule : null, mergeOptions);
|
|
}
|
|
|
|
fixedColumnIdsByTable[createdTableId] = hiddenTableColIds;
|
|
|
|
tables.push({
|
|
hiddenTableId: createdTableId, // TODO: rename thing?
|
|
uploadFileIndex,
|
|
origTableName,
|
|
transformSectionRef, // TODO: this shouldn't always be needed, and we only get it if genimporttransform
|
|
destTableId
|
|
});
|
|
}
|
|
|
|
await this._fixReferences(docSession, tables, fixedColumnIdsByTable, references, isHidden);
|
|
|
|
return ({options, tables});
|
|
}
|
|
|
|
/**
|
|
* Imports records from `hiddenTableId` into `destTableId`, transforming the column
|
|
* values from `hiddenTableId` according to the `transformRule`. Finalizes import when done.
|
|
*
|
|
* If `mergeOptions` is present, records from `hiddenTableId` will be "merged" into `destTableId`
|
|
* according to a set of merge columns. Records from both tables that have equal values for all
|
|
* merge columns are treated as the same record, and will be updated in `destTableId` according
|
|
* to the strategy specified in `mergeOptions`.
|
|
*
|
|
* @param {string} hiddenTableId Source table containing records to be imported.
|
|
* @param {string} destTableId Destination table that will be updated.
|
|
* @param {boolean} intoNewTable True if import destination is a new table.
|
|
* @param {TransformRule|null} transformRule Rules for transforming source columns using formulas
|
|
* before merging/importing takes place.
|
|
* @param {MergeOptions|null} mergeOptions Options for how to merge matching records between
|
|
* the source and destination table.
|
|
* @returns {string} The table id of the new or updated destination table.
|
|
*/
|
|
private async _transformAndFinishImport(docSession: OptDocSession,
|
|
hiddenTableId: string, destTableId: string,
|
|
intoNewTable: boolean, transformRule: TransformRule|null,
|
|
mergeOptions: MergeOptions|null): Promise<string> {
|
|
log.info("ActiveDocImport._transformAndFinishImport(%s, %s, %s, %s, %s)",
|
|
hiddenTableId, destTableId, intoNewTable, transformRule, mergeOptions);
|
|
const srcCols = await this._activeDoc.getTableCols(docSession, hiddenTableId);
|
|
|
|
// Use a default transform rule if one was not provided by the client.
|
|
if (!transformRule) {
|
|
const transformDest = intoNewTable ? null : destTableId;
|
|
transformRule = await this._makeDefaultTransformRule(docSession, srcCols, transformDest);
|
|
}
|
|
|
|
// Transform rules from client may have prefixed column ids, so we need to strip them.
|
|
stripRulePrefixes(transformRule);
|
|
|
|
if (intoNewTable) {
|
|
// Transform rules for new tables don't have filled in destination column ids.
|
|
const result = await this._activeDoc.applyUserActions(docSession, [['FillTransformRuleColIds', transformRule]]);
|
|
transformRule = result.retValues[0] as TransformRule;
|
|
|
|
// Encode Refs as Ints, to avoid table dependency issues. We'll convert back to Ref at the end.
|
|
encodeRuleReferences(transformRule);
|
|
} else if (transformRule.destCols.some(c => c.colId === null)) {
|
|
throw new Error('Column ids in transform rule must be filled when importing into an existing table');
|
|
}
|
|
|
|
await this._activeDoc.applyUserActions(docSession,
|
|
[['MakeImportTransformColumns', hiddenTableId, transformRule, false]]);
|
|
|
|
if (!intoNewTable && mergeOptions && mergeOptions.mergeCols.length > 0) {
|
|
await this._mergeAndFinishImport(docSession, hiddenTableId, destTableId, transformRule, mergeOptions);
|
|
return destTableId;
|
|
}
|
|
|
|
const hiddenTableData = fromTableDataAction(await this._activeDoc.fetchTable(docSession, hiddenTableId, true));
|
|
const columnData: BulkColValues = {};
|
|
|
|
const srcColIds = srcCols.map(c => c.id as string);
|
|
|
|
// Only include destination columns that weren't skipped.
|
|
const destCols = transformRule.destCols.filter(c => c.formula.trim() !== '');
|
|
for (const destCol of destCols) {
|
|
const formula = destCol.formula.trim();
|
|
if (!formula) { continue; }
|
|
|
|
const srcColId = formula.startsWith('$') && srcColIds.includes(formula.slice(1)) ?
|
|
formula.slice(1) : IMPORT_TRANSFORM_COLUMN_PREFIX + destCol.colId;
|
|
|
|
columnData[destCol.colId!] = hiddenTableData[srcColId];
|
|
}
|
|
|
|
// We no longer need the temporary import table, so remove it.
|
|
await this._activeDoc.applyUserActions(docSession, [['RemoveTable', hiddenTableId]]);
|
|
|
|
// If destination is a new table, we need to create it.
|
|
if (intoNewTable) {
|
|
const colSpecs = destCols.map(({type, colId: id, label, widgetOptions}) => ({type, id, label, widgetOptions}));
|
|
const newTable = await this._activeDoc.applyUserActions(docSession, [['AddTable', destTableId, colSpecs]]);
|
|
destTableId = newTable.retValues[0].table_id;
|
|
}
|
|
|
|
await this._activeDoc.applyUserActions(docSession,
|
|
[['BulkAddRecord', destTableId, gutil.arrayRepeat(hiddenTableData.id.length, null), columnData]],
|
|
// Don't use parseStrings for new tables to make the import lossless.
|
|
{parseStrings: !intoNewTable});
|
|
|
|
return destTableId;
|
|
}
|
|
|
|
/**
|
|
* Merges matching records from `hiddenTableId` into `destTableId`, and finalizes import.
|
|
*
|
|
* @param {string} hiddenTableId Source table containing records to be imported.
|
|
* @param {string} destTableId Destination table that will be updated.
|
|
* @param {TransformRule} transformRule Rules for transforming source columns using formulas
|
|
* before merging/importing takes place.
|
|
* @param {MergeOptions} mergeOptions Options for how to merge matching records between
|
|
* the source and destination table.
|
|
*/
|
|
private async _mergeAndFinishImport(docSession: OptDocSession, hiddenTableId: string, destTableId: string,
|
|
{destCols, sourceCols}: TransformRule,
|
|
{mergeCols, mergeStrategy}: MergeOptions): Promise<void> {
|
|
// Merge column ids from client have prefixes that need to be stripped.
|
|
mergeCols = stripPrefixes(mergeCols);
|
|
|
|
// Get column differences between `hiddenTableId` and `destTableId` for rows that exist in both tables.
|
|
const srcAndDestColIds: [string, string][] = destCols.map(destCol => {
|
|
const formula = destCol.formula.trim();
|
|
const srcColId = formula.startsWith('$') && sourceCols.includes(formula.slice(1)) ?
|
|
formula.slice(1) : IMPORT_TRANSFORM_COLUMN_PREFIX + destCol.colId;
|
|
return [srcColId, destCol.colId!];
|
|
});
|
|
const srcToDestColIds: Map<string, string[]> = new Map();
|
|
srcAndDestColIds.forEach(([srcColId, destColId]) => {
|
|
if (!srcToDestColIds.has(srcColId)) {
|
|
srcToDestColIds.set(srcColId, [destColId]);
|
|
} else {
|
|
srcToDestColIds.get(srcColId)!.push(destColId);
|
|
}
|
|
});
|
|
const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId, srcToDestColIds, mergeCols);
|
|
|
|
// Initialize containers for new and updated records in the expected formats.
|
|
const newRecords: BulkColValues = {};
|
|
let numNewRecords = 0;
|
|
const updatedRecords: BulkColValues = {};
|
|
const updatedRecordIds: number[] = [];
|
|
|
|
// Destination columns with a blank formula (i.e. skipped columns).
|
|
const skippedColumnIds = new Set(
|
|
stripPrefixes(destCols.filter(c => c.formula.trim() === '').map(c => c.colId!))
|
|
);
|
|
|
|
// Remove all skipped columns from the map.
|
|
srcToDestColIds.forEach((destColIds, srcColId) => {
|
|
srcToDestColIds.set(srcColId, destColIds.filter(id => !skippedColumnIds.has(id)));
|
|
});
|
|
|
|
const destColIds = flatten([...srcToDestColIds.values()]);
|
|
for (const id of destColIds) {
|
|
newRecords[id] = [];
|
|
updatedRecords[id] = [];
|
|
}
|
|
|
|
// Retrieve the function used to reconcile differences between source and destination.
|
|
const merge = getMergeFunction(mergeStrategy);
|
|
|
|
const srcColIds = [...srcToDestColIds.keys()];
|
|
const numResultRows = comparisonResult[hiddenTableId + '.id'].length;
|
|
for (let i = 0; i < numResultRows; i++) {
|
|
if (comparisonResult[destTableId + '.id'][i] === null) {
|
|
// No match in destination table found for source row, so it must be a new record.
|
|
for (const srcColId of srcColIds) {
|
|
const matchingDestColIds = srcToDestColIds.get(srcColId);
|
|
matchingDestColIds!.forEach(id => {
|
|
newRecords[id].push(comparisonResult[`${hiddenTableId}.${srcColId}`][i]);
|
|
});
|
|
}
|
|
numNewRecords++;
|
|
} else {
|
|
// Otherwise, a match was found between source and destination tables, so we merge their columns.
|
|
for (const srcColId of srcColIds) {
|
|
const matchingDestColIds = srcToDestColIds.get(srcColId);
|
|
const srcVal = comparisonResult[`${hiddenTableId}.${srcColId}`][i];
|
|
matchingDestColIds!.forEach(id => {
|
|
const destVal = comparisonResult[`${destTableId}.${id}`][i];
|
|
updatedRecords[id].push(merge(srcVal, destVal));
|
|
});
|
|
}
|
|
updatedRecordIds.push(comparisonResult[destTableId + '.id'][i] as number);
|
|
}
|
|
}
|
|
|
|
// We no longer need the temporary import table, so remove it.
|
|
const actions: UserAction[] = [['RemoveTable', hiddenTableId]];
|
|
|
|
if (updatedRecordIds.length > 0) {
|
|
actions.push(['BulkUpdateRecord', destTableId, updatedRecordIds, updatedRecords]);
|
|
}
|
|
|
|
if (numNewRecords > 0) {
|
|
actions.push(['BulkAddRecord', destTableId, gutil.arrayRepeat(numNewRecords, null), newRecords]);
|
|
}
|
|
|
|
await this._activeDoc.applyUserActions(docSession, actions, {parseStrings: true});
|
|
}
|
|
|
|
/**
|
|
* Builds and executes a SQL query that compares common columns from `hiddenTableId`
|
|
* and `destTableId`, returning matched rows that contain differences between both tables.
|
|
*
|
|
* The `mergeCols` parameter defines how rows from both tables are matched; we consider
|
|
* rows whose columns values for all columns in `mergeCols` to be the same record in both
|
|
* tables.
|
|
*
|
|
* @param {string} hiddenTableId Source table.
|
|
* @param {string} destTableId Destination table.
|
|
* @param {Map<string, string[]>} srcToDestColIds Map of source to one or more destination column ids
|
|
* to include in the comparison results.
|
|
* @param {string[]} mergeCols List of (destination) column ids to use for matching.
|
|
* @returns {Promise<BulkColValues} Decoded column values from both tables that were matched, and had differences.
|
|
*/
|
|
private async _getTableComparison(hiddenTableId: string, destTableId: string, srcToDestColIds: Map<string, string[]>,
|
|
mergeCols: string[]): Promise<BulkColValues> {
|
|
const mergeColIds = new Set(mergeCols);
|
|
const destToSrcMergeColIds = new Map();
|
|
srcToDestColIds.forEach((destColIds, srcColId) => {
|
|
const maybeMergeColId = destColIds.find(colId => mergeColIds.has(colId));
|
|
if (maybeMergeColId !== undefined) {
|
|
destToSrcMergeColIds.set(maybeMergeColId, srcColId);
|
|
}
|
|
});
|
|
|
|
const query = buildComparisonQuery(hiddenTableId, destTableId, srcToDestColIds, destToSrcMergeColIds);
|
|
const result = await this._activeDoc.docStorage.fetchQuery(query);
|
|
return this._activeDoc.docStorage.decodeMarshalledDataFromTables(result);
|
|
}
|
|
|
|
/**
|
|
* Returns a default TransformRule using column definitions from `destTableId`. If `destTableId`
|
|
* is null (in the case when the import destination is a new table), the `srcCols` are used instead.
|
|
*
|
|
* @param {TableRecordValue[]} srcCols Source column definitions.
|
|
* @param {string|null} destTableId The destination table id. If null, the destination is assumed
|
|
* to be a new table, and `srcCols` are used to build the transform rule.
|
|
* @returns {Promise<TransformRule>} The constructed transform rule.
|
|
*/
|
|
private async _makeDefaultTransformRule(docSession: OptDocSession, srcCols: TableRecordValue[],
|
|
destTableId: string|null): Promise<TransformRule> {
|
|
const targetCols = destTableId ? await this._activeDoc.getTableCols(docSession, destTableId) : srcCols;
|
|
const destCols: TransformColumn[] = [];
|
|
const srcColIds = srcCols.map(c => c.id as string);
|
|
|
|
for (const {id, fields} of targetCols) {
|
|
if (fields.isFormula === true || fields.formula !== '') { continue; }
|
|
|
|
destCols.push({
|
|
colId: destTableId ? id as string : null,
|
|
label: fields.label as string,
|
|
type: fields.type as string,
|
|
widgetOptions: fields.widgetOptions as string,
|
|
formula: srcColIds.includes(id as string) ? `$${id}` : ''
|
|
});
|
|
}
|
|
|
|
return {
|
|
destTableId,
|
|
destCols,
|
|
sourceCols: srcColIds
|
|
};
|
|
}
|
|
|
|
/**
|
|
* This function removes temporary hidden tables which were created during the import process
|
|
*
|
|
* @param {Array[String]} hiddenTableIds: Array of hidden table ids
|
|
* @returns {Promise} Promise that's resolved when all actions are applied successfully.
|
|
*/
|
|
private async _removeHiddenTables(docSession: DocSession, hiddenTableIds: string[]) {
|
|
if (hiddenTableIds.length !== 0) {
|
|
await this._activeDoc.applyUserActions(docSession, hiddenTableIds.map(t => ['RemoveTable', t]));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Changes every column of references into a column of integers in `parsedTables`. It
|
|
* returns a list of descriptors of all columns of references.
|
|
*/
|
|
private _encodeReferenceAsInt(parsedTables: GristTable[]): ReferenceDescription[] {
|
|
const references = [];
|
|
for (const [tableIndex, parsedTable] of parsedTables.entries()) {
|
|
for (const [colIndex, col] of parsedTable.column_metadata.entries()) {
|
|
const refTableId = gutil.removePrefix(col.type, "Ref:");
|
|
if (refTableId) {
|
|
references.push({refTableId, colIndex, tableIndex});
|
|
col.type = 'Int';
|
|
}
|
|
}
|
|
}
|
|
return references;
|
|
}
|
|
|
|
/**
|
|
* This function fix references that are broken by the change of table id.
|
|
*/
|
|
private async _fixReferences(docSession: OptDocSession,
|
|
tables: ImportTableResult[],
|
|
fixedColumnIds: { [tableId: string]: string[]; },
|
|
references: ReferenceDescription[],
|
|
isHidden: boolean) {
|
|
|
|
// collect all new table ids
|
|
const tablesByOrigName = _.indexBy(tables, 'origTableName');
|
|
|
|
// gather all of the user actions
|
|
let userActions: any[] = references.map( ref => {
|
|
const fixedTableId = tables[ref.tableIndex].hiddenTableId;
|
|
return [
|
|
'ModifyColumn',
|
|
fixedTableId,
|
|
fixedColumnIds[fixedTableId][ref.colIndex],
|
|
{ type: `Ref:${tablesByOrigName[ref.refTableId].hiddenTableId}` }
|
|
];
|
|
});
|
|
|
|
if (isHidden) {
|
|
userActions = userActions.concat(userActions.map(([, tableId, columnId, colInfo]) => [
|
|
'ModifyColumn', tableId, IMPORT_TRANSFORM_COLUMN_PREFIX + columnId, colInfo ]));
|
|
}
|
|
|
|
// apply user actions
|
|
if (userActions.length) {
|
|
await this._activeDoc.applyUserActions(docSession, userActions);
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// Helper function that returns true if a given cell is blank (i.e. null or empty).
|
|
function isBlank(value: CellValue): boolean {
|
|
return value === null || (typeof value === 'string' && value.trim().length === 0);
|
|
}
|
|
|
|
/**
|
|
* Changes every Ref column to an Int column in `destCols`.
|
|
*
|
|
* Encoding references as ints can be useful when finishing imports to avoid
|
|
* issues such as importing linked tables in the wrong order. When encoding references,
|
|
* ActiveDocImport._fixReferences should be called at the end of importing to
|
|
* decode Ints back to Refs.
|
|
*/
|
|
function encodeRuleReferences({destCols}: TransformRule): void {
|
|
for (const col of destCols) {
|
|
const refTableId = gutil.removePrefix(col.type, "Ref:");
|
|
if (refTableId) {
|
|
col.type = 'Int';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function that strips import prefixes from columns in transform rules (if ids are present).
|
|
function stripRulePrefixes({destCols}: TransformRule): void {
|
|
for (const col of destCols) {
|
|
const colId = col.colId;
|
|
if (colId && colId.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX)) {
|
|
col.colId = colId.slice(IMPORT_TRANSFORM_COLUMN_PREFIX.length);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function that returns new `colIds` with import prefixes stripped.
|
|
function stripPrefixes(colIds: string[]): string[] {
|
|
return colIds.map(id => id.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX) ?
|
|
id.slice(IMPORT_TRANSFORM_COLUMN_PREFIX.length) : id);
|
|
}
|
|
|
|
type MergeFunction = (srcVal: CellValue, destVal: CellValue) => CellValue;
|
|
|
|
/**
|
|
* Returns a function that maps source and destination column values to a single output value.
|
|
*
|
|
* @param {MergeStrategy} mergeStrategy Determines how matching source and destination column values
|
|
* should be reconciled when merging.
|
|
* @returns {MergeFunction} Function that maps column value pairs to a single output value.
|
|
*/
|
|
function getMergeFunction({type}: MergeStrategy): MergeFunction {
|
|
switch (type) {
|
|
case 'replace-with-nonblank-source': {
|
|
return (srcVal, destVal) => isBlank(srcVal) ? destVal : srcVal;
|
|
}
|
|
case 'replace-all-fields': {
|
|
return (srcVal, _destVal) => srcVal;
|
|
}
|
|
case 'replace-blank-fields-only': {
|
|
return (srcVal, destVal) => isBlank(destVal) ? srcVal : destVal;
|
|
}
|
|
default: {
|
|
// Normally, we should never arrive here. If we somehow do, throw an error.
|
|
const unknownStrategyType: never = type;
|
|
throw new Error(`Unknown merge strategy: ${unknownStrategyType}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tweak the column metadata used in the AddTable action.
|
|
* If `columns` is populated with non-blank column ids, adds labels to all
|
|
* columns using the values set for the column ids.
|
|
* For columns of type Any, guess the type and parse data according to it, or mark as empty
|
|
* formula columns when they should be empty.
|
|
*/
|
|
function cleanColumnMetadata(columns: GristColumn[], tableData: unknown[][], activeDoc: ActiveDoc) {
|
|
return columns.map((c, index) => {
|
|
const newCol: any = {...c};
|
|
if (c.id) {
|
|
newCol.label = c.id;
|
|
}
|
|
if (c.type === "Any") {
|
|
// If import logic left it to us to decide on column type, then use our guessing logic to
|
|
// pick a suitable type and widgetOptions, and to convert values to it.
|
|
const origValues = tableData[index] as CellValue[];
|
|
const {values, colMetadata} = guessColInfoForImports(origValues, activeDoc.docData!);
|
|
tableData[index] = values;
|
|
if (colMetadata) {
|
|
Object.assign(newCol, colMetadata);
|
|
}
|
|
}
|
|
return newCol;
|
|
});
|
|
}
|