Skip to content

Commit 602289e

Browse files
authored
Add validation for duplicate names in the db config (#1867)
1 parent bce5d42 commit 602289e

10 files changed

Lines changed: 437 additions & 26 deletions

File tree

extensions/ql-vscode/src/databases/config/db-config-store.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ import {
99
import * as chokidar from "chokidar";
1010
import { DisposableObject, DisposeHandler } from "../../pure/disposable-object";
1111
import { DbConfigValidator } from "./db-config-validator";
12-
import { ValueResult } from "../../common/value-result";
1312
import { App } from "../../common/app";
1413
import { AppEvent, AppEventEmitter } from "../../common/events";
14+
import {
15+
DbConfigValidationError,
16+
DbConfigValidationErrorKind,
17+
} from "../db-validation-errors";
18+
import { ValueResult } from "../../common/value-result";
1519

1620
export class DbConfigStore extends DisposableObject {
1721
public readonly onDidChangeConfig: AppEvent<void>;
@@ -21,7 +25,7 @@ export class DbConfigStore extends DisposableObject {
2125
private readonly configValidator: DbConfigValidator;
2226

2327
private config: DbConfig | undefined;
24-
private configErrors: string[];
28+
private configErrors: DbConfigValidationError[];
2529
private configWatcher: chokidar.FSWatcher | undefined;
2630

2731
public constructor(app: App) {
@@ -48,7 +52,7 @@ export class DbConfigStore extends DisposableObject {
4852
this.configWatcher?.unwatch(this.configPath);
4953
}
5054

51-
public getConfig(): ValueResult<DbConfig, string> {
55+
public getConfig(): ValueResult<DbConfig, DbConfigValidationError> {
5256
if (this.config) {
5357
// Clone the config so that it's not modified outside of this class.
5458
return ValueResult.ok(cloneDbConfig(this.config));
@@ -124,7 +128,12 @@ export class DbConfigStore extends DisposableObject {
124128
try {
125129
newConfig = await readJSON(this.configPath);
126130
} catch (e) {
127-
this.configErrors = [`Failed to read config file: ${this.configPath}`];
131+
this.configErrors = [
132+
{
133+
kind: DbConfigValidationErrorKind.InvalidJson,
134+
message: `Failed to read config file: ${this.configPath}`,
135+
},
136+
];
128137
}
129138

130139
if (newConfig) {
@@ -139,7 +148,12 @@ export class DbConfigStore extends DisposableObject {
139148
try {
140149
newConfig = readJSONSync(this.configPath);
141150
} catch (e) {
142-
this.configErrors = [`Failed to read config file: ${this.configPath}`];
151+
this.configErrors = [
152+
{
153+
kind: DbConfigValidationErrorKind.InvalidJson,
154+
message: `Failed to read config file: ${this.configPath}`,
155+
},
156+
];
143157
}
144158

145159
if (newConfig) {

extensions/ql-vscode/src/databases/config/db-config-validator.ts

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { readJsonSync } from "fs-extra";
22
import { resolve } from "path";
33
import Ajv from "ajv";
44
import { DbConfig } from "./db-config";
5+
import { findDuplicateStrings } from "../../text-utils";
6+
import {
7+
DbConfigValidationError,
8+
DbConfigValidationErrorKind,
9+
} from "../db-validation-errors";
510

611
export class DbConfigValidator {
712
private readonly schema: any;
@@ -14,16 +19,118 @@ export class DbConfigValidator {
1419
this.schema = readJsonSync(schemaPath);
1520
}
1621

17-
public validate(dbConfig: DbConfig): string[] {
22+
public validate(dbConfig: DbConfig): DbConfigValidationError[] {
1823
const ajv = new Ajv({ allErrors: true });
1924
ajv.validate(this.schema, dbConfig);
2025

2126
if (ajv.errors) {
22-
return ajv.errors.map(
23-
(error) => `${error.instancePath} ${error.message}`,
24-
);
27+
return ajv.errors.map((error) => ({
28+
kind: DbConfigValidationErrorKind.InvalidConfig,
29+
message: `${error.instancePath} ${error.message}`,
30+
}));
2531
}
2632

27-
return [];
33+
return [
34+
...this.validateDbListNames(dbConfig),
35+
...this.validateDbNames(dbConfig),
36+
...this.validateDbNamesInLists(dbConfig),
37+
...this.validateOwners(dbConfig),
38+
];
39+
}
40+
41+
private validateDbListNames(dbConfig: DbConfig): DbConfigValidationError[] {
42+
const errors: DbConfigValidationError[] = [];
43+
44+
const buildError = (dups: string[]) => ({
45+
kind: DbConfigValidationErrorKind.DuplicateNames,
46+
message: `There are database lists with the same name: ${dups.join(
47+
", ",
48+
)}`,
49+
});
50+
51+
const duplicateLocalDbLists = findDuplicateStrings(
52+
dbConfig.databases.local.lists.map((n) => n.name),
53+
);
54+
55+
if (duplicateLocalDbLists.length > 0) {
56+
errors.push(buildError(duplicateLocalDbLists));
57+
}
58+
59+
const duplicateRemoteDbLists = findDuplicateStrings(
60+
dbConfig.databases.remote.repositoryLists.map((n) => n.name),
61+
);
62+
if (duplicateRemoteDbLists.length > 0) {
63+
errors.push(buildError(duplicateRemoteDbLists));
64+
}
65+
66+
return errors;
67+
}
68+
69+
private validateDbNames(dbConfig: DbConfig): DbConfigValidationError[] {
70+
const errors: DbConfigValidationError[] = [];
71+
72+
const buildError = (dups: string[]) => ({
73+
kind: DbConfigValidationErrorKind.DuplicateNames,
74+
message: `There are databases with the same name: ${dups.join(", ")}`,
75+
});
76+
77+
const duplicateLocalDbs = findDuplicateStrings(
78+
dbConfig.databases.local.databases.map((d) => d.name),
79+
);
80+
81+
if (duplicateLocalDbs.length > 0) {
82+
errors.push(buildError(duplicateLocalDbs));
83+
}
84+
85+
const duplicateRemoteDbs = findDuplicateStrings(
86+
dbConfig.databases.remote.repositories,
87+
);
88+
if (duplicateRemoteDbs.length > 0) {
89+
errors.push(buildError(duplicateRemoteDbs));
90+
}
91+
92+
return errors;
93+
}
94+
95+
private validateDbNamesInLists(
96+
dbConfig: DbConfig,
97+
): DbConfigValidationError[] {
98+
const errors: DbConfigValidationError[] = [];
99+
100+
const buildError = (listName: string, dups: string[]) => ({
101+
kind: DbConfigValidationErrorKind.DuplicateNames,
102+
message: `There are databases with the same name in the ${listName} list: ${dups.join(
103+
", ",
104+
)}`,
105+
});
106+
107+
for (const list of dbConfig.databases.local.lists) {
108+
const dups = findDuplicateStrings(list.databases.map((d) => d.name));
109+
if (dups.length > 0) {
110+
errors.push(buildError(list.name, dups));
111+
}
112+
}
113+
114+
for (const list of dbConfig.databases.remote.repositoryLists) {
115+
const dups = findDuplicateStrings(list.repositories);
116+
if (dups.length > 0) {
117+
errors.push(buildError(list.name, dups));
118+
}
119+
}
120+
121+
return errors;
122+
}
123+
124+
private validateOwners(dbConfig: DbConfig): DbConfigValidationError[] {
125+
const errors: DbConfigValidationError[] = [];
126+
127+
const dups = findDuplicateStrings(dbConfig.databases.remote.owners);
128+
if (dups.length > 0) {
129+
errors.push({
130+
kind: DbConfigValidationErrorKind.DuplicateNames,
131+
message: `There are owners with the same name: ${dups.join(", ")}`,
132+
});
133+
}
134+
return errors;
28135
}
29136
}

extensions/ql-vscode/src/databases/db-manager.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
mapDbItemToSelectedDbItem,
1010
} from "./db-item-selection";
1111
import { createLocalTree, createRemoteTree } from "./db-tree-creator";
12+
import { DbConfigValidationError } from "./db-validation-errors";
1213

1314
export class DbManager {
1415
public readonly onDbItemsChanged: AppEvent<void>;
@@ -24,16 +25,16 @@ export class DbManager {
2425
}
2526

2627
public getSelectedDbItem(): DbItem | undefined {
27-
const dbItems = this.getDbItems();
28+
const dbItemsResult = this.getDbItems();
2829

29-
if (dbItems.isFailure) {
30+
if (dbItemsResult.errors.length > 0) {
3031
return undefined;
3132
}
3233

33-
return getSelectedDbItem(dbItems.value);
34+
return getSelectedDbItem(dbItemsResult.value);
3435
}
3536

36-
public getDbItems(): ValueResult<DbItem[], string> {
37+
public getDbItems(): ValueResult<DbItem[], DbConfigValidationError> {
3738
const configResult = this.dbConfigStore.getConfig();
3839
if (configResult.isFailure) {
3940
return ValueResult.fail(configResult.errors);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export enum DbConfigValidationErrorKind {
2+
InvalidJson = "InvalidJson",
3+
InvalidConfig = "InvalidConfig",
4+
DuplicateNames = "DuplicateNames",
5+
}
6+
7+
export interface DbConfigValidationError {
8+
kind: DbConfigValidationErrorKind;
9+
message: string;
10+
}

extensions/ql-vscode/src/databases/ui/db-tree-data-provider.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { createDbTreeViewItemError, DbTreeViewItem } from "./db-tree-view-item";
99
import { DbManager } from "../db-manager";
1010
import { mapDbItemToTreeViewItem } from "./db-item-mapper";
1111
import { DisposableObject } from "../../pure/disposable-object";
12+
import {
13+
DbConfigValidationError,
14+
DbConfigValidationErrorKind,
15+
} from "../db-validation-errors";
1216

1317
export class DbTreeDataProvider
1418
extends DisposableObject
@@ -61,14 +65,34 @@ export class DbTreeDataProvider
6165
const dbItemsResult = this.dbManager.getDbItems();
6266

6367
if (dbItemsResult.isFailure) {
68+
return this.createErrorItems(dbItemsResult.errors);
69+
}
70+
71+
return dbItemsResult.value.map(mapDbItemToTreeViewItem);
72+
}
73+
74+
private createErrorItems(
75+
errors: DbConfigValidationError[],
76+
): DbTreeViewItem[] {
77+
if (
78+
errors.some(
79+
(e) =>
80+
e.kind === DbConfigValidationErrorKind.InvalidJson ||
81+
e.kind === DbConfigValidationErrorKind.InvalidConfig,
82+
)
83+
) {
6484
const errorTreeViewItem = createDbTreeViewItemError(
6585
"Error when reading databases config",
6686
"Please open your databases config and address errors",
6787
);
6888

6989
return [errorTreeViewItem];
90+
} else {
91+
return errors
92+
.filter((e) => e.kind === DbConfigValidationErrorKind.DuplicateNames)
93+
.map((e) =>
94+
createDbTreeViewItemError(e.message, "Please remove duplicates"),
95+
);
7096
}
71-
72-
return dbItemsResult.value.map(mapDbItemToTreeViewItem);
7397
}
7498
}

extensions/ql-vscode/src/text-utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,11 @@ export function convertNonPrintableChars(label: string | undefined) {
3131
return convertedLabelArray.join("");
3232
}
3333
}
34+
35+
export function findDuplicateStrings(strings: string[]): string[] {
36+
const dups = strings.filter(
37+
(string, index, strings) => strings.indexOf(string) !== index,
38+
);
39+
40+
return [...new Set(dups)];
41+
}

extensions/ql-vscode/src/vscode-tests/minimal-workspace/databases/db-panel.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { TreeItemCollapsibleState, ThemeIcon } from "vscode";
1+
import { TreeItemCollapsibleState, ThemeIcon, ThemeColor } from "vscode";
22
import { join } from "path";
33
import { ensureDir, readJSON, remove, writeJson } from "fs-extra";
44
import {
@@ -591,6 +591,73 @@ describe("db panel", () => {
591591
});
592592
});
593593

594+
it("should show error for invalid config", async () => {
595+
// We're intentionally bypassing the type check because we'd
596+
// like to make sure validation errors are highlighted.
597+
const dbConfig = {
598+
databases: {},
599+
} as any as DbConfig;
600+
601+
await saveDbConfig(dbConfig);
602+
603+
const dbTreeItems = await dbTreeDataProvider.getChildren();
604+
605+
expect(dbTreeItems).toBeTruthy();
606+
const items = dbTreeItems!;
607+
expect(items.length).toBe(1);
608+
609+
checkErrorItem(
610+
items[0],
611+
"Error when reading databases config",
612+
"Please open your databases config and address errors",
613+
);
614+
});
615+
616+
it("should show errors for duplicate names", async () => {
617+
const dbConfig: DbConfig = {
618+
databases: {
619+
remote: {
620+
repositoryLists: [
621+
{
622+
name: "my-list-1",
623+
repositories: ["owner1/repo1", "owner1/repo2"],
624+
},
625+
{
626+
name: "my-list-1",
627+
repositories: ["owner1/repo1", "owner2/repo2"],
628+
},
629+
],
630+
owners: [],
631+
repositories: ["owner1/repo1", "owner1/repo1"],
632+
},
633+
local: {
634+
lists: [],
635+
databases: [],
636+
},
637+
},
638+
expanded: [],
639+
};
640+
641+
await saveDbConfig(dbConfig);
642+
643+
const dbTreeItems = await dbTreeDataProvider.getChildren();
644+
645+
expect(dbTreeItems).toBeTruthy();
646+
const items = dbTreeItems!;
647+
expect(items.length).toBe(2);
648+
649+
checkErrorItem(
650+
items[0],
651+
"There are database lists with the same name: my-list-1",
652+
"Please remove duplicates",
653+
);
654+
checkErrorItem(
655+
items[1],
656+
"There are databases with the same name: owner1/repo1",
657+
"Please remove duplicates",
658+
);
659+
});
660+
594661
async function saveDbConfig(dbConfig: DbConfig): Promise<void> {
595662
await writeJson(dbConfigFilePath, dbConfig);
596663

@@ -672,6 +739,21 @@ describe("db panel", () => {
672739
expect(item.collapsibleState).toBe(TreeItemCollapsibleState.None);
673740
}
674741

742+
function checkErrorItem(
743+
item: DbTreeViewItem,
744+
label: string,
745+
tooltip: string,
746+
): void {
747+
expect(item.dbItem).toBe(undefined);
748+
expect(item.iconPath).toEqual(
749+
new ThemeIcon("error", new ThemeColor("problemsErrorIcon.foreground")),
750+
);
751+
expect(item.label).toBe(label);
752+
expect(item.tooltip).toBe(tooltip);
753+
expect(item.collapsibleState).toBe(TreeItemCollapsibleState.None);
754+
expect(item.children.length).toBe(0);
755+
}
756+
675757
function isTreeViewItemSelectable(treeViewItem: DbTreeViewItem) {
676758
return (
677759
treeViewItem.resourceUri === undefined &&

0 commit comments

Comments
 (0)