All files / languages/sparql/services sparql-connection-registry.ts

100% Statements 76/76
90% Branches 36/40
100% Functions 23/23
100% Lines 71/71

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284                                                82x         82x         82x   82x         82x     82x 82x 82x         82x 82x 82x   82x               82x                         9x 9x                   4x   3x 1x     2x 1x     1x 1x   1x                 1x 1x 1x                   164x   164x 156x     8x   164x 4x     4x       164x 3x 3x 3x                   8x 16x 4x                             4x   4x 4x   4x 8x 8x     4x               10x                 6x                 34x                     23x   23x                 23x 23x   23x                   2x 2x   2x 1x 1x                   10x 1x     17x   9x 1x   8x     9x                 4x 1x     5x   3x      
import * as vscode from 'vscode';
import { v4 as uuidv4 } from 'uuid';
import { getConfig } from '@src/utilities/vscode/config';
import { ICredentialStorageService } from '@src/services/core';
import { ConfigurationScope } from '@src/utilities/config-scope';
import { AuthCredential } from '@src/services/core/credential';
import { SparqlConnection } from './sparql-connection';
import { ITripleStoreConfigService } from './triple-store-config-service.interface';
import { WORKSPACE_CONNECTION } from './workspace-store';
 
export { WORKSPACE_CONNECTION };
 
/**
 * Service for managing connections to SPARQL endpoints: the in-memory connection
 * registry, its persistence to VS Code settings, and per-connection inference settings.
 *
 * Document- and notebook-cell-scoped settings live in `DocumentConnectionService`;
 * endpoint reachability testing lives in `SparqlEndpointTester`.
 */
export class SparqlConnectionRegistry {
 
	/**
	 * VS Code settings key under which SPARQL connections are persisted.
	 */
	private readonly _connectionsConfigKey = 'sparql.connections';
 
	/**
	 * Workspace-state key prefix for per-connection inference settings (`<prefix><connectionId>`).
	 */
	private readonly _inferenceEnabledStorageKeyPrefix = 'mentor.inference.enabled:';
 
	/**
	 * The current in-memory connection list, including the workspace store at index 0.
	 */
	private _connections: SparqlConnection[] = [];
 
	private _onDidChangeConnections = new vscode.EventEmitter<void>();
 
	/**
	 * Fired whenever the connection list or a connection's inference state changes.
	 */
	public readonly onDidChangeConnections = this._onDidChangeConnections.event;
 
	constructor(
		private readonly _extensionContext: vscode.ExtensionContext,
		private readonly _credentialStorage: ICredentialStorageService,
		private readonly _storeConfigService: ITripleStoreConfigService
	) {
		// Workspace-scope connections load before user-scope ones so that a
		// workspace definition wins on a duplicate id in `getConnection`,
		// matching how stores and validation profiles resolve scope collisions.
		this._connections = [this._createWorkspaceStoreConnection()];
		this._connections.push(...this._loadConnectionsFromConfiguration(vscode.ConfigurationTarget.Workspace));
		this._connections.push(...this._loadConnectionsFromConfiguration(vscode.ConfigurationTarget.Global));
 
		this._onDidChangeConnections.fire();
	}
 
	/**
	 * Creates the workspace store connection with the persisted inference setting.
	 * @returns The workspace store as a SparqlConnection with inference capability set.
	 */
	private _createWorkspaceStoreConnection(): SparqlConnection {
		return {
			...WORKSPACE_CONNECTION,
			canToggleInference: true
		};
	}
 
	/**
	 * Gets whether inference is currently enabled for a specific connection. Inference is opt-in
	 * per connection (off unless the connection explicitly enables it).
	 * @param connectionId The ID of the connection.
	 * @returns `true` if inference is enabled, `false` otherwise.
	 */
	getInferenceEnabled(connectionId: string): boolean {
		const storageKey = `${this._inferenceEnabledStorageKeyPrefix}${connectionId}`;
		return this._extensionContext.workspaceState.get<boolean>(storageKey, false);
	}
 
