Skip to content

Commit 4bb3340

Browse files
authored
Merge pull request #681 from Marvell-Consulting/SW-1229-Prevent-bad-sort-orders
Prevent non-numerical sort orders
2 parents 0317868 + bea174f commit 4bb3340

6 files changed

Lines changed: 143 additions & 21 deletions

File tree

src/interfaces/filter-row.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ export interface FilterRow {
55
dimension_name: string;
66
description: string;
77
hierarchy: string;
8-
sort_order?: string | null;
8+
sort_order?: string | null; // Backed by a BIGINT column in the DB, but represented here as a string.
99
reference_count?: string | null;
1010
}

src/services/cube-builder.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ function createCubeBaseTables(revisionId: string, buildId: string, factTableQuer
575575
fact_table_column VARCHAR,
576576
dimension_name VARCHAR,
577577
description VARCHAR,
578-
sort_order TEXT,
578+
sort_order BIGINT,
579579
hierarchy VARCHAR,
580580
reference_count BIGINT DEFAULT 0,
581581
PRIMARY KEY (reference, language, fact_table_column)
@@ -1266,7 +1266,7 @@ function setupMeasureAndDataValuesNoLookup(
12661266
);
12671267
statements.push(
12681268
pgformat(
1269-
`INSERT INTO %I.%I SELECT DISTINCT CAST(%I AS VARCHAR), %L, %L, %L, CAST(%I AS VARCHAR), NULL, NULL, 0 FROM %I.%I ORDER BY %I;`,
1269+
`INSERT INTO %I.%I SELECT DISTINCT CAST(%I AS VARCHAR), %L, %L, %L, CAST(%I AS VARCHAR), NULL::BIGINT, NULL, 0 FROM %I.%I ORDER BY %I;`,
12701270
buildId,
12711271
FILTER_TABLE_NAME,
12721272
measureColumn.columnName,
@@ -1303,7 +1303,7 @@ export const measureTableCreateStatement = (
13031303
language TEXT,
13041304
description TEXT,
13051305
notes TEXT,
1306-
sort_order TEXT,
1306+
sort_order BIGINT,
13071307
format TEXT,
13081308
decimals INTEGER,
13091309
measure_type TEXT,
@@ -1522,7 +1522,7 @@ function setupMeasureAndDataValuesWithLookup(
15221522
const columnName = t('column_headers.measure', { lng: locale });
15231523
statements.push(
15241524
pgformat(
1525-
`INSERT INTO %I.%I SELECT CAST(reference AS VARCHAR), language, %L, %L, description, sort_order, CAST(hierarchy AS VARCHAR), 0 FROM %I.measure WHERE language = %L ORDER BY sort_order, reference;`,
1525+
`INSERT INTO %I.%I SELECT CAST(reference AS VARCHAR), language, %L, %L, description, CAST (sort_order AS BIGINT), CAST(hierarchy AS VARCHAR), 0 FROM %I.measure WHERE language = %L ORDER BY sort_order, reference;`,
15261526
buildId,
15271527
FILTER_TABLE_NAME,
15281528
measureColumn.columnName,
@@ -1582,7 +1582,7 @@ function rawDimensionProcessor(
15821582
statements.push(
15831583
pgformat(
15841584
`INSERT INTO %I.%I
1585-
SELECT DISTINCT CAST(%I AS VARCHAR), %L, %L, %L, CAST (%I AS VARCHAR), NULL, NULL, 0
1585+
SELECT DISTINCT CAST(%I AS VARCHAR), %L, %L, %L, CAST (%I AS VARCHAR), NULL::BIGINT, NULL, 0
15861586
FROM %I.%I ORDER BY %I;`,
15871587
buildId,
15881588
FILTER_TABLE_NAME,
@@ -1708,7 +1708,7 @@ export function setupLookupTableDimension(
17081708
`INSERT INTO %I.%I
17091709
SELECT reference, language, fact_table_column, dimension_name, description, sort_order, hierarchy, 0
17101710
FROM (SELECT DISTINCT
1711-
CAST(%I AS VARCHAR) AS reference, language, %L AS fact_table_column, %L AS dimension_name, description, sort_order AS sort_order, hierarchy
1711+
CAST(%I AS VARCHAR) AS reference, language, %L AS fact_table_column, %L AS dimension_name, description, CAST (sort_order AS BIGINT) AS sort_order, hierarchy
17121712
FROM %I.%I
17131713
WHERE language = %L
17141714
ORDER BY sort_order %s, description);`,
@@ -1889,7 +1889,7 @@ function setupTextDimension(
18891889
statements.push(
18901890
pgformat(
18911891
`INSERT INTO %I.%I
1892-
SELECT DISTINCT CAST(%I AS VARCHAR), %L, %L, %L, CAST (%I AS VARCHAR), NULL, NULL, 0
1892+
SELECT DISTINCT CAST(%I AS VARCHAR), %L, %L, %L, CAST (%I AS VARCHAR), NULL::BIGINT, NULL, 0
18931893
FROM %I.%I;`,
18941894
buildId,
18951895
FILTER_TABLE_NAME,
@@ -2086,9 +2086,17 @@ export function updateFilterTableCounts(buildId: string, factTable: FactTableCol
20862086
};
20872087
}
20882088

2089-
function alterFilterTableBlock(cubeId: string): TransactionBlock {
2089+
export function alterFilterTableBlock(cubeId: string): TransactionBlock {
20902090
const statements: string[] = ['BEGIN TRANSACTION;'];
2091-
statements.push(pgformat('ALTER TABLE %I.%I ADD IF NOT EXISTS sort_order TEXT;', cubeId, FILTER_TABLE_NAME));
2091+
statements.push(pgformat('ALTER TABLE %I.%I ADD IF NOT EXISTS sort_order BIGINT;', cubeId, FILTER_TABLE_NAME));
2092+
// Migrate existing TEXT columns to BIGINT; non-numeric values are nulled out rather than failing.
2093+
statements.push(
2094+
pgformat(
2095+
`ALTER TABLE %I.%I ALTER COLUMN sort_order TYPE BIGINT USING (CASE WHEN sort_order::TEXT ~ '^-?[0-9]+$' THEN (sort_order::TEXT)::BIGINT ELSE NULL END);`,
2096+
cubeId,
2097+
FILTER_TABLE_NAME
2098+
)
2099+
);
20922100
statements.push(
20932101
pgformat('ALTER TABLE %I.%I ADD IF NOT EXISTS reference_count BIGINT DEFAULT 0;', cubeId, FILTER_TABLE_NAME)
20942102
);
@@ -2105,7 +2113,7 @@ function createFilterTableSortOrders(cubeId: string, dataset: Dataset): Transact
21052113
if (!dim.lookupTable) continue;
21062114
statements.push(
21072115
pgformat(
2108-
'UPDATE %I.%I SET sort_order = lookup.sort_order FROM (SELECT CAST(%I AS VARCHAR) AS reference, language, sort_order FROM %I.%I) AS lookup WHERE %I.reference = lookup.reference AND %I.language = lookup.language AND %I.fact_table_column = %L;',
2116+
'UPDATE %I.%I SET sort_order = lookup.sort_order FROM (SELECT CAST(%I AS VARCHAR) AS reference, language, CAST (sort_order AS BIGINT) as sort_order FROM %I.%I) AS lookup WHERE %I.reference = lookup.reference AND %I.language = lookup.language AND %I.fact_table_column = %L;',
21092117
cubeId,
21102118
FILTER_TABLE_NAME,
21112119
dim.factTableColumn,
@@ -2122,7 +2130,7 @@ function createFilterTableSortOrders(cubeId: string, dataset: Dataset): Transact
21222130
if (dataset.measure?.lookupTable) {
21232131
statements.push(
21242132
pgformat(
2125-
'UPDATE %I.%I SET sort_order = lookup.sort_order FROM (SELECT CAST(reference AS VARCHAR) AS reference, language, sort_order FROM %I.measure) AS lookup WHERE %I.reference = lookup.reference AND %I.language = lookup.language AND %I.fact_table_column = %L;',
2133+
'UPDATE %I.%I SET sort_order = lookup.sort_order FROM (SELECT CAST(reference AS VARCHAR) AS reference, language, CAST (sort_order AS BIGINT) AS sort_order FROM %I.measure) AS lookup WHERE %I.reference = lookup.reference AND %I.language = lookup.language AND %I.fact_table_column = %L;',
21262134
cubeId,
21272135
FILTER_TABLE_NAME,
21282136
cubeId,

src/services/lookup-table-handler.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,19 @@ export const validateLookupTable = async (
151151
lookupReferenceColumn
152152
);
153153
} catch (err) {
154-
logger.error(err, `Something went wrong trying to convert the lookup table to SW3 format`);
155-
return viewErrorGenerators(500, dataset.id, 'patch', 'errors.dimension_validation.lookup_table_loading_failed', {
156-
mismatch: false
154+
void cleanUpPostgresValidationSchema(mockCubeId, lookupTable.id).catch((cleanupErr) => {
155+
logger.error(cleanupErr, 'Something went wrong trying to clean up the mock cube');
157156
});
157+
if (err instanceof Error && err.message.includes('invalid input syntax for type bigint')) {
158+
return viewErrorGenerators(400, dataset.id, 'patch', 'errors.dimension_validation.sort_contains_text', {
159+
mismatch: false
160+
});
161+
} else {
162+
logger.error(err, `Something went wrong trying to convert the lookup table to SW3 format`);
163+
return viewErrorGenerators(500, dataset.id, 'patch', 'errors.dimension_validation.lookup_table_loading_failed', {
164+
mismatch: false
165+
});
166+
}
158167
}
159168

160169
const hierarchyErrors = await validateLookupTableHierarchyValues(
@@ -190,6 +199,9 @@ export const validateLookupTable = async (
190199
await saveValidatedLookupTableToDatabase(mockCubeId, lookupTable.id);
191200
} catch (err) {
192201
logger.error(err, 'Something went wrong trying to save the lookup table or clean up the mock cube.');
202+
void cleanUpPostgresValidationSchema(mockCubeId, lookupTable.id).catch((cleanupErr) => {
203+
logger.error(cleanupErr, 'Something went wrong trying to clean up the mock cube');
204+
});
193205
return viewErrorGenerators(500, dataset.id, 'patch', `errors.lookup_table_validation.unknown_error`, {});
194206
}
195207

src/utils/file-utils.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export const createLookupTableQuery = (
8181
});
8282
}
8383
return pgformat(
84-
'CREATE TABLE %I.%I (%I %s NOT NULL, language VARCHAR(5) NOT NULL, description TEXT NOT NULL, notes TEXT, sort_order INTEGER, hierarchy VARCHAR %s);',
84+
'CREATE TABLE %I.%I (%I %s NOT NULL, language VARCHAR(5) NOT NULL, description TEXT NOT NULL, notes TEXT, sort_order BIGINT, hierarchy VARCHAR %s);',
8585
schemaName,
8686
lookupTableName,
8787
referenceColumnName,
@@ -122,7 +122,7 @@ export async function loadFileIntoLookupTablesSchema(
122122
);
123123
const notesCol = extractor.notesColumns?.find((col) => col.lang.toLowerCase() === locale.toLowerCase());
124124
const notesColStr = notesCol ? pgformat('%I', notesCol.name) : 'NULL';
125-
const sortStr = extractor.sortColumn ? pgformat('%I', extractor.sortColumn) : 'NULL';
125+
const sortStr = extractor.sortColumn ? pgformat('CAST (%I AS BIGINT)', extractor.sortColumn) : 'NULL::BIGINT';
126126
const hierarchyCol = hierarchySelectExpression(extractor.hierarchyColumn);
127127
dataExtractorParts.push(
128128
pgformat(
@@ -149,7 +149,7 @@ export async function loadFileIntoLookupTablesSchema(
149149
} else {
150150
const languageMatcher = languageMatcherCaseStatement(extractor.languageColumn);
151151
const notesStr = extractor.notesColumns ? pgformat('%I', extractor.notesColumns[0].name) : 'NULL';
152-
const sortStr = extractor.sortColumn ? pgformat('%I', extractor.sortColumn) : 'NULL';
152+
const sortStr = extractor.sortColumn ? pgformat('CAST (%I AS BIGINT)', extractor.sortColumn) : 'NULL::BIGINT';
153153
const hierarchyStr = hierarchySelectExpression(extractor.hierarchyColumn);
154154
const dataExtractorParts = pgformat(
155155
`SELECT %I AS %I, %s as language, %I as description, %s as notes, %s as sort_order, %s as hierarchy FROM %I.%I;`,
@@ -247,7 +247,7 @@ export async function convertLookupTableToSW3Format(
247247
);
248248
const notesCol = extractor.notesColumns?.find((col) => col.lang.toLowerCase() === locale.toLowerCase());
249249
const notesColStr = notesCol ? pgformat('%I', notesCol.name) : 'NULL';
250-
const sortStr = extractor.sortColumn ? pgformat('%I', extractor.sortColumn) : 'NULL';
250+
const sortStr = extractor.sortColumn ? pgformat('CAST (%I AS BIGINT)', extractor.sortColumn) : 'NULL::BIGINT';
251251
const hierarchyCol = hierarchySelectExpression(extractor.hierarchyColumn);
252252
dataExtractorParts.push(
253253
pgformat(
@@ -269,7 +269,7 @@ export async function convertLookupTableToSW3Format(
269269
} else {
270270
const languageMatcher = languageMatcherCaseStatement(extractor.languageColumn);
271271
const notesStr = extractor.notesColumns ? pgformat('%I', extractor.notesColumns[0].name) : 'NULL';
272-
const sortStr = extractor.sortColumn ? pgformat('%I', extractor.sortColumn) : 'NULL';
272+
const sortStr = extractor.sortColumn ? pgformat('CAST (%I AS BIGINT)', extractor.sortColumn) : 'NULL::BIGINT';
273273
const hierarchyStr = hierarchySelectExpression(extractor.hierarchyColumn);
274274
const dataExtractorParts = pgformat(
275275
`SELECT %I AS %I, %s as language, %I as description, %s as notes, %s as sort_order, %s as hierarchy %s FROM %I.%I;`,

test/unit/services/cube-builder.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
setupCubeBuilder,
5656
setupLookupTableDimension,
5757
updateFilterTableCounts,
58+
alterFilterTableBlock,
5859
FACT_TABLE_NAME,
5960
FILTER_TABLE_NAME,
6061
METADATA_TABLE_NAME,
@@ -370,7 +371,7 @@ describe('measureTableCreateStatement', () => {
370371
expect(sql).toContain('hierarchy TEXT');
371372
expect(sql).toContain('language TEXT');
372373
expect(sql).toContain('description TEXT');
373-
expect(sql).toContain('sort_order TEXT');
374+
expect(sql).toContain('sort_order BIGINT');
374375
expect(sql).toContain('format TEXT');
375376
expect(sql).toContain('decimals INTEGER');
376377
expect(sql).toContain('measure_type TEXT');
@@ -830,6 +831,26 @@ describe('setupLookupTableDimension — filter table sort direction', () => {
830831
});
831832
});
832833
}
834+
835+
describe('sort_order BIGINT casting', () => {
836+
it('casts sort_order as BIGINT in the filter_table INSERT for lookup table dimensions', () => {
837+
const { stmts } = callSetup(DimensionType.LookupTable);
838+
const inserts = filterInserts(stmts);
839+
expect(inserts.length).toBeGreaterThan(0);
840+
for (const stmt of inserts) {
841+
expect(stmt).toContain('CAST (sort_order AS BIGINT)');
842+
}
843+
});
844+
845+
it('casts sort_order as BIGINT for date-period lookup table dimensions too', () => {
846+
const { stmts } = callSetup(DimensionType.DatePeriod);
847+
const inserts = filterInserts(stmts);
848+
expect(inserts.length).toBeGreaterThan(0);
849+
for (const stmt of inserts) {
850+
expect(stmt).toContain('CAST (sort_order AS BIGINT)');
851+
}
852+
});
853+
});
833854
});
834855

835856
// ===========================================================================
@@ -935,3 +956,48 @@ describe('updateFilterTableCounts', () => {
935956
expect(countUpdates).toHaveLength(0);
936957
});
937958
});
959+
960+
// ===========================================================================
961+
describe('alterFilterTableBlock', () => {
962+
const CUBE_ID = 'test-cube-id';
963+
964+
it('wraps statements in a transaction', () => {
965+
const { statements } = alterFilterTableBlock(CUBE_ID);
966+
expect(statements[0]).toBe('BEGIN TRANSACTION;');
967+
expect(statements[statements.length - 1]).toBe('COMMIT;');
968+
});
969+
970+
it('returns buildStage UpdateFilterTable', () => {
971+
const block = alterFilterTableBlock(CUBE_ID);
972+
expect(block.buildStage).toBe(BuildStage.UpdateFilterTable);
973+
});
974+
975+
it('adds sort_order column as BIGINT if not exists', () => {
976+
const { statements } = alterFilterTableBlock(CUBE_ID);
977+
const addStmt = statements.find((s) => s.includes('ADD IF NOT EXISTS') && s.includes('sort_order'));
978+
expect(addStmt).toBeDefined();
979+
expect(addStmt).toContain('sort_order BIGINT');
980+
});
981+
982+
it('includes ALTER COLUMN to migrate existing TEXT sort_order to BIGINT', () => {
983+
const { statements } = alterFilterTableBlock(CUBE_ID);
984+
const alterStmt = statements.find((s) => s.includes('ALTER COLUMN') && s.includes('sort_order'));
985+
expect(alterStmt).toBeDefined();
986+
expect(alterStmt).toContain('TYPE BIGINT');
987+
expect(alterStmt).toContain('USING');
988+
});
989+
990+
it('ALTER COLUMN statement comes after ADD IF NOT EXISTS so the column is guaranteed to exist', () => {
991+
const { statements } = alterFilterTableBlock(CUBE_ID);
992+
const addIdx = statements.findIndex((s) => s.includes('ADD IF NOT EXISTS') && s.includes('sort_order'));
993+
const alterIdx = statements.findIndex((s) => s.includes('ALTER COLUMN') && s.includes('sort_order'));
994+
expect(addIdx).toBeGreaterThanOrEqual(0);
995+
expect(alterIdx).toBeGreaterThan(addIdx);
996+
});
997+
998+
it('nulls non-numeric values in the USING clause rather than failing', () => {
999+
const { statements } = alterFilterTableBlock(CUBE_ID);
1000+
const alterStmt = statements.find((s) => s.includes('ALTER COLUMN') && s.includes('sort_order'));
1001+
expect(alterStmt).toContain('ELSE NULL');
1002+
});
1003+
});

test/unit/services/lookup-table-handler.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,42 @@ describe('validateLookupTable', () => {
478478
expect(result.errors[0].message.key).toBe('errors.dimension_validation.lookup_table_loading_failed');
479479
});
480480

481+
it('returns 400 with sort_contains_text when convertLookupTableToSW3Format throws a bigint syntax error', async () => {
482+
const dataset = makeDataset();
483+
setupJoinColumnFoundMocks();
484+
(convertLookupTableToSW3Format as jest.Mock).mockRejectedValueOnce(
485+
new Error('invalid input syntax for type bigint: "not a number"')
486+
);
487+
488+
const result = (await validateLookupTable(
489+
validProtoTable,
490+
dataset,
491+
draftRevision,
492+
dimension,
493+
'en-GB'
494+
)) as ViewErrDTO;
495+
496+
expect(result.status).toBe(400);
497+
expect(result.errors[0].message.key).toBe('errors.dimension_validation.sort_contains_text');
498+
});
499+
500+
it('returns 500 (not 400) when convertLookupTableToSW3Format throws an unrelated error', async () => {
501+
const dataset = makeDataset();
502+
setupJoinColumnFoundMocks();
503+
(convertLookupTableToSW3Format as jest.Mock).mockRejectedValueOnce(new Error('some other db error'));
504+
505+
const result = (await validateLookupTable(
506+
validProtoTable,
507+
dataset,
508+
draftRevision,
509+
dimension,
510+
'en-GB'
511+
)) as ViewErrDTO;
512+
513+
expect(result.status).toBe(500);
514+
expect(result.errors[0].message.key).toBe('errors.dimension_validation.lookup_table_loading_failed');
515+
});
516+
481517
it('returns the language error when validateLookupTableLanguages returns an error DTO', async () => {
482518
const dataset = makeDataset();
483519
setupJoinColumnFoundMocks();

0 commit comments

Comments
 (0)