(core) Converting big number (9 digits or more) to date directly

Summary:
Interpret huge numbers (>8 digits) as timestamps when converting numeric column to date.
Convert date/date time columns to timestamp when converted from numeric/int column.

Test Plan: Updated

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: dsagal, alexmojaki

Differential Revision: https://phab.getgrist.com/D4030
This commit is contained in:
Jarosław Sadziński 2023-10-05 08:48:14 +02:00
parent 498ad07d38
commit ad299f338a
5 changed files with 77 additions and 7 deletions

View File

@ -11,6 +11,7 @@ import {csvDecodeRow} from 'app/common/csvFormat';
import * as gristTypes from 'app/common/gristTypes'; import * as gristTypes from 'app/common/gristTypes';
import {isFullReferencingType} from 'app/common/gristTypes'; import {isFullReferencingType} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil'; import * as gutil from 'app/common/gutil';
import {isNonNullish} from 'app/common/gutil';
import NumberParse from 'app/common/NumberParse'; import NumberParse from 'app/common/NumberParse';
import {dateTimeWidgetOptions, guessDateFormat, timeFormatOptions} from 'app/common/parseDate'; import {dateTimeWidgetOptions, guessDateFormat, timeFormatOptions} from 'app/common/parseDate';
import {TableData} from 'app/common/TableData'; import {TableData} from 'app/common/TableData';
@ -134,7 +135,8 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe
if (!dateFormat) { if (!dateFormat) {
// Guess date and time format if there aren't any already // Guess date and time format if there aren't any already
const colValues = tableData.getColValues(sourceCol.colId()) || []; const colValues = tableData.getColValues(sourceCol.colId()) || [];
dateFormat = guessDateFormat(colValues.map(String)); const strValues = colValues.map(v => isNonNullish(v) ? String(v) : null);
dateFormat = guessDateFormat(strValues);
widgetOptions = {...widgetOptions, ...(dateTimeWidgetOptions(dateFormat, true))}; widgetOptions = {...widgetOptions, ...(dateTimeWidgetOptions(dateFormat, true))};
} }
if (toType === 'DateTime' && !widgetOptions.timeFormat) { if (toType === 'DateTime' && !widgetOptions.timeFormat) {

View File

@ -928,6 +928,13 @@ export function isNonNullish<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined; return value !== null && value !== undefined;
} }
/**
* Ensures that a value is truthy, with a type guard for the return type.
*/
export function truthy<T>(value: T | null | undefined): value is Exclude<T, false | "" | 0> {
return Boolean(value);
}
/** /**
* Returns the value of both grainjs and knockout observable without creating a dependency. * Returns the value of both grainjs and knockout observable without creating a dependency.
*/ */

View File

@ -108,6 +108,13 @@ export function parseDate(date: string, options: ParseOptions = {}): number | nu
if (!date) { if (!date) {
return null; return null;
} }
// If this looks like a timestamp (string with 9 or more digits), just return it.
const timestamp = parseTimeStamp(date);
if (timestamp !== null) {
return timestamp;
}
const dateFormat = options.dateFormat || "YYYY-MM-DD"; const dateFormat = options.dateFormat || "YYYY-MM-DD";
const dateFormats = [..._buildVariations(dateFormat, date), ...PARSER_FORMATS]; const dateFormats = [..._buildVariations(dateFormat, date), ...PARSER_FORMATS];
const cleanDate = date.replace(SEPARATORS, ' '); const cleanDate = date.replace(SEPARATORS, ' ');
@ -125,11 +132,11 @@ export function parseDate(date: string, options: ParseOptions = {}): number | nu
datetime += ' ' + time + tzOffset; datetime += ' ' + time + tzOffset;
timeformat = ' HH:mm:ss' + (tzOffset ? 'Z' : ''); timeformat = ' HH:mm:ss' + (tzOffset ? 'Z' : '');
} }
for (const f of dateFormats) { for (const format of dateFormats) {
const fullFormat = f + timeformat; const fullFormat = format + timeformat;
const m = moment.tz(datetime, fullFormat, true, options.timezone || 'UTC'); const m = moment.tz(datetime, fullFormat, true, options.timezone || 'UTC');
if (m.isValid()) { if (m.isValid()) {
return m.valueOf() / 1000; return m.unix();
} }
} }
return null; return null;
@ -149,6 +156,11 @@ export function parseDateStrict(
if (!date) { if (!date) {
return; return;
} }
// If this looks like a timestamp (string with 9 or more digits), just return it.
const timestamp = parseTimeStamp(date);
if (timestamp !== null) {
return timestamp;
}
dateFormat = dateFormat || "YYYY-MM-DD"; dateFormat = dateFormat || "YYYY-MM-DD";
const dateFormats = [..._buildVariations(dateFormat, date), ...UNAMBIGUOUS_FORMATS]; const dateFormats = [..._buildVariations(dateFormat, date), ...UNAMBIGUOUS_FORMATS];
const cleanDate = date.replace(SEPARATORS, ' ').trim(); const cleanDate = date.replace(SEPARATORS, ' ').trim();
@ -419,3 +431,22 @@ export function dateTimeWidgetOptions(fullFormat: string, defaultTimeFormat: boo
isCustomTimeFormat: !timeFormatOptions.includes(timeFormat), isCustomTimeFormat: !timeFormatOptions.includes(timeFormat),
}; };
} }
/**
* Attempts to parse a timestamp string. Returns the timestamp in seconds
* since epoch, or returns null on failure. Accepts only strings with 9 to 11 digits.
* Lowest 11 digit timestamp is 2286-11-20, so we don't consider them valid.
*/
export function parseTimeStamp(date: string): number | null {
// If this looks like a timestamp (number with 9 or more digits), just return it.
// This covers most of the cases leaving some time around the unix epoch not covered.
// So time before 100 000 000 (1974-04-26) is not covered. Also negative values
// are also not supported, as they overlap with the YYYYYY date format.
if (date && /^[1-9]\d{8,9}$/.test(date)) {
const parsedDate = moment(date, 'X');
if (parsedDate.isValid()) {
return parsedDate.unix();
}
}
return null;
}