	/**
	 * Sets whether inference should be enabled for a specific connection and persists the change.
	 * @param connectionId The ID of the connection.
	 * @param inferenceEnabled `true` to enable inference, `false` to disable it.
	 * @throws If the connection is not found or does not support inference toggling.
	 */
	async setInferenceEnabled(connectionId: string, inferenceEnabled: boolean): Promise<void> {
		const connection = this._connections.find(c => c.id === connectionId);
 
		if (!connection) {
			throw new Error(`Connection not found: ${connectionId}`);
		}
 
		if (!this._storeConfigService.supportsInference(connection)) {
			throw new Error(`Connection does not support inference toggling: ${connectionId}`);
		}
 
		const storageKey = `${this._inferenceEnabledStorageKeyPrefix}${connectionId}`;
		await this._extensionContext.workspaceState.update(storageKey, inferenceEnabled);
 
		this._onDidChangeConnections.fire();
	}
 
	/**
	 * Toggles the inference-enabled state for a specific connection.
	 * @param connectionId The ID of the connection.
	 * @returns The new inference-enabled state.
	 */
	async toggleInferenceEnabled(connectionId: string): Promise<boolean> {
		const newValue = !this.getInferenceEnabled(connectionId);
		await this.setInferenceEnabled(connectionId, newValue);
		return newValue;
	}
 
	/**
	 * Reads connections from a configuration scope and resolves their inferred store type and
	 * inference support based on the registered store configs.
	 * @param configTarget The configuration target (Global or Workspace) to read from.
	 * @returns The connections found in the given scope, with `configScope` and `canToggleInference` set.
	 */
	private _loadConnectionsFromConfiguration(configTarget: vscode.ConfigurationTarget): SparqlConnection[] {
		const inspect = getConfig().inspect<SparqlConnection[]>(this._connectionsConfigKey);
 
		if (!inspect) {
			return [];
		}
 
		const raw = configTarget === vscode.ConfigurationTarget.Global ? inspect.globalValue : inspect.workspaceValue;
 
		if (!raw) {
			return [];
		}
 
		const configScope = configTarget === vscode.ConfigurationTarget.Workspace
			? ConfigurationScope.Workspace
			: ConfigurationScope.User;
 
		return raw.map(c => {
			const storeType = c.storeType ?? this._storeConfigService.defaultStoreType;
			const connection = { ...c, storeType };
			return { ...connection, configScope, canToggleInference: this._storeConfigService.supportsInference(connection) };
		});
	}
 
	/**
	 * Returns the serializable form of all connections in a given scope, suitable for writing back
	 * to VS Code settings (excludes runtime-only fields and the workspace pseudo-connection).
	 * @param configScope The scope to collect connections for.
	 */
	private _getEndpointDataForConfigScope(configScope: ConfigurationScope) {
		return this._connections
			.filter(c => c.configScope === configScope && c.id !== WORKSPACE_CONNECTION.id)
			.map(c => ({
				id: c.id,
				...(c.description ? { description: c.description } : {}),
				endpointUrl: c.endpointUrl,
				storeType: c.storeType ?? this._storeConfigService.defaultStoreType,
				...(c.autoLoadGraphs !== undefined ? { autoLoadGraphs: c.autoLoadGraphs } : {}),
				...(c.graphReloadIntervalSeconds !== undefined ? { graphReloadIntervalSeconds: c.graphReloadIntervalSeconds } : {}),
				...(c.graphReloadTime !== undefined ? { graphReloadTime: c.graphReloadTime } : {}),
			}));
	}
 
	/**
	 * Persists all in-memory connections to VS Code settings and clears their dirty flags.
	 */
	async saveConfiguration(): Promise<void> {
		const config = getConfig();
 
		await config.update(this._connectionsConfigKey, this._getEndpointDataForConfigScope(ConfigurationScope.User), vscode.ConfigurationTarget.Global);
		await config.update(this._connectionsConfigKey, this._getEndpointDataForConfigScope(ConfigurationScope.Workspace), vscode.ConfigurationTarget.Workspace);
 
		for (const connection of this._connections) {
			connection.isNew = false;
			connection.isModified = false;
		}
 
		this._onDidChangeConnections.fire();
	}
 
	/**
	 * Retrieves all available SPARQL connections, including the workspace store.
	 * @returns An array of all connections.
	 */
	getConnections(): SparqlConnection[] {
		return this._connections;
	}
 
	/**
	 * Retrieves all SPARQL connections for a specific configuration scope.
	 * @param configScope The configuration scope to filter connections by.
	 * @returns An array of connections in the given scope.
	 */
	getConnectionsForConfigurationScope(configScope: ConfigurationScope): SparqlConnection[] {
		return this._connections.filter(c => c.configScope === configScope);
	}
 
	/**
	 * Retrieves a SPARQL connection by its ID.
	 * @param connectionId The ID of the connection to retrieve.
	 * @returns The SPARQL connection, or `undefined` if not found.
	 */
	getConnection(connectionId: string): SparqlConnection | undefined {
		return this._connections.find(c => c.id === connectionId);
	}
 
	/**
	 * Creates a new, unsaved SPARQL connection with a generated ID and default settings.
	 * @param scope The configuration scope to create the connection in. When omitted, new
	 * connections are project-scoped so endpoints can be shared via version control,
	 * falling back to User scope when no workspace folder is open.
	 * @returns A promise that resolves to the new connection.
	 */
	async createConnection(scope?: ConfigurationScope): Promise<SparqlConnection> {
		const hasWorkspace = (vscode.workspace.workspaceFolders?.length ?? 0) > 0;
 
		const connection: SparqlConnection = {
			id: uuidv4(),
			isNew: true,
			isModified: false,
			endpointUrl: 'https://',
			autoLoadGraphs: true,
			configScope: scope ?? (hasWorkspace ? ConfigurationScope.Workspace : ConfigurationScope.User)
		};
 
		this._connections.push(connection);
		this._onDidChangeConnections.fire();
 
		return connection;
	}
 
	/**
	 * Persists a connection edit together with its credential, replacing any existing
	 * credential for the same connection.
	 * @param connection The connection to save.
	 * @param credential The credential to store, or `null` to leave any existing credential untouched.
	 */
	async saveConnectionWithCredential(connection: SparqlConnection, credential: AuthCredential | null): Promise<void> {
		await this.updateConnection(connection);
		await this.saveConfiguration();
 
		if (credential) {
			await this._credentialStorage.deleteCredential(connection.id);
			await this._credentialStorage.saveCredential(connection.id, credential);
		}
	}
 
	/**
	 * Updates an existing connection in memory, or inserts it if it does not yet exist.
	 * @param connection The connection data to apply.
	 * @throws If the connection is the protected workspace store.
	 */
	async updateConnection(connection: SparqlConnection): Promise<void> {
		if (connection.id === WORKSPACE_CONNECTION.id) {
			throw new Error('The Mentor Workspace Store cannot be modified.');
		}
 
		const i = this._connections.findIndex(c => c.id === connection.id);
 
		if (i === -1) {
			this._connections.push(connection);
		} else {
			this._connections[i] = connection;
		}
 
		this._onDidChangeConnections.fire();
	}
 
	/**
	 * Removes a connection from memory.
	 * @param connectionId The ID of the connection to delete.
	 * @throws If the connection is the protected workspace store.
	 */
	async deleteConnection(connectionId: string): Promise<void> {
		if (connectionId === WORKSPACE_CONNECTION.id) {
			throw new Error('The Mentor Workspace Store cannot be removed.');
		}
 
		this._connections = this._connections.filter(c => c.id !== connectionId);
 
		this._onDidChangeConnections.fire();
	}
}