Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 125 additions & 2 deletions extensions/mssql/src/languageservice/sqlMoveToSchemaProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ interface SchemaFolderMovePlan {
newAbsUri: vscode.Uri;
}

interface MoveToSchemaSqlToken {
text: string;
start: number;
}

/**
* Surfaces a "Move to Schema..." action under the editor's **Refactor...** menu for SQL files in a
* SQL project.
Expand Down Expand Up @@ -96,8 +101,12 @@ export class SqlMoveToSchemaProvider implements vscode.CodeActionProvider {
}),
vscode.commands.registerCommand(
cmdMoveToSchema,
(document: vscode.TextDocument, position: vscode.Position) =>
provider.runMoveToSchema(document, position),
(documentOrPath: vscode.TextDocument | string, position?: vscode.Position) =>
typeof documentOrPath === "string"
? provider.runMoveToSchemaFromFilePath(documentOrPath)
: position
? provider.runMoveToSchema(documentOrPath, position)
: undefined,
),
];
}
Expand Down Expand Up @@ -189,6 +198,120 @@ export class SqlMoveToSchemaProvider implements vscode.CodeActionProvider {
await this.applyMove(document, position, selected.label, schemas);
}

//#region Tree Entry Local Parsing (replaceable)

@ssreerama Sai Avishkar Sreerama (ssreerama) Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the whole region is replaceable if STS can give the sql object name from the parser, which script Dom already have. But, needed STS changes which can be done in another release cycle not at the ask mode time.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just to provide a quality-of-life improvement, correct? Might be better just to hold this fix back from this release so that we can do it correctly (using STS/ScriptDom)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had a chat with Drew and this can be moved to other cycle as is not very urgent one.

have moved the issue to next cycle, updating the PR to draft (will close it and open a new one if required)

// NOTE: This region exists only to bridge the tree-entry (file path only) flow into the
// existing Move-to-Schema document+position flow. If STS exposes an API that resolves the
// movable object position from a file path, replace this entire region with that STS call.
// TODO Task: https://github.com/microsoft/vscode-mssql/issues/22709
/**
* Starts Move to Schema from a Database Projects tree file path.
*/
public async runMoveToSchemaFromFilePath(filePath: string): Promise<void> {
try {
const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath));
const position = this.findMoveToSchemaSymbolPosition(document);
if (!position) {
void vscode.window.showInformationMessage(loc.noMovableSymbolAtCursor);
return;
}

await this.runMoveToSchema(document, position);
} catch (err) {
void vscode.window.showErrorMessage(
loc.moveToSchemaRequestFailed(err instanceof Error ? err.message : String(err)),
);
}
}

/**
* Finds the position of the object identifier used as the Move-to-Schema cursor target.
* Supports CREATE/ALTER forms, including CREATE OR ALTER, and schema-qualified names.
*/
private findMoveToSchemaSymbolPosition(
document: vscode.TextDocument,
): vscode.Position | undefined {
const tokens = this.tokenizeMoveToSchemaSql(document.getText());
const objectTypes = new Set([
"table",
"view",
"proc",
"procedure",
"function",
"trigger",
"sequence",
]);

for (let i = 0; i < tokens.length; i++) {
const statement = tokens[i].text.toLowerCase();
if (statement !== "create" && statement !== "alter") {
continue;
}

let objectTypeIndex = i + 1;
if (
tokens[objectTypeIndex]?.text.toLowerCase() === "or" &&
tokens[objectTypeIndex + 1]?.text.toLowerCase() === "alter"
) {
objectTypeIndex += 2;
}

const objectType = tokens[objectTypeIndex]?.text.toLowerCase();
if (!objectType || !objectTypes.has(objectType)) {
continue;
}

const firstNameToken = tokens[objectTypeIndex + 1];
if (!firstNameToken) {
return undefined;
}

// schema.object -> target object token; object -> target first name token
if (tokens[objectTypeIndex + 2]?.text === "." && tokens[objectTypeIndex + 3]) {
return document.positionAt(tokens[objectTypeIndex + 3].start);
}
return document.positionAt(firstNameToken.start);
}

return undefined;
}

/**
* Tokenizes SQL text into identifiers and separators while skipping whitespace, comments,
* and string literals so statement detection ignores non-executable text.
*/
private tokenizeMoveToSchemaSql(text: string): MoveToSchemaSqlToken[] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any unit tests that cover this method. Please add several, especially since we're (temporarily) relying on a complicated REGEX.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, Added the test coverage for the regex method and also created a task for the scriptDom replacement and added as a todo comment in the code.

// Match only syntax that affects CREATE/ALTER object detection: whitespace and
// comments/strings to skip, quoted or bare identifiers to keep, and dots for
// schema-qualified names. The sticky flag keeps token positions exact while the
// loop below advances over punctuation this parser does not care about.
const tokenPattern =
/\s+|--[^\r\n]*|\/\*[\s\S]*?\*\/|'(?:''|[^'])*'|\[(?:[^\]]|\]\])+\]|"(?:""|[^"])*"|`(?:``|[^`])*`|[A-Za-z0-9_$#@]+|\./y;
Comment thread
ssreerama marked this conversation as resolved.
const tokens: MoveToSchemaSqlToken[] = [];

for (let index = 0; index < text.length; ) {
tokenPattern.lastIndex = index;
const match = tokenPattern.exec(text);
if (!match) {
index++;
continue;
}

const value = match[0];
index = tokenPattern.lastIndex;
if (!value || /^\s+$/.test(value)) {
continue;
}
if (value.startsWith("--") || value.startsWith("/*") || value.startsWith("'")) {
continue;
}
tokens.push({ text: value, start: match.index });
}

return tokens;
}

//#endregion

/**
* Runs the move end to end: asks STS for the script edits, applies them through VS Code's
* refactor preview, then relocates the definition file and updates the `.sqlproj`.
Expand Down
115 changes: 112 additions & 3 deletions extensions/mssql/test/unit/sqlSymbolRenameProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,36 @@ function makeMoveDocument(
): vscode.TextDocument {
const fsPath = opts.fsPath ?? defaultSqlFile;
const lineText = opts.lineText ?? "CREATE TABLE [dbo].[MyTable]";
const lines = lineText.split("\n");
const uri = vscode.Uri.file(fsPath);
const uriString = uri.toString();
return {
uri: Object.assign(uri, { toString: () => uriString }),
lineAt: sandbox.stub().callsFake((lineOrPos: number | vscode.Position) => {
const lineNum =
typeof lineOrPos === "number" ? lineOrPos : (lineOrPos as vscode.Position).line;
const text = lines[lineNum] ?? "";
return {
text: lineNum === 0 ? lineText : "",
text,
range: new vscode.Range(
new vscode.Position(lineNum, 0),
new vscode.Position(lineNum, (lineNum === 0 ? lineText : "").length),
new vscode.Position(lineNum, text.length),
),
};
}),
getText: sandbox.stub().callsFake((range?: vscode.Range) => {
if (!range) return lineText;
return lineText.slice(range.start.character, range.end.character);
if (range.start.line === range.end.line) {
return (lines[range.start.line] ?? "").slice(
range.start.character,
range.end.character,
);
}
return lineText.slice(0, range.end.character);
}),
positionAt: sandbox.stub().callsFake((offset: number) => {
const lines = lineText.slice(0, offset).split("\n");
return new vscode.Position(lines.length - 1, lines[lines.length - 1].length);
}),
} as unknown as vscode.TextDocument;
}
Expand Down Expand Up @@ -661,6 +673,103 @@ suite("SqlMoveToSchemaProvider Tests", () => {

// -------------------------------------------------------------------------
suite("runMoveToSchema", () => {
test("resolves the object position after a preamble when started from a file path", async () => {
findFilesStub.resolves([vscode.Uri.file(defaultProjFile)]);
showQuickPickStub.resolves({ label: "hr" });
sendRequestStub.withArgs(ListProjectSchemasRequest.type).resolves({ schemas: ["hr"] });
sendRequestStub.withArgs(SqlMoveToSchemaRequest.type).resolves({ changes: {} });

const document = makeMoveDocument(sandbox, {
lineText:
"SET ANSI_NULLS ON;\nGO\nAlter Table dbo.Table1\nAdd NewColumn1 INT NULL;",
});
sandbox.stub(vscode.workspace, "openTextDocument").resolves(document);

await provider.runMoveToSchemaFromFilePath(defaultSqlFile);

expect(sendRequestStub).to.have.been.calledWith(
SqlMoveToSchemaRequest.type,
sinon.match({
position: { line: 2, character: 16 },
}),
);
});

test("ignores CREATE text in comments and string literals", async () => {
findFilesStub.resolves([vscode.Uri.file(defaultProjFile)]);
showQuickPickStub.resolves({ label: "hr" });
sendRequestStub.withArgs(ListProjectSchemasRequest.type).resolves({ schemas: ["hr"] });
sendRequestStub.withArgs(SqlMoveToSchemaRequest.type).resolves({ changes: {} });

const document = makeMoveDocument(sandbox, {
lineText:
"-- CREATE TABLE dbo.CommentedOut\n/* CREATE VIEW dbo.BlockCommentedOut */\nSELECT 'CREATE VIEW dbo.InString';\nCREATE TABLE dbo.RealTable;",
});
sandbox.stub(vscode.workspace, "openTextDocument").resolves(document);

await provider.runMoveToSchemaFromFilePath(defaultSqlFile);

expect(sendRequestStub).to.have.been.calledWith(
SqlMoveToSchemaRequest.type,
sinon.match({
position: { line: 3, character: 17 },
}),
);
});

test("resolves the object token for CREATE OR ALTER with quoted names", async () => {
findFilesStub.resolves([vscode.Uri.file(defaultProjFile)]);
showQuickPickStub.resolves({ label: "hr" });
sendRequestStub.withArgs(ListProjectSchemasRequest.type).resolves({ schemas: ["hr"] });
sendRequestStub.withArgs(SqlMoveToSchemaRequest.type).resolves({ changes: {} });

const document = makeMoveDocument(sandbox, {
lineText: "CREATE OR ALTER VIEW [sales].[Order Details] AS SELECT 1;",
});
sandbox.stub(vscode.workspace, "openTextDocument").resolves(document);

await provider.runMoveToSchemaFromFilePath(defaultSqlFile);

expect(sendRequestStub).to.have.been.calledWith(
SqlMoveToSchemaRequest.type,
sinon.match({
position: { line: 0, character: 29 },
}),
);
});

test("skips unsupported object types and finds a later supported definition", async () => {
findFilesStub.resolves([vscode.Uri.file(defaultProjFile)]);
showQuickPickStub.resolves({ label: "hr" });
sendRequestStub.withArgs(ListProjectSchemasRequest.type).resolves({ schemas: ["hr"] });
sendRequestStub.withArgs(SqlMoveToSchemaRequest.type).resolves({ changes: {} });

const document = makeMoveDocument(sandbox, {
lineText: "CREATE DATABASE MyDatabase;\nCREATE SEQUENCE dbo.OrderSequence;",
});
sandbox.stub(vscode.workspace, "openTextDocument").resolves(document);

await provider.runMoveToSchemaFromFilePath(defaultSqlFile);

expect(sendRequestStub).to.have.been.calledWith(
SqlMoveToSchemaRequest.type,
sinon.match({
position: { line: 1, character: 20 },
}),
);
});

test("shows error when opening a file path fails", async () => {
const error = new Error("File not found");
sandbox.stub(vscode.workspace, "openTextDocument").rejects(error);

await provider.runMoveToSchemaFromFilePath(defaultSqlFile);

expect(messageBoxes.showErrorMessage).to.have.been.calledWith(
moveLoc.moveToSchemaRequestFailed(error.message),
);
});

test("shows message when file is not in a SQL project", async () => {
const doc = makeMoveDocument(sandbox);
await provider.runMoveToSchema(doc, new vscode.Position(0, 0));
Expand Down
1 change: 1 addition & 0 deletions extensions/sql-database-projects/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ _The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- Added support for using **Move to Schema** on sequences and DML triggers
- Added a **Restore Packages** command to the SQL project context menu for restoring the project's NuGet packages.
- Added support for **Move to Schema** now automatically moves the `.sql` file to the target schema folder.
- Added a **Move to Schema** option to the context menu for SQL object files in the Database Projects tree.
- Added support for creating SQL objects with the schema corresponding to the selected folder instead of defaulting to `dbo`.
- Added support for custom code analysis rules. Rules contributed by referenced NuGet analyzer packages now appear in the **Code Analysis Settings** dialog alongside the built-in rules.
- Fixed an issue where the **Rename Symbol** feature was incorrectly enabled on SQL alias identifiers (column aliases, table aliases, subquery aliases, and CTE names), which could generate an invalid `.refactorlog` entry.
Expand Down
14 changes: 14 additions & 0 deletions extensions/sql-database-projects/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@
"title": "%sqlDatabaseProjects.configureCodeAnalysisSettings%",
"category": "%sqlDatabaseProjects.displayName%"
},
{
"command": "sqlDatabaseProjects.moveToSchema",
"title": "%sqlDatabaseProjects.moveToSchema%",
"category": "%sqlDatabaseProjects.displayName%"
},
{
"command": "sqlDatabaseProjects.addDatabaseReference",
"title": "%sqlDatabaseProjects.addDatabaseReference%",
Expand Down Expand Up @@ -344,6 +349,10 @@
"command": "sqlDatabaseProjects.configureCodeAnalysisSettings",
"when": "false"
},
{
"command": "sqlDatabaseProjects.moveToSchema",
"when": "false"
},
{
"command": "sqlDatabaseProjects.addDatabaseReference",
"when": "false"
Expand Down Expand Up @@ -537,6 +546,11 @@
"when": "view == dataworkspace.views.main && viewItem =~ /^(databaseProject.itemType.project|databaseProject.itemType.legacyProject)$/",
"group": "9_dbProjectsLast@9"
},
{
"command": "sqlDatabaseProjects.moveToSchema",
"when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.file.sqlObjectScript",
"group": "9_dbProjectsLast@4"
Comment thread
ssreerama marked this conversation as resolved.
},
{
"submenu": "sqlDatabaseProjects.objectExplorerSubmenu",
"when": "view == objectExplorer && viewItem =~ /\\btype=(disconnectedServer|Server|Database)\\b/",
Expand Down
1 change: 1 addition & 0 deletions extensions/sql-database-projects/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"sqlDatabaseProjects.createProjectFromDatabase": "Create Project from Database",
"sqlDatabaseProjects.updateProjectFromDatabase": "Update Project from Database",
"sqlDatabaseProjects.configureCodeAnalysisSettings": "Code Analysis Settings",
"sqlDatabaseProjects.moveToSchema": "Move to Schema...",
"sqlDatabaseProjects.properties": "Properties",
"sqlDatabaseProjects.schemaCompare": "Schema Compare",
"sqlDatabaseProjects.delete": "Delete",
Expand Down
1 change: 1 addition & 0 deletions extensions/sql-database-projects/src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
export const mssqlSchemaCompareCommand = "mssql.schemaCompare";
export const mssqlPublishProjectCommand = "mssql.publishDatabaseProject";
export const mssqlConfigureCodeAnalysisSettingsCommand = "mssql.configureCodeAnalysisSettings";
export const mssqlMoveToSchemaCommand = "mssql.moveToSchema";
export const vscodeOpenCommand = "vscode.open";
export const refreshDataWorkspaceCommand = "dataworkspace.refresh";

Expand Down Expand Up @@ -207,7 +208,7 @@
export const dataSourceDropdownTitle = l10n.t("Data source");
export const noDataSourcesText = l10n.t("No data sources in this project");
export const loadProfilePlaceholderText = l10n.t("Load profile...");
export const profileReadError = (err: any) =>

Check warning on line 211 in extensions/sql-database-projects/src/common/constants.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
l10n.t("Error loading the publish profile. {0}", utils.getErrorMessage(err));
export const sqlCmdVariables = l10n.t("SQLCMD Variables");
export const sqlCmdVariableColumn = l10n.t("Name");
Expand Down Expand Up @@ -534,7 +535,7 @@
export function circularProjectReference(project1: string, project2: string) {
return l10n.t("Circular reference from project {0} to project {1}", project1, project2);
}
export function errorFindingBuildFilesLocation(err: any) {

Check warning on line 538 in extensions/sql-database-projects/src/common/constants.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
return l10n.t("Error finding build files location: {0}", utils.getErrorMessage(err));
}
export function projBuildFailed(errorMessage: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ export default class MainController implements vscode.Disposable {
},
),
);
this.context.subscriptions.push(
vscode.commands.registerCommand(
"sqlDatabaseProjects.moveToSchema",
async (node: WorkspaceTreeItem) => {
await this.projectsController.moveToSchema(node);
},
),
);
this.context.subscriptions.push(
vscode.commands.registerCommand(
"sqlDatabaseProjects.createProjectFromDatabase",
Expand Down
Loading
Loading