View File

@ -19,7 +19,7 @@ const month = String(today.getUTCMonth() + 1).padStart(2, '0');
* Otherwise, parseDateStrict should return a result * Otherwise, parseDateStrict should return a result
* unless no dateFormat is given in which case it may or may not. * unless no dateFormat is given in which case it may or may not.
*/ */
function testParse(dateFormat: string|null, input: string, expectedDateStr: string, fallback: boolean = false) { function testParse(dateFormat: string|null, input: string, expectedDateStr: string|null, fallback: boolean = false) {
assertDateEqual(parseDate(input, dateFormat ? {dateFormat} : {}), expectedDateStr); assertDateEqual(parseDate(input, dateFormat ? {dateFormat} : {}), expectedDateStr);
const strict = new Set<number>(); const strict = new Set<number>();
@ -41,7 +41,7 @@ function testParse(dateFormat: string|null, input: string, expectedDateStr: stri
} }
} }
function assertDateEqual(parsed: number|null, expectedDateStr: string) { function assertDateEqual(parsed: number|null, expectedDateStr: string|null) {
const formatted = parsed === null ? null : new Date(parsed * 1000).toISOString().slice(0, 10); const formatted = parsed === null ? null : new Date(parsed * 1000).toISOString().slice(0, 10);
assert.equal(formatted, expectedDateStr); assert.equal(formatted, expectedDateStr);
} }
@ -94,6 +94,7 @@ function testDateTimeStringParse(
describe('parseDate', function() { describe('parseDate', function() {
this.timeout(5000); this.timeout(5000);
this.slow(50);
it('should allow parsing common date formats', function() { it('should allow parsing common date formats', function() {
testParse(null, 'November 18th, 1994', '1994-11-18'); testParse(null, 'November 18th, 1994', '1994-11-18');
@ -411,6 +412,35 @@ describe('parseDate', function() {
testParse('MM-DD-YY', `1/2/98`, `1998-01-02`); testParse('MM-DD-YY', `1/2/98`, `1998-01-02`);
}); });
it('should parse timestamps as dates', function() {
testParse(null, '123456789', '1973-11-29');
testParse(null, '100000000', '1973-03-03');
testParse(null, '1000000000', '2001-09-09');
testParse(null, '10000000000', null);
testParse(null, '20230926', null);
testParse(null, '12345678', null);
testParse(null, '-1000000', null);
testParse(null, '-9999999', null);
testParse(null, '123456789.0', null);
testParse(null, '-100000000', null);
testParse(null, '100000000000', null);
testParse(null, '1000000000000', null);
// Test exact times.
assert.equal(parseDate( '123456789'), 123456789);
assert.equal(parseDate( '100000000'), 100000000);
// Now those that don't fit into our format.
assert.isNull(parseDate('1234567'));
assert.isNull(parseDate('-999999'));
assert.isNull(parseDate('12345678.0'));
assert.isNull(parseDate('-100000000'));
assert.isNull(parseDate('100000000000'));
assert.isNull(parseDate('1000000000000'));
});
describe('guessDateFormat', function() { describe('guessDateFormat', function() {
it('should guess date formats', function() { it('should guess date formats', function() {
// guessDateFormats with an *s* shows all the equally likely guesses. // guessDateFormats with an *s* shows all the equally likely guesses.

View File

@ -1191,7 +1191,7 @@ export async function renameTable(tableId: string, newName: string) {
/** /**
* Rename the given column. * Rename the given column.
*/ */
export async function renameColumn(col: IColHeader, newName: string) { export async function renameColumn(col: IColHeader|string, newName: string) {
const header = await getColumnHeader(col); const header = await getColumnHeader(col);
await header.click(); await header.click();
await header.click(); // Second click opens the label for editing. await header.click(); // Second click opens the label for editing.