Skip to content

Commit

Permalink
create dbt project from VS Code (#446)
Browse files Browse the repository at this point in the history
* activate extension after vscode startup

* enable mocha tests on client

* delete dependency from server

* add command for creating project

* handle ctrl+c

* add log

* add test


* support different versions

* fix setting project path

* skip open for test

* rename test

* improve test

* add logs

* increase retry timeout

* create settings.json for new project

* kill process after closing terminal

* skip test on linux

* delete test

* replace only first tilde

* enable autosave

* delete test

* Revert "add logs"

This reverts commit af92a7b.

* extract const

* add readme for create project command
  • Loading branch information
pgrivachev authored Oct 10, 2022
1 parent 0b3db2b commit 2be5eb3
Show file tree
Hide file tree
Showing 14 changed files with 293 additions and 12 deletions.
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ jobs:
export DISPLAY=:99
activate-venv: source ~/dbt_1_0_8_env/bin/activate
python-version: '3.9.12'
SKIP_TESTS: 'create_project.spec.js'

- install-dbt: 'python -m pip install dbt-bigquery dbt-postgres'
os: windows-latest
Expand Down
2 changes: 1 addition & 1 deletion .mocharc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"package": "./package.json",
"slow": "75ms",
"timeout": "1s",
"spec": "./server/out/test/**/*.spec.js",
"spec": ["./server/out/test/**/*.spec.js", "./client/out/test/**/*.spec.js"],
"fail-zero": true
}
8 changes: 5 additions & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@

"files.eol": "\n",

"mochaExplorer.files": "server/out/test/**/*.js",
"mochaExplorer.files": ["server/out/test/**/*.js", "client/out/test/**/*.js"],
"mochaExplorer.require": "source-map-support/register",
"mochaExplorer.watch": "server/out/**/*.js",
"mochaExplorer.watch": ["server/out/**/*.js", "client/out/**/*.js"],

"cSpell.words": [
"bigdecimal",
Expand Down Expand Up @@ -72,6 +72,7 @@
"Pmfy",
"promisified",
"protos",
"Pseudoterminal",
"publicsuffix",
"pyenv",
"rockset",
Expand Down Expand Up @@ -99,5 +100,6 @@
"Xvfb",
"zetasql"
],
"cSpell.language": "en,en-GB"
"cSpell.language": "en,en-GB",
"files.autoSave": "afterDelay"
}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ When you open dbt model you can use dbt Wizard status bar items. The following a

![dbt status](images/status-items.gif)

### Create a new dbt project from scratch

You can create a new dbt project using Command Palette. Press <kbd>F1</kbd> (or <kbd>⇧</kbd><kbd>⌘</kbd><kbd>P</kbd>) to run Command Palette, type `dbtWizard: Create dbt Project` and press Enter. You should choose new project location and then answer all questions. This will open the new project in a separate VS Code window.

### Install dbt packages

You can install dbt packages by running `dbtWizard.installDbtPackages` command or by using language status item menu. After selecting package name and version, the `packages.yml` file will be updated automatically.
Expand Down
2 changes: 2 additions & 0 deletions client/src/ExtensionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { TelemetryClient } from './TelemetryClient';

import { EventEmitter } from 'node:events';
import * as path from 'node:path';
import { CreateDbtProject } from './commands/CreateDbtProject/CreateDbtProject';

export interface PackageJson {
name: string;
Expand Down Expand Up @@ -83,6 +84,7 @@ export class ExtensionClient {
registerCommands(): void {
this.commandManager.register(new Compile(this.dbtLanguageClientManager));
this.commandManager.register(new AfterFunctionCompletion());
this.commandManager.register(new CreateDbtProject());
this.commandManager.register(new InstallLatestDbt(this.dbtLanguageClientManager, this.outputChannelProvider));
this.commandManager.register(new InstallDbtAdapters(this.dbtLanguageClientManager, this.outputChannelProvider));
this.commandManager.register(new OpenOrCreatePackagesYml());
Expand Down
122 changes: 122 additions & 0 deletions client/src/commands/CreateDbtProject/CreateDbtProject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { exec } from 'node:child_process';
import { EOL } from 'node:os';
import { promisify, TextEncoder } from 'node:util';
import { commands, OpenDialogOptions, Uri, window, workspace } from 'vscode';
import { log } from '../../Logger';
import { PythonExtension } from '../../python/PythonExtension';
import { Command } from '../CommandManager';
import { DbtInitTerminal } from './DbtInitTerminal';

enum DbtInitState {
Default,
ExpectProjectName,
ProjectAlreadyExists,
}

export class CreateDbtProject implements Command {
readonly id = 'dbtWizard.createDbtProject';

static readonly TERMINAL_NAME = 'Create dbt project';
static readonly SETTINGS_JSON_CONTENT = `{
"files.autoSave": "afterDelay"
}
`;

async execute(projectFolder?: string, skipOpen?: boolean): Promise<void> {
const dbtInitCommandPromise = this.getDbtInitCommand();

const projectFolderUri = projectFolder ? Uri.file(projectFolder) : await CreateDbtProject.openDialogForFolder();
if (projectFolderUri) {
const pty = new DbtInitTerminal(`Creating dbt project in "${projectFolderUri.fsPath}" folder.\n\rPlease answer all questions.\n\r`);
const terminal = window.createTerminal({
name: CreateDbtProject.TERMINAL_NAME,
pty,
});
terminal.show();

const dbtInitCommand = await dbtInitCommandPromise;
if (!dbtInitCommand) {
window
.showWarningMessage('dbt not found, please choose Python that is used to run dbt.')
.then(undefined, e => log(`Error while showing warning: ${e instanceof Error ? e.message : String(e)}`));
await commands.executeCommand('python.setInterpreter');
return;
}

log(`Running init command: ${dbtInitCommand}`);

const initProcess = exec(dbtInitCommand, { cwd: projectFolderUri.fsPath });

let dbtInitState = DbtInitState.Default;
let projectName: string | undefined;
initProcess.on('exit', async (code: number | null) => {
if (code === 0 && dbtInitState !== DbtInitState.ProjectAlreadyExists) {
const projectUri = projectName ? Uri.joinPath(projectFolderUri, projectName) : projectFolderUri;
const vscodeUri = Uri.joinPath(projectUri, '.vscode');
await workspace.fs.createDirectory(vscodeUri);
await workspace.fs.writeFile(Uri.joinPath(vscodeUri, 'settings.json'), new TextEncoder().encode(CreateDbtProject.SETTINGS_JSON_CONTENT));
if (!skipOpen) {
await commands.executeCommand('vscode.openFolder', projectUri, { forceNewWindow: true });
}
} else {
pty.writeRed(`${EOL}Command failed, please try again.${EOL}`);
dbtInitState = DbtInitState.Default;
}
});

pty.onDataSubmitted((data: string) => {
if (dbtInitState === DbtInitState.ExpectProjectName) {
projectName = data;
dbtInitState = DbtInitState.Default;
}
if (data.includes(DbtInitTerminal.CONTROL_CODES.ctrlC)) {
initProcess.kill('SIGTERM');
}
initProcess.stdin?.write(`${data}${EOL}`);
});

initProcess.stdout?.on('data', (data: string) => {
if (data.includes('name for your project')) {
dbtInitState = DbtInitState.ExpectProjectName;
} else if (data.includes('already exists here')) {
dbtInitState = DbtInitState.ProjectAlreadyExists;
}

pty.write(data);
});
}
}

async getDbtInitCommand(): Promise<string | undefined> {
const pythonInfo = await new PythonExtension().getPythonInfo();
if (!pythonInfo) {
return undefined;
}

const promisifiedExec = promisify(exec);
const pythonCommand = `${pythonInfo.path} -c "import dbt.main; dbt.main.main(['--version'])"`;
const cliCommand = 'dbt --version';

const settledResults = await Promise.allSettled([promisifiedExec(pythonCommand), promisifiedExec(cliCommand)]);
const [pythonVersion, cliVersion] = settledResults.map(v => (v.status === 'fulfilled' ? v.value : undefined));
if (pythonVersion && pythonVersion.stderr.length > 0) {
return `${pythonInfo.path} -c "import dbt.main; dbt.main.main(['init'])"`;
}
if (cliVersion && cliVersion.stderr.length > 0) {
return 'dbt init';
}
return undefined;
}

static async openDialogForFolder(): Promise<Uri | undefined> {
const options: OpenDialogOptions = {
title: 'Choose Folder For dbt Project',
openLabel: 'Open',
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
};
const result = await window.showOpenDialog(options);
return result && result.length > 0 ? result[0] : undefined;
}
}
69 changes: 69 additions & 0 deletions client/src/commands/CreateDbtProject/DbtInitTerminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { EOL } from 'node:os';
import { Event, EventEmitter, Pseudoterminal, TerminalDimensions } from 'vscode';

export class DbtInitTerminal implements Pseudoterminal {
static readonly CONTROL_CODES = {
ctrlC: '\u0003',
enter: '\r',
backspace: '\u007F',
moveCursorBack: '\u001B[D',
deleteCharacter: '\u001B[P',
};

private writeEmitter = new EventEmitter<string>();
private dataSubmittedEventEmitter = new EventEmitter<string>();

constructor(private startMessage: string) {}

onDidWrite = this.writeEmitter.event;
line = '';

open(_initialDimensions: TerminalDimensions | undefined): void {
this.writeRed(this.startMessage);
}

close(): void {
this.dataSubmittedEventEmitter.fire(DbtInitTerminal.CONTROL_CODES.ctrlC);
}

handleInput(data: string): void {
if (data === DbtInitTerminal.CONTROL_CODES.enter) {
this.writeEmitter.fire('\r\n');
this.dataSubmittedEventEmitter.fire(this.line);
this.line = '';
return;
}
if (data === DbtInitTerminal.CONTROL_CODES.backspace) {
if (this.line.length === 0) {
return;
}
this.line = this.line.slice(0, -1);
this.writeEmitter.fire(DbtInitTerminal.CONTROL_CODES.moveCursorBack);
this.writeEmitter.fire(DbtInitTerminal.CONTROL_CODES.deleteCharacter);
return;
}
if (data.includes(DbtInitTerminal.CONTROL_CODES.ctrlC)) {
this.dataSubmittedEventEmitter.fire(DbtInitTerminal.CONTROL_CODES.ctrlC);
}
if (data.includes(DbtInitTerminal.CONTROL_CODES.enter)) {
const lines = data.split(DbtInitTerminal.CONTROL_CODES.enter);
this.line = lines[lines.length - 1];
this.writeEmitter.fire(data.replaceAll(DbtInitTerminal.CONTROL_CODES.enter, '\n\r'));
} else {
this.line += data;
this.writeEmitter.fire(data);
}
}

write(data: string): void {
this.writeEmitter.fire(data.replaceAll(EOL, '\n\r'));
}

writeRed(data: string): void {
this.write(`\u001B[31m${data}\u001B[0m`);
}

get onDataSubmitted(): Event<string> {
return this.dataSubmittedEventEmitter.event;
}
}
2 changes: 1 addition & 1 deletion e2e/src/dbt_compile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from './helper';

suite('Should compile jinja expressions', () => {
test('Should recompile jinja expression changed', async () => {
test('Should recompile when jinja expression changed', async () => {
const selectFromTestTable1 = 'select * from dbt_ls_e2e_dataset.test_table1';
const selectFromUsers = 'select * from dbt_ls_e2e_dataset.users';
const docUri = getDocUri('dbt_compile.sql');
Expand Down
14 changes: 11 additions & 3 deletions e2e/src/editing_outside_jinja.spec.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import { assertThat } from 'hamjest';
import { languages } from 'vscode';
import { EOL } from 'node:os';
import { DiagnosticSeverity, Range } from 'vscode';
import { assertAllDiagnostics } from './asserts';
import { activateAndWait, getCustomDocUri, getMainEditorText, getPreviewText, replaceText, setTestContent } from './helper';

suite('Editing outside jinja without recompilation', () => {
const DOC_URI = getCustomDocUri('completion-jinja/models/join_ref.sql');
const DIAGNOSTICS = [
{
message: `Compilation Error in model join_ref (models/join_ref.sql)${EOL} unexpected '}', expected ')'${EOL} line 6${EOL} }}`,
range: new Range(5, 0, 5, 100),
severity: DiagnosticSeverity.Error,
},
];

test('Should not compile outside jinja if error exists', async () => {
await activateAndWait(DOC_URI);
const initialContent = getMainEditorText();
const initialPreview = getPreviewText();

await replaceText(' )', ' ');
const dbtDiagnostics = languages.getDiagnostics(DOC_URI);
await assertAllDiagnostics(DOC_URI, DIAGNOSTICS);

await replaceText('select u.id', 'select u.i');
assertThat(getPreviewText(), getMainEditorText());
await assertAllDiagnostics(DOC_URI, dbtDiagnostics);
await assertAllDiagnostics(DOC_URI, DIAGNOSTICS);

await setTestContent(initialContent);
assertThat(getPreviewText(), initialPreview);
Expand Down
10 changes: 10 additions & 0 deletions e2e/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
CompletionList,
DefinitionLink,
extensions,
ExtensionTerminalOptions,
Position,
Pseudoterminal,
Range,
Selection,
SignatureHelp,
Expand Down Expand Up @@ -306,6 +308,10 @@ export async function executeInstallLatestDbt(): Promise<void> {
return commands.executeCommand('dbtWizard.installLatestDbt', undefined, true);
}

export async function executeCreateDbtProject(fsPath: string): Promise<void> {
return commands.executeCommand('dbtWizard.createDbtProject', fsPath, true);
}

export async function moveCursorLeft(): Promise<unknown> {
return commands.executeCommand('cursorMove', {
to: 'left',
Expand Down Expand Up @@ -414,3 +420,7 @@ function trimPath(rawPath: string): string {
.replace(/^[\\/]+/, '')
.replace(/[\\/]+$/, '');
}

export function getCreateProjectPseudoterminal(): Pseudoterminal {
return (window.terminals.find(t => t.name === 'Create dbt project')?.creationOptions as ExtensionTerminalOptions).pty;
}
Loading

0 comments on commit 2be5eb3

Please sign in to comment.