├── .node-version ├── server ├── src │ ├── __tests__ │ │ ├── __fixtures__ │ │ │ ├── queries │ │ │ ├── migrations │ │ │ └── definitions │ │ └── helpers │ │ │ ├── server.ts │ │ │ ├── settings.ts │ │ │ ├── logger.ts │ │ │ ├── textDocuments.ts │ │ │ └── file.ts │ ├── server │ │ ├── index.ts │ │ └── settingsManager.ts │ ├── utilities │ │ ├── regex.ts │ │ ├── neverReach.ts │ │ ├── functool.ts │ │ ├── logger.ts │ │ ├── disableLanguageServer.ts │ │ ├── schema.ts │ │ ├── sanitizeWord.test.ts │ │ └── sanitizeWord.ts │ ├── postgres │ │ ├── index.ts │ │ ├── kind.ts │ │ ├── parameters │ │ │ ├── helpers.ts │ │ │ ├── defaultParameters.test.ts │ │ │ ├── positionalParameters.ts │ │ │ ├── defaultParameters.ts │ │ │ ├── keywordParameters.test.ts │ │ │ ├── index.ts │ │ │ └── keywordParameters.ts │ │ ├── queries │ │ │ ├── index.ts │ │ │ ├── querySchemas.ts │ │ │ ├── queryTriggerDefinitions.ts │ │ │ ├── queryTablePartition.ts │ │ │ ├── queryViewDefinitions.ts │ │ │ ├── queryIndexDefinitions.ts │ │ │ ├── queryMaterializedViewDefinitions.ts │ │ │ ├── queryDomainDefinitions.ts │ │ │ ├── queryTableTriggers.ts │ │ │ ├── queryTableConstraints.ts │ │ │ ├── queryTableDefinitions.ts │ │ │ ├── queryFileStaticAnalysis.ts │ │ │ ├── queryTableIndexes.ts │ │ │ └── queryTypeDefinitions.ts │ │ ├── pool.test.ts │ │ ├── pool.ts │ │ └── parsers │ │ │ └── parseFunctions.ts │ ├── jest.d.ts │ ├── commands │ │ ├── index.ts │ │ ├── executeFileQuery.ts │ │ ├── validateWorkspace.ts │ │ └── validateWorkspace.test.ts │ ├── main.ts │ ├── services │ │ ├── symbol.ts │ │ ├── definition.ts │ │ ├── codeLens.ts │ │ ├── codeAction.ts │ │ ├── codeLens.test.ts │ │ ├── codeAction.test.ts │ │ ├── symbol.test.ts │ │ └── validation.ts │ ├── settings.ts │ └── errors.ts ├── .vscode │ └── settings.json ├── tsconfig.json ├── webpack.config.js ├── jest.config.ts └── package.json ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── QUESTION.md │ ├── BUG_REPORT.md │ └── ENHANCEMENT.md └── workflows │ └── CI.yaml ├── client ├── src │ ├── extension │ │ ├── index.ts │ │ ├── extension.ts │ │ ├── clientManager.ts │ │ └── handlers.ts │ ├── main.ts │ └── options │ │ ├── serverOptions.ts │ │ └── clientOptions.ts ├── webpack.config.js ├── tsconfig.json └── package.json ├── sample ├── queries │ ├── correct_query.pgsql │ ├── syntax_error_query_with_language_server_disable_comment.pgsql │ ├── syntax_error_query_with_language_server_disable_block_comment.pgsql │ ├── correct_query_with_post_migrations_run.pgsql │ ├── syntax_error_query_with_language_server_disable_validation_comment.pgsql │ ├── syntax_error_query_with_language_server_disable_validation_block_comment.pgsql │ ├── correct_query_with_keyword_parameter.pgsql │ ├── correct_query_with_default_positional_parameter.pgsql │ ├── correct_query_with_positional_parameter.pgsql │ ├── correct_query_with_multiple_keyword_parameter.pgsql │ ├── correct_query_with_arbitory_positional_parameter.pgsql │ ├── correct_query_with_default_keyword_parameter.pgsql │ ├── correct_query_with_arbitory_keyword_parameter.pgsql │ ├── correct_query_with_multiple_statements.pgsql │ ├── correct_query_with_ts_query_keyword_parameter.pgsql │ └── correct_query_with_migrations_run.pgsql ├── migrations │ ├── migrations_test │ │ ├── 0002.down.pgsql │ │ ├── 0001.down.pgsql │ │ ├── post-migrations │ │ │ └── 1-example.pgsql │ │ ├── 0002.up.pgsql │ │ └── 0001.up.pgsql │ └── bad_migrations_test │ │ ├── 0002.down.pgsql │ │ ├── 0001.down.pgsql │ │ ├── 0002.up.pgsql │ │ └── 0001.up.pgsql ├── Dockerfile ├── definitions │ ├── type │ │ ├── type_empty.pgsql │ │ ├── type_user.pgsql │ │ ├── type_single_field.pgsql │ │ ├── type_empty.pgsql.json │ │ ├── type_single_field.pgsql.json │ │ └── type_user.pgsql.json │ ├── table │ │ ├── empty_table.pgsql │ │ ├── companies.pgsql │ │ ├── schedule.pgsql │ │ ├── campaign_participants.pgsql │ │ ├── public_users.pgsql │ │ ├── empty_table.pgsql.json │ │ └── companies.pgsql.json │ ├── index │ │ ├── users_id_name_index.pgsql │ │ └── users_id_name_index.pgsql.json │ ├── domain │ │ ├── jp_postal_code.pgsql │ │ ├── us_postal_code.pgsql │ │ └── jp_postal_code.pgsql.json │ ├── function │ │ ├── correct_uppercase_function.pgsql │ │ ├── constant_function.pgsql │ │ ├── keyword_argument_function.pgsql │ │ ├── positional_argument_function.pgsql │ │ ├── static_analysis_warning_function_unused_variable.pgsql │ │ ├── correct_function.pgsql │ │ ├── syntax_error_function_column_does_not_exist.pgsql │ │ ├── correct_uppercase_function.pgsql.json │ │ ├── correct_function.pgsql.json │ │ ├── constant_function.pgsql.json │ │ ├── static_analysis_warning_function_unused_variable.pgsql.json │ │ ├── keyword_argument_function.pgsql.json │ │ └── syntax_error_function_column_does_not_exist.pgsql.json │ ├── materialized_view │ │ ├── my_users.pgsql │ │ └── my_users.pgsql.json │ ├── view │ │ ├── deleted_users.pgsql │ │ ├── public_deleted_users.pgsql │ │ ├── campaign_deleted_participants.pgsql │ │ ├── public_deleted_users.pgsql.json │ │ ├── campaign_deleted_participants.pgsql.json │ │ └── deleted_users.pgsql.json │ ├── procedure │ │ ├── correct_procedure.pgsql │ │ └── correct_procedure.pgsql.json │ └── trigger │ │ ├── user_update.pgsql │ │ └── user_update.pgsql.json ├── .env ├── initialize.pgsql ├── pgsql-parse.sh ├── psql.sh ├── .vscode │ └── settings.json ├── prepare.sh └── sample.code-workspace ├── .gitignore ├── images ├── postgres.png ├── preview.gif ├── code_lens.png └── execute_file_query_command.png ├── .eslintignore ├── webpack.config.js ├── syntaxes └── pgsql.abnf ├── .vscode ├── extensions.json ├── settings.json ├── launch.json └── tasks.json ├── docker-compose.yaml ├── tsconfig.json ├── .vscodeignore ├── language-configuration.json ├── LICENSE ├── DEVELOP.ja.md ├── plpgsql-lsp.code-workspace ├── DEVELOP.md ├── webpack.config.default.js └── .eslintrc.json /.node-version: -------------------------------------------------------------------------------- 1 | 18.7.0 2 | -------------------------------------------------------------------------------- /server/src/__tests__/__fixtures__/queries: -------------------------------------------------------------------------------- 1 | ../../../../sample/queries -------------------------------------------------------------------------------- /server/src/server/index.ts: -------------------------------------------------------------------------------- 1 | export { Server } from "./server" 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global Owners 2 | * @yas7010uv 3 | * yassun4dev 4 | -------------------------------------------------------------------------------- /client/src/extension/index.ts: -------------------------------------------------------------------------------- 1 | export { Extension } from "./extension" 2 | -------------------------------------------------------------------------------- /server/src/__tests__/__fixtures__/migrations: -------------------------------------------------------------------------------- 1 | ../../../../sample/migrations -------------------------------------------------------------------------------- /sample/queries/correct_query.pgsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | id 3 | FROM 4 | users; 5 | -------------------------------------------------------------------------------- /server/src/__tests__/__fixtures__/definitions: -------------------------------------------------------------------------------- 1 | ../../../../sample/definitions -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules/ 3 | client/server 4 | .vscode-test 5 | *.vsix 6 | -------------------------------------------------------------------------------- /images/postgres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UniqueVision/plpgsql-lsp/HEAD/images/postgres.png -------------------------------------------------------------------------------- /images/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UniqueVision/plpgsql-lsp/HEAD/images/preview.gif -------------------------------------------------------------------------------- /images/code_lens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UniqueVision/plpgsql-lsp/HEAD/images/code_lens.png -------------------------------------------------------------------------------- /sample/migrations/migrations_test/0002.down.pgsql: -------------------------------------------------------------------------------- 1 | drop table if exists migrations_test.user_team cascade; 2 | -------------------------------------------------------------------------------- /sample/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:14 2 | 3 | RUN apt update && apt install -y postgresql-$PG_MAJOR-plpgsql-check 4 | -------------------------------------------------------------------------------- /sample/migrations/bad_migrations_test/0002.down.pgsql: -------------------------------------------------------------------------------- 1 | drop table if exists bad_migrations_test.user_team cascade; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | client/node_modules/** 3 | client/out/** 4 | server/node_modules/** 5 | server/out/** 6 | -------------------------------------------------------------------------------- /sample/definitions/type/type_empty.pgsql: -------------------------------------------------------------------------------- 1 | DROP TYPE IF EXISTS type_empty CASCADE; 2 | 3 | CREATE TYPE type_empty AS (); 4 | -------------------------------------------------------------------------------- /sample/definitions/table/empty_table.pgsql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS empty_table CASCADE; 2 | 3 | CREATE TABLE empty_table(); 4 | -------------------------------------------------------------------------------- /sample/definitions/index/users_id_name_index.pgsql: -------------------------------------------------------------------------------- 1 | CREATE INDEX IF NOT EXISTS users_id_name_index ON public.users (id, name); 2 | -------------------------------------------------------------------------------- /images/execute_file_query_command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UniqueVision/plpgsql-lsp/HEAD/images/execute_file_query_command.png -------------------------------------------------------------------------------- /sample/.env: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PASSWORD=password 3 | POSTGRES_HOST=localhost 4 | POSTGRES_PORT=5432 5 | POSTGRES_DB=postgres 6 | -------------------------------------------------------------------------------- /sample/definitions/type/type_user.pgsql: -------------------------------------------------------------------------------- 1 | DROP TYPE IF EXISTS type_user CASCADE; 2 | 3 | CREATE TYPE type_user AS ( 4 | id uuid, 5 | name text 6 | ); 7 | -------------------------------------------------------------------------------- /server/src/utilities/regex.ts: -------------------------------------------------------------------------------- 1 | export function escapeRegex(string: string): string { 2 | return string.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&") 3 | } 4 | -------------------------------------------------------------------------------- /sample/definitions/type/type_single_field.pgsql: -------------------------------------------------------------------------------- 1 | DROP TYPE IF EXISTS type_single_field CASCADE; 2 | 3 | CREATE TYPE type_single_field AS ( 4 | id uuid 5 | ); 6 | -------------------------------------------------------------------------------- /server/src/postgres/index.ts: -------------------------------------------------------------------------------- 1 | export { getPool, PostgresClient, PostgresPool, PostgresPoolMap } from "./pool" 2 | export { PostgresDefinition } from "./queries" 3 | -------------------------------------------------------------------------------- /sample/queries/syntax_error_query_with_language_server_disable_comment.pgsql: -------------------------------------------------------------------------------- 1 | -- plpgsql-language-server:disable 2 | 3 | SELECT 4 | name, 5 | tags 6 | FROM 7 | public.users; 8 | -------------------------------------------------------------------------------- /sample/queries/syntax_error_query_with_language_server_disable_block_comment.pgsql: -------------------------------------------------------------------------------- 1 | /* plpgsql-language-server:disable */ 2 | 3 | SELECT 4 | name, 5 | tags 6 | FROM 7 | public.users; 8 | -------------------------------------------------------------------------------- /sample/definitions/domain/jp_postal_code.pgsql: -------------------------------------------------------------------------------- 1 | DROP DOMAIN IF EXISTS public.jp_postal_code; 2 | 3 | CREATE DOMAIN public.jp_postal_code AS text 4 | CHECK( 5 | VALUE ~ '^\d{3}-\d{4}$' 6 | ); 7 | -------------------------------------------------------------------------------- /sample/definitions/function/correct_uppercase_function.pgsql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION CONSTANT_VALUE() 2 | RETURNS TEXT AS 3 | $$SELECT TEXT '00001'$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE; 4 | -------------------------------------------------------------------------------- /sample/definitions/table/companies.pgsql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS companies CASCADE; 2 | 3 | CREATE TABLE companies ( 4 | id integer not null PRIMARY KEY, 5 | name varchar(10) not null UNIQUE 6 | ); 7 | -------------------------------------------------------------------------------- /sample/queries/correct_query_with_post_migrations_run.pgsql: -------------------------------------------------------------------------------- 1 | do $BODY$ 2 | begin 3 | drop function before_update_updated_at; -- ensure fail when not found 4 | end; 5 | $BODY$ 6 | language plpgsql; 7 | -------------------------------------------------------------------------------- /server/src/utilities/neverReach.ts: -------------------------------------------------------------------------------- 1 | import { NeverReachError } from "@/errors" 2 | 3 | export function neverReach(message = "never reach."): never { 4 | throw new NeverReachError(message) 5 | } 6 | -------------------------------------------------------------------------------- /sample/definitions/domain/us_postal_code.pgsql: -------------------------------------------------------------------------------- 1 | DROP DOMAIN IF EXISTS us_postal_code; 2 | 3 | CREATE DOMAIN us_postal_code AS text 4 | CHECK( 5 | VALUE ~ '^\d{5}$' OR VALUE ~ '^\d{5}-\d{4}$' 6 | ); 7 | -------------------------------------------------------------------------------- /sample/queries/syntax_error_query_with_language_server_disable_validation_comment.pgsql: -------------------------------------------------------------------------------- 1 | -- plpgsql-language-server:disable validation 2 | 3 | SELECT 4 | name, 5 | tags 6 | FROM 7 | public.users; 8 | -------------------------------------------------------------------------------- /sample/migrations/migrations_test/0001.down.pgsql: -------------------------------------------------------------------------------- 1 | drop table if exists migrations_test.users cascade; 2 | drop table if exists migrations_test.teams cascade; 3 | drop schema if exists migrations_test cascade; 4 | -------------------------------------------------------------------------------- /server/src/postgres/kind.ts: -------------------------------------------------------------------------------- 1 | export enum PostgresKind { 2 | Schema, 3 | Table, 4 | View, 5 | MaterializedView, 6 | Type, 7 | Domain, 8 | Index, 9 | Function, 10 | Trigger, 11 | } 12 | -------------------------------------------------------------------------------- /sample/definitions/materialized_view/my_users.pgsql: -------------------------------------------------------------------------------- 1 | DROP MATERIALIZED VIEW IF EXISTS public.my_users CASCADE; 2 | 3 | CREATE MATERIALIZED VIEW public.my_users AS 4 | SELECT 5 | * 6 | FROM 7 | users; 8 | -------------------------------------------------------------------------------- /sample/queries/syntax_error_query_with_language_server_disable_validation_block_comment.pgsql: -------------------------------------------------------------------------------- 1 | /* plpgsql-language-server:disable validation */ 2 | 3 | SELECT 4 | name, 5 | tags 6 | FROM 7 | public.users; 8 | -------------------------------------------------------------------------------- /sample/definitions/view/deleted_users.pgsql: -------------------------------------------------------------------------------- 1 | DROP VIEW IF EXISTS deleted_users CASCADE; 2 | 3 | CREATE VIEW deleted_users AS 4 | SELECT 5 | id, 6 | name 7 | FROM 8 | users 9 | WHERE 10 | deleted_at <> NULL; 11 | -------------------------------------------------------------------------------- /sample/migrations/bad_migrations_test/0001.down.pgsql: -------------------------------------------------------------------------------- 1 | drop table if exists bad_migrations_test.users cascade; 2 | drop table if exists bad_migrations_test.teams cascade; 3 | drop schema if exists bad_migrations_test cascade; 4 | -------------------------------------------------------------------------------- /sample/queries/correct_query_with_keyword_parameter.pgsql: -------------------------------------------------------------------------------- 1 | -- plpgsql-language-server:use-keyword-query-parameter 2 | 3 | SELECT 4 | id, 5 | name 6 | FROM 7 | users 8 | WHERE 9 | id = @id AND name = ANY(@names); 10 | -------------------------------------------------------------------------------- /sample/initialize.pgsql: -------------------------------------------------------------------------------- 1 | DROP SCHEMA IF EXISTS public CASCADE; 2 | DROP SCHEMA IF EXISTS campaign CASCADE; 3 | 4 | CREATE SCHEMA public; 5 | CREATE SCHEMA campaign; 6 | 7 | CREATE EXTENSION plpgsql_check; 8 | CREATE EXTENSION pgcrypto; -------------------------------------------------------------------------------- /sample/queries/correct_query_with_default_positional_parameter.pgsql: -------------------------------------------------------------------------------- 1 | /* plpgsql-language-server:use-query-parameter */ 2 | 3 | SELECT 4 | id, 5 | name 6 | FROM 7 | users 8 | WHERE 9 | id = $1 AND name = ANY($2); 10 | -------------------------------------------------------------------------------- /sample/queries/correct_query_with_positional_parameter.pgsql: -------------------------------------------------------------------------------- 1 | /* plpgsql-language-server:use-positional-query-parameter */ 2 | 3 | SELECT 4 | id, 5 | name 6 | FROM 7 | users 8 | WHERE 9 | id = $1 AND name = ANY($2); 10 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | "use strict" 4 | 5 | const clientConfig = require("./client/webpack.config") 6 | const serverConfig = require("./server/webpack.config") 7 | 8 | module.exports = [clientConfig, serverConfig] 9 | -------------------------------------------------------------------------------- /sample/definitions/view/public_deleted_users.pgsql: -------------------------------------------------------------------------------- 1 | DROP VIEW IF EXISTS public.deleted_users CASCADE; 2 | 3 | CREATE VIEW public.deleted_users AS 4 | SELECT 5 | * 6 | FROM 7 | public.users 8 | WHERE 9 | deleted_at <> NULL; 10 | -------------------------------------------------------------------------------- /syntaxes/pgsql.abnf: -------------------------------------------------------------------------------- 1 | commands = command [ semicolon command ] 2 | command = selectcommand / updatecommand 3 | updatecommand = "UPDATE" 4 | selectcommand = "SELECT " [expression ["," expression]] 5 | expression = "test" 6 | semicolon = ";" 7 | -------------------------------------------------------------------------------- /sample/queries/correct_query_with_multiple_keyword_parameter.pgsql: -------------------------------------------------------------------------------- 1 | -- plpgsql-language-server:use-keyword-query-parameter 2 | 3 | SELECT 4 | id, 5 | name 6 | FROM 7 | users 8 | WHERE 9 | id = sqlc.arg('id') AND name = ANY(@names); 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What does this PR do? 2 | 3 | 4 | ### What issues does this PR fix or reference? 5 | 6 | 7 | ### Is it tested? How? 8 | 9 | -------------------------------------------------------------------------------- /server/src/utilities/functool.ts: -------------------------------------------------------------------------------- 1 | export const asyncFlatMap = async ( 2 | arr: Item[], 3 | callback: (value: Item, index: number, array: Item[]) => Promise, 4 | ) => { 5 | return ( 6 | await Promise.all(arr.map(callback)) 7 | ).flat() 8 | } 9 | -------------------------------------------------------------------------------- /sample/definitions/view/campaign_deleted_participants.pgsql: -------------------------------------------------------------------------------- 1 | DROP VIEW IF EXISTS campaign.deleted_participants CASCADE; 2 | 3 | CREATE VIEW campaign.deleted_participants AS 4 | SELECT 5 | * 6 | FROM 7 | campaign.participants 8 | WHERE 9 | deleted_at <> NULL; 10 | -------------------------------------------------------------------------------- /sample/definitions/table/schedule.pgsql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS schedule CASCADE; 2 | 3 | CREATE TABLE schedule( 4 | id SERIAL PRIMARY KEY not null, 5 | room_name TEXT not null, 6 | reservation_time tsrange not null, 7 | EXCLUDE USING GIST (reservation_time WITH &&) 8 | ); 9 | -------------------------------------------------------------------------------- /sample/queries/correct_query_with_arbitory_positional_parameter.pgsql: -------------------------------------------------------------------------------- 1 | -- plpgsql-language-server:use-positional-query-parameter number=2 2 | 3 | SELECT 4 | id, 5 | name, 6 | 'This text contains "$3" :(' 7 | FROM 8 | users 9 | WHERE 10 | id = $1 AND name = ANY($2); 11 | -------------------------------------------------------------------------------- /sample/queries/correct_query_with_default_keyword_parameter.pgsql: -------------------------------------------------------------------------------- 1 | /* plpgsql-language-server:disable validation */ 2 | /* plpgsql-language-server:use-query-parameter */ 3 | 4 | SELECT 5 | id, 6 | name 7 | FROM 8 | users 9 | WHERE 10 | id = :id AND name = ANY(:names); 11 | -------------------------------------------------------------------------------- /sample/pgsql-parse.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")" 6 | 7 | for file in definitions/**/*.pgsql; do 8 | if [ "$file" -nt "$file.json" ]; then 9 | echo "Parse: $file" 10 | npx pgsql-parser "$file" > "$file.json" 11 | fi 12 | done 13 | -------------------------------------------------------------------------------- /sample/definitions/function/constant_function.pgsql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION IF EXISTS constant_function; 2 | 3 | CREATE FUNCTION constant_function() 4 | RETURNS TEXT AS $FUNCTION$ 5 | DECLARE 6 | BEGIN 7 | RETURN 'CONSTANT'; 8 | END; 9 | $FUNCTION$ LANGUAGE plpgsql 10 | IMMUTABLE PARALLEL SAFE; 11 | -------------------------------------------------------------------------------- /sample/definitions/function/keyword_argument_function.pgsql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION IF EXISTS keyword_argument_function; 2 | 3 | CREATE FUNCTION keyword_argument_function( 4 | i integer 5 | ) 6 | RETURNS integer 7 | AS $$ 8 | BEGIN 9 | RETURN i + 1; 10 | END; 11 | $$ 12 | LANGUAGE plpgsql; 13 | -------------------------------------------------------------------------------- /sample/queries/correct_query_with_arbitory_keyword_parameter.pgsql: -------------------------------------------------------------------------------- 1 | -- plpgsql-language-server:use-keyword-query-parameter keywords=[id, names] 2 | 3 | SELECT 4 | id, 5 | name, 6 | 'This text contains "@tags" :(' 7 | FROM 8 | users 9 | WHERE 10 | id = @id AND name = ANY(@names); 11 | -------------------------------------------------------------------------------- /sample/psql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | current_dir=$(pwd) 6 | cd "$(dirname "$0")" 7 | 8 | # shellcheck source=/dev/null 9 | source ".env" 10 | 11 | cd "$current_dir" 12 | 13 | psql "postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:${POSTGRES_PORT:-5432}/$POSTGRES_DB" "$@" 14 | -------------------------------------------------------------------------------- /sample/definitions/procedure/correct_procedure.pgsql: -------------------------------------------------------------------------------- 1 | DROP PROCEDURE IF EXISTS correct_procedure; 2 | 3 | CREATE PROCEDURE correct_procedure( 4 | INOUT p1 text 5 | ) 6 | AS $$ 7 | BEGIN 8 | p1 := '!! ' || p1 || ' !!'; 9 | RAISE NOTICE 'Procedure Parameter: %', p1; 10 | END; 11 | $$ 12 | LANGUAGE plpgsql; 13 | -------------------------------------------------------------------------------- /sample/definitions/function/positional_argument_function.pgsql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION IF EXISTS positional_argument_function; 2 | 3 | CREATE FUNCTION positional_argument_function( 4 | integer, 5 | integer 6 | ) 7 | RETURNS integer AS 8 | 'select $1 + $2;' 9 | LANGUAGE SQL 10 | IMMUTABLE 11 | RETURNS NULL ON NULL INPUT; 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/QUESTION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 🤔 3 | about: Usage question or discussion about the PL/pgSQL Language Server 4 | title: "" 5 | labels: "kind/question" 6 | assignees: "" 7 | --- 8 | 9 | ## Summary 10 | 11 | ## Relevant information 12 | 13 | 14 | -------------------------------------------------------------------------------- /sample/migrations/migrations_test/post-migrations/1-example.pgsql: -------------------------------------------------------------------------------- 1 | create or replace function before_update_updated_at () 2 | returns trigger 3 | as $BODY$ 4 | begin 5 | if row (new.*::text) is distinct from row (old.*::text) then 6 | new.updated_at = NOW(); 7 | end if; 8 | return NEW; 9 | end; 10 | $BODY$ 11 | language plpgsql; 12 | -------------------------------------------------------------------------------- /server/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "plpgsqlLanguageServer.host": "localhost", 3 | "plpgsqlLanguageServer.user": "postgres", 4 | "plpgsqlLanguageServer.password": "password", 5 | "plpgsqlLanguageServer.database": "postgres", 6 | "plpgsqlLanguageServer.definitionFiles": [ 7 | "src/postgres/__fixtures__/**/*.pgsql" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /sample/migrations/migrations_test/0002.up.pgsql: -------------------------------------------------------------------------------- 1 | create table migrations_test.user_team ( 2 | team_id int not null 3 | , user_id uuid not null 4 | , primary key (user_id , team_id) 5 | , foreign key (user_id) references migrations_test.users (user_id) on delete cascade 6 | , foreign key (team_id) references migrations_test.teams (team_id) on delete cascade 7 | ); 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | // List of extensions which should be recommended for users of this workspace. 5 | "recommendations": [ 6 | "dbaeumer.vscode-eslint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from "vscode" 2 | 3 | import { Extension } from "./extension" 4 | 5 | 6 | const extension = new Extension() 7 | 8 | export function activate(context: ExtensionContext): void { 9 | return extension.activate(context) 10 | } 11 | 12 | export function deactivate(): Thenable { 13 | return extension.deactivate() 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | postgres: 5 | build: 6 | context: sample 7 | ports: 8 | - "5432:5432" 9 | env_file: 10 | - sample/.env 11 | volumes: 12 | - postgres-data:/var/lib/postgresql/data 13 | networks: 14 | - default 15 | 16 | networks: 17 | default: 18 | 19 | volumes: 20 | postgres-data: 21 | -------------------------------------------------------------------------------- /sample/migrations/bad_migrations_test/0002.up.pgsql: -------------------------------------------------------------------------------- 1 | create table bad_migrations_test.user_team ( 2 | team_id int not null 3 | , user_id uuid not null 4 | , primary key (user_id , team_id) 5 | , foreign key (user_id) references bad_migrations_test.users (user_id) on delete cascade 6 | -- bad fk 7 | , foreign key (fff) references bad_migrations_test.teams (team_id) on delete cascade 8 | ); 9 | -------------------------------------------------------------------------------- /sample/definitions/table/campaign_participants.pgsql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS campaign.participants CASCADE; 2 | 3 | CREATE TABLE campaign.participants ( 4 | id integer not null PRIMARY KEY, 5 | name varchar(10) not null, 6 | created_at timestamp with time zone not null DEFAULT now(), 7 | deleted_at timestamp with time zone CHECK (deleted_at > created_at) 8 | ) 9 | PARTITION BY HASH (id); 10 | -------------------------------------------------------------------------------- /sample/definitions/function/static_analysis_warning_function_unused_variable.pgsql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION IF EXISTS warning_function_unused_variable; 2 | 3 | CREATE OR REPLACE FUNCTION warning_function_unused_variable( 4 | p_id uuid 5 | ) 6 | RETURNS SETOF uuid AS $FUNCTION$ 7 | DECLARE 8 | w_id uuid; 9 | BEGIN 10 | RETURN QUERY 11 | SELECT 12 | p_id; 13 | END; 14 | $FUNCTION$ LANGUAGE plpgsql; 15 | -------------------------------------------------------------------------------- /client/webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | "use strict" 4 | 5 | const withDefaults = require("../webpack.config.default") 6 | const path = require("path") 7 | 8 | module.exports = withDefaults({ 9 | context: __dirname, 10 | entry: { 11 | extension: "./src/main.ts", 12 | }, 13 | output: { 14 | filename: "extension.js", 15 | path: path.join(__dirname, "out"), 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /server/src/jest.d.ts: -------------------------------------------------------------------------------- 1 | import { CompletionItem, URI } from "vscode-languageserver" 2 | 3 | declare global { 4 | namespace jest { 5 | interface Matchers { 6 | completionItemContaining(expected: CompletionItem): R 7 | 8 | toHoverCodeEqual(expectedCode: string): R 9 | 10 | toDefinitionUriEqual(expectedUri: URI): R 11 | 12 | toSymbolUriEqual(expectedUri: URI): R 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /sample/migrations/migrations_test/0001.up.pgsql: -------------------------------------------------------------------------------- 1 | create schema migrations_test; 2 | 3 | create table migrations_test.teams ( 4 | team_id serial not null 5 | , name text not null unique 6 | , primary key (team_id) 7 | ); 8 | 9 | create table migrations_test.users ( 10 | user_id uuid default gen_random_uuid () not null 11 | , username text not null unique 12 | , email text not null unique 13 | , primary key (user_id) 14 | ); 15 | -------------------------------------------------------------------------------- /sample/migrations/bad_migrations_test/0001.up.pgsql: -------------------------------------------------------------------------------- 1 | create schema bad_migrations_test; 2 | 3 | create table bad_migrations_test.teams ( 4 | team_id serial not null 5 | , name text not null unique 6 | , primary key (team_id) 7 | ); 8 | 9 | create table bad_migrations_test.users ( 10 | user_id uuid default gen_random_uuid () not null 11 | , username text not null unique 12 | , email text not null unique 13 | , primary key (user_id) 14 | ); 15 | -------------------------------------------------------------------------------- /sample/definitions/table/public_users.pgsql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS public.users CASCADE; 2 | 3 | CREATE TABLE public.users ( 4 | id integer not null PRIMARY KEY, 5 | name varchar(10) not null, 6 | company_id integer not null references public.companies(id), 7 | created_at timestamp with time zone not null DEFAULT now(), 8 | updated_at timestamp with time zone not null DEFAULT now(), 9 | deleted_at timestamp with time zone CHECK (deleted_at > created_at) 10 | ); 11 | -------------------------------------------------------------------------------- /sample/definitions/function/correct_function.pgsql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION IF EXISTS correct_function; 2 | 3 | CREATE FUNCTION correct_function( 4 | p_id integer 5 | ) 6 | RETURNS SETOF public.users AS $FUNCTION$ 7 | DECLARE 8 | BEGIN 9 | RETURN QUERY 10 | SELECT 11 | id, 12 | name, 13 | company_id, 14 | created_at, 15 | updated_at, 16 | deleted_at 17 | FROM 18 | public.users 19 | WHERE 20 | id = p_id; 21 | END; 22 | $FUNCTION$ LANGUAGE plpgsql; 23 | -------------------------------------------------------------------------------- /sample/definitions/trigger/user_update.pgsql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION IF EXISTS update_user_update_at CASCADE; 2 | 3 | CREATE FUNCTION update_user_update_at() RETURNS trigger AS $FUNCTION$ 4 | BEGIN 5 | UPDATE users SET updated_at = now(); 6 | END; 7 | $FUNCTION$ 8 | LANGUAGE plpgsql; 9 | 10 | 11 | DROP TRIGGER IF EXISTS check_update_trigger ON users CASCADE; 12 | 13 | CREATE TRIGGER check_update_trigger 14 | AFTER UPDATE ON users 15 | EXECUTE FUNCTION update_user_update_at(); 16 | -------------------------------------------------------------------------------- /server/src/postgres/parameters/helpers.ts: -------------------------------------------------------------------------------- 1 | import { uinteger } from "vscode-languageserver" 2 | 3 | export function makePositionalParamter( 4 | index: uinteger, 5 | keywordParameter: string, 6 | ): string { 7 | let positionalParameter = `$${index + 1}` 8 | 9 | // Add padding to maintain query length 10 | positionalParameter += " ".repeat( 11 | Math.max(0, keywordParameter.length - positionalParameter.length), 12 | ) 13 | 14 | return positionalParameter 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2019", 5 | "lib": [ 6 | "ES2019" 7 | ], 8 | "outDir": "out", 9 | "rootDir": "src", 10 | "sourceMap": true 11 | }, 12 | "include": [ 13 | "src" 14 | ], 15 | "exclude": [ 16 | "node_modules", 17 | ".vscode-test" 18 | ], 19 | "references": [ 20 | { 21 | "path": "./client" 22 | }, 23 | { 24 | "path": "./server" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /server/src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { FILE_QUERY_COMMAND } from "./executeFileQuery" 2 | import { WORKSPACE_VALIDATION_COMMAND } from "./validateWorkspace" 3 | 4 | export const COMMANDS = [FILE_QUERY_COMMAND, WORKSPACE_VALIDATION_COMMAND] as const 5 | 6 | export const COMMAND_NAMES = COMMANDS.map(command => command.name) 7 | 8 | export const COMMAND_TITLE_MAP = Object.fromEntries( 9 | COMMANDS.map(command => [command.name, command.title]), 10 | ) 11 | 12 | export type CommandName = (typeof COMMAND_NAMES)[number] 13 | -------------------------------------------------------------------------------- /client/src/extension/extension.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from "vscode" 2 | 3 | import { ClientManager } from "./clientManager" 4 | import { Handlers } from "./handlers" 5 | 6 | export class Extension { 7 | private handlers?: Handlers 8 | private clientManager: ClientManager = new ClientManager() 9 | 10 | activate(context: ExtensionContext): void { 11 | this.handlers = new Handlers(context, this.clientManager) 12 | } 13 | 14 | deactivate(): Thenable { 15 | return this.clientManager.stop() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "lib": [ 5 | "ES2019" 6 | ], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "strict": true, 11 | "outDir": "out", 12 | "rootDir": "src", 13 | "baseUrl": "src", 14 | "paths": { 15 | "@/*": [ 16 | "*" 17 | ] 18 | } 19 | }, 20 | "include": [ 21 | "src" 22 | ], 23 | "exclude": [ 24 | "node_modules", 25 | ".vscode-test" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createConnection, 3 | ProposedFeatures, 4 | } from "vscode-languageserver/node" 5 | 6 | import { Server } from "@/server" 7 | import { ConsoleLogger } from "@/utilities/logger" 8 | 9 | 10 | // Create a connection for the server. 11 | const connection = (() => { 12 | return (process.argv.indexOf("--stdio") === -1) 13 | ? createConnection(ProposedFeatures.all) 14 | : createConnection() 15 | })() 16 | 17 | // Start Server. 18 | new Server(connection, new ConsoleLogger(connection)).start() 19 | -------------------------------------------------------------------------------- /sample/definitions/function/syntax_error_function_column_does_not_exist.pgsql: -------------------------------------------------------------------------------- 1 | /* plpgsql-language-server:disable validation */ 2 | 3 | DROP FUNCTION IF EXISTS function_column_does_not_exist; 4 | 5 | CREATE FUNCTION function_column_does_not_exist( 6 | p_id integer 7 | ) 8 | RETURNS SETOF public.users AS $FUNCTION$ 9 | DECLARE 10 | BEGIN 11 | RETURN QUERY 12 | SELECT 13 | id, 14 | name, 15 | tags, 16 | deleted_at 17 | FROM 18 | public.users 19 | WHERE 20 | id = p_id; 21 | END; 22 | $FUNCTION$ LANGUAGE plpgsql; 23 | -------------------------------------------------------------------------------- /sample/queries/correct_query_with_multiple_statements.pgsql: -------------------------------------------------------------------------------- 1 | -- plpgsql-language-server:use-keyword-query-parameter 2 | 3 | -- name: ListUser :many 4 | SELECT 5 | id, 6 | name 7 | FROM 8 | users 9 | WHERE 10 | id = sqlc.arg('id'); 11 | 12 | -- name: ListUsers :many 13 | SELECT 14 | id, 15 | name 16 | FROM 17 | users 18 | WHERE 19 | name = ANY(@names); 20 | 21 | -- name: DoNotValidate :many 22 | -- plpgsql-language-server:disable 23 | SELECT 24 | id, 25 | name 26 | FROM 27 | users 28 | WHERE 29 | name = ANY(@names); 30 | -------------------------------------------------------------------------------- /sample/queries/correct_query_with_ts_query_keyword_parameter.pgsql: -------------------------------------------------------------------------------- 1 | -- plpgsql-language-server:use-keyword-query-parameter 2 | 3 | SELECT 4 | id, 5 | LOWER(TS_HEADLINE('english', COALESCE(name, ''), 6 | WEBSEARCH_TO_TSQUERY('english', @query), 'StartSel=<--,StopSel=-->, FragmentDelimiter=$#$')) as headline, 7 | LOWER(TS_HEADLINE('english', COALESCE(name, ''), 8 | WEBSEARCH_TO_TSQUERY('english', sqlc.arg('query2')), 'StartSel=<--,StopSel=-->, FragmentDelimiter=$#$')) as headline2 9 | FROM 10 | users 11 | WHERE 12 | id = @id; 13 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "lib": [ 5 | "ES2019" 6 | ], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "strict": true, 11 | "outDir": "out", 12 | "rootDir": "src", 13 | "baseUrl": "src", 14 | "paths": { 15 | "@/*": [ 16 | "*" 17 | ] 18 | }, 19 | "esModuleInterop": true 20 | }, 21 | "include": [ 22 | "src" 23 | ], 24 | "exclude": [ 25 | "node_modules", 26 | ".vscode-test" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /server/src/utilities/logger.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from "vscode-languageserver" 2 | 3 | export class ConsoleLogger { 4 | constructor(private readonly connection: Connection) { } 5 | 6 | log(message: string): void { 7 | this.connection.console.log(message) 8 | } 9 | 10 | info(message: string): void { 11 | this.connection.console.info(message) 12 | } 13 | 14 | warn(message: string): void { 15 | this.connection.console.warn(message) 16 | } 17 | 18 | error(message: string): void { 19 | this.connection.console.error(message) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 4, 3 | "editor.rulers": [ 4 | 88 5 | ], 6 | "eslint.enable": true, 7 | "files.trimFinalNewlines": true, 8 | "editor.formatOnSave": true, 9 | "editor.comments.insertSpace": true, 10 | "editor.trimAutoWhitespace": true, 11 | "files.trimTrailingWhitespace": true, 12 | "files.insertFinalNewline": true, 13 | "editor.useTabStops": true, 14 | "editor.insertSpaces": false, 15 | "typescript.preferences.quoteStyle": "single", 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll.eslint": true 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-plpgsql-language-client", 3 | "description": "VSCode PL/pgSQL Language Client.", 4 | "license": "MIT", 5 | "version": "0.1.0", 6 | "publisher": "uniquevision", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/UniqueVision/plpgsql-lsp" 10 | }, 11 | "engines": { 12 | "vscode": "^1.68.0" 13 | }, 14 | "dependencies": { 15 | "vscode-languageclient": "^7.0.0" 16 | }, 17 | "devDependencies": { 18 | "@types/vscode": "^1.68.1", 19 | "@vscode/test-electron": "^2.1.5" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github/** 2 | .gitignore 3 | .vscode/** 4 | *.code-workspace 5 | **/.eslint* 6 | **/*.map 7 | **/*.ts 8 | **/tsconfig.json 9 | **/webpack.* 10 | DEVELOP.md 11 | DEVELOP.ja.md 12 | docker-compose.yaml 13 | sample/** 14 | client/node_modules/** 15 | !client/node_modules/vscode-jsonrpc/** 16 | !client/node_modules/vscode-languageclient/** 17 | !client/node_modules/vscode-languageserver-protocol/** 18 | !client/node_modules/vscode-languageserver-types/** 19 | !client/node_modules/{minimatch,brace-expansion,concat-map,balanced-match}/** 20 | !client/node_modules/{semver,lru-cache,yallist}/** 21 | -------------------------------------------------------------------------------- /sample/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "plpgsqlLanguageServer.host": "localhost", 3 | "plpgsqlLanguageServer.user": "postgres", 4 | "plpgsqlLanguageServer.password": "password", 5 | "plpgsqlLanguageServer.database": "postgres", 6 | "plpgsqlLanguageServer.definitionFiles": [ 7 | "definitions/**/*.pgsql" 8 | ], 9 | "plpgsqlLanguageServer.keywordQueryParameterPattern": [ 10 | "@{keyword}", 11 | "sqlc\\.arg\\s*\\('{keyword}'\\)", 12 | "sqlc\\.narg\\s*\\('{keyword}'\\)", 13 | ], 14 | "plpgsqlLanguageServer.statementSeparatorPattern": "-- name:[\\s]+.*", 15 | } 16 | -------------------------------------------------------------------------------- /client/src/extension/clientManager.ts: -------------------------------------------------------------------------------- 1 | import { LanguageClient, URI } from "vscode-languageclient/node" 2 | 3 | export class ClientManager { 4 | global?: LanguageClient 5 | workspaces: Map = new Map() 6 | 7 | stop(): Thenable { 8 | const promises: Thenable[] = [] 9 | 10 | // stop global client. 11 | if (this.global !== undefined) { 12 | promises.push(this.global.stop()) 13 | } 14 | // stop workspace clients. 15 | for (const client of this.workspaces.values()) { 16 | promises.push(client.stop()) 17 | } 18 | 19 | return Promise.all(promises).then(() => undefined) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 🐞 3 | about: Report a bug found in the PL/pgSQL Language Server 4 | title: "" 5 | labels: "kind/bug" 6 | assignees: "" 7 | --- 8 | 9 | ## Describe the bug 10 | 11 | 12 | 13 | ## Expected Behavior 14 | 15 | 16 | 17 | ## Current Behavior 18 | 19 | 20 | 21 | ## Steps to Reproduce 22 | 23 | 24 | 25 | 1. 26 | 2. 27 | 3. 28 | 29 | ## Environment 30 | 31 | - [ ] Windows 32 | - [ ] Mac 33 | - [ ] Linux 34 | - [ ] other (please specify) 35 | -------------------------------------------------------------------------------- /sample/prepare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")" 6 | 7 | sql_file=$(mktemp) 8 | 9 | { 10 | cat initialize.pgsql 11 | cat definitions/domain/*.pgsql 12 | cat definitions/table/*.pgsql 13 | cat definitions/view/*.pgsql 14 | cat definitions/materialized_view/*.pgsql 15 | cat definitions/type/*.pgsql 16 | cat definitions/index/*.pgsql 17 | cat definitions/procedure/*.pgsql 18 | cat definitions/function/*.pgsql 19 | cat definitions/trigger/*.pgsql 20 | } >> "$sql_file" 21 | 22 | if ${DRYRUN:-false}; then 23 | cat "$sql_file" 24 | else 25 | ./psql.sh --set "ON_ERROR_STOP=1" -f "$sql_file" 26 | fi 27 | 28 | rm "$sql_file" 29 | -------------------------------------------------------------------------------- /server/src/postgres/queries/index.ts: -------------------------------------------------------------------------------- 1 | import { DomainDefinition } from "./queryDomainDefinitions" 2 | import { FunctionDefinition } from "./queryFunctionDefinitions" 3 | import { IndexDefinition } from "./queryIndexDefinitions" 4 | import { MaterializedViewDefinition } from "./queryMaterializedViewDefinitions" 5 | import { TriggerDefinition } from "./queryTriggerDefinitions" 6 | import { TypeDefinition } from "./queryTypeDefinitions" 7 | import { ViewDefinition } from "./queryViewDefinitions" 8 | 9 | export type PostgresDefinition = ViewDefinition 10 | | MaterializedViewDefinition 11 | | FunctionDefinition 12 | | TypeDefinition 13 | | DomainDefinition 14 | | IndexDefinition 15 | | TriggerDefinition 16 | -------------------------------------------------------------------------------- /client/src/options/serverOptions.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path" 2 | import { ExtensionContext } from "vscode" 3 | import { 4 | ForkOptions, 5 | ServerOptions, 6 | TransportKind, 7 | } from "vscode-languageclient/node" 8 | 9 | 10 | export function makeLanguageServerOptions( 11 | context: ExtensionContext, debugOptions: ForkOptions, 12 | ): ServerOptions { 13 | // The server is implemented in node 14 | const module = context.asAbsolutePath( 15 | path.join("server", "out", "server.js"), 16 | ) 17 | 18 | return { 19 | run: { module, transport: TransportKind.ipc }, 20 | debug: { 21 | module, 22 | transport: TransportKind.ipc, 23 | options: debugOptions, 24 | }, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/__tests__/helpers/server.ts: -------------------------------------------------------------------------------- 1 | import { createConnection, Logger } from "vscode-languageserver/node" 2 | 3 | import { Server } from "@/server" 4 | import { Settings } from "@/settings" 5 | 6 | import { TestTextDocuments } from "./textDocuments" 7 | 8 | export function setupTestServer(settings: Settings, logger: Logger): Server { 9 | process.argv.push("--node-ipc") 10 | 11 | const connection = createConnection() 12 | const server = new Server(connection, logger, settings) 13 | 14 | server.documents = new TestTextDocuments() 15 | server.initialize({ 16 | processId: null, 17 | capabilities: {}, 18 | rootUri: null, 19 | workspaceFolders: null, 20 | }) 21 | 22 | return server 23 | } 24 | -------------------------------------------------------------------------------- /server/src/__tests__/helpers/settings.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_SETTINGS, Settings } from "@/settings" 2 | 3 | export class SettingsBuilder { 4 | private settings: Settings = DEFAULT_SETTINGS 5 | 6 | constructor() { 7 | this.settings.host = process.env.POSTGRES_HOST ?? "localhost" 8 | this.settings.database = process.env.POSTGRES_DB ?? "postgres" 9 | this.settings.user = process.env.POSTGRES_USER ?? "postgres" 10 | this.settings.password = process.env.POSTGRES_PASSWORD ?? "password" 11 | } 12 | 13 | build(): Settings { 14 | return this.settings 15 | } 16 | 17 | with(settings: Partial): SettingsBuilder { 18 | this.settings = { ...this.settings, ...settings } 19 | 20 | return this 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/src/services/symbol.ts: -------------------------------------------------------------------------------- 1 | import { Logger, SymbolInformation } from "vscode-languageserver" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { parseDocumentSymbols, SymbolsManager } from "@/server/symbolsManager" 5 | import { Settings } from "@/settings" 6 | 7 | export async function getDocumentSymbols( 8 | document: TextDocument, settings: Settings, logger: Logger, 9 | ): Promise { 10 | return parseDocumentSymbols( 11 | document.uri, document.getText(), settings.defaultSchema, logger, 12 | ) 13 | } 14 | 15 | export async function getWorkspaceSymbols( 16 | symbolsManager: SymbolsManager, _logger: Logger, 17 | ): Promise { 18 | return symbolsManager.getSymbols() 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ENHANCEMENT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement 💡 3 | about: Suggest an enhancement for the PL/pgSQL Language Server 4 | title: "" 5 | labels: "kind/enhancement" 6 | assignees: "" 7 | --- 8 | 9 | ### Is your enhancement related to a problem? Please describe. 10 | 11 | 12 | 13 | ### Describe the solution you would like 14 | 15 | 16 | 17 | ### Describe alternatives you have considered 18 | 19 | 20 | 21 | ### Additional context 22 | 23 | 24 | -------------------------------------------------------------------------------- /server/webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | "use strict" 4 | 5 | const withDefaults = require("../webpack.config.default") 6 | const path = require("path") 7 | const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin") 8 | 9 | 10 | module.exports = withDefaults({ 11 | context: __dirname, 12 | resolve: { 13 | mainFields: ["module", "main"], 14 | extensions: [".ts", ".js", ".node"], 15 | alias: { 16 | "@": path.resolve(__dirname), 17 | }, 18 | plugins: [ 19 | new TsconfigPathsPlugin.TsconfigPathsPlugin( 20 | { configFile: "./server/tsconfig.json" }, 21 | ), 22 | ], 23 | }, 24 | entry: { 25 | extension: "./src/main.ts", 26 | }, 27 | output: { 28 | filename: "server.js", 29 | path: path.join(__dirname, "out"), 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /server/src/postgres/queries/querySchemas.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-languageserver" 2 | 3 | import { PostgresPool } from "../pool" 4 | 5 | export async function querySchemas( 6 | pgPool: PostgresPool, logger: Logger, 7 | ): Promise { 8 | let schemas: string[] = [] 9 | 10 | const pgClient = await pgPool.connect() 11 | try { 12 | const results = await pgClient.query(` 13 | SELECT 14 | DISTINCT schema_name 15 | FROM 16 | information_schema.schemata 17 | ORDER BY 18 | schema_name 19 | `) 20 | 21 | schemas = results.rows.map(row => row.schema_name) 22 | } 23 | catch (error: unknown) { 24 | logger.error(`${(error as Error).message}`) 25 | } 26 | finally { 27 | pgClient.release() 28 | } 29 | 30 | return schemas 31 | } 32 | -------------------------------------------------------------------------------- /server/src/commands/executeFileQuery.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-jsonrpc/node" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { PostgresPool } from "@/postgres" 5 | 6 | 7 | export const FILE_QUERY_COMMAND = { 8 | title: "PL/pgSQL: Execute the Current File Query", 9 | name: "plpgsql-lsp.executeFileQuery", 10 | execute: executeFileQuery, 11 | } as const 12 | 13 | async function executeFileQuery( 14 | pgPool: PostgresPool, 15 | document: TextDocument, 16 | logger: Logger, 17 | ): Promise { 18 | const pgClient = await pgPool.connect() 19 | try { 20 | await pgClient.query(document.getText()) 21 | } 22 | catch (error: unknown) { 23 | logger.error((error as Error).message) 24 | 25 | throw error 26 | } 27 | finally { 28 | await pgClient.release() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/src/utilities/disableLanguageServer.ts: -------------------------------------------------------------------------------- 1 | import { TextDocument } from "vscode-languageserver-textdocument" 2 | 3 | import { getFirstLine } from "./text" 4 | 5 | export function disableLanguageServer(document: TextDocument): boolean { 6 | const firstLine = getFirstLine(document) 7 | 8 | return !( 9 | firstLine.match(/^ *-- +plpgsql-language-server:disable *$/) === null 10 | && firstLine.match(/^ *\/\* +plpgsql-language-server:disable +\*\/$/) === null 11 | ) 12 | } 13 | 14 | export function disableValidation(document: TextDocument): boolean { 15 | const firstLine = getFirstLine(document) 16 | 17 | return !( 18 | firstLine.match(/^ *-- +plpgsql-language-server:disable( +validation)? *$/) === null 19 | && firstLine.match( 20 | /^ *\/\* +plpgsql-language-server:disable( +validation)? +\*\/$/, 21 | ) === null 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /server/src/utilities/schema.ts: -------------------------------------------------------------------------------- 1 | export interface SchemaCandidate { 2 | schema?: string, candidate: string 3 | } 4 | 5 | export function makeSchemas( 6 | schema: string | undefined, defaultSchema: string, 7 | ): string[] { 8 | if (schema === undefined) { 9 | return [defaultSchema, "pg_catalog"] 10 | } 11 | else { 12 | return [schema.toLowerCase()] 13 | } 14 | } 15 | 16 | export function separateSchemaFromCandidate( 17 | candidate: string, 18 | ): SchemaCandidate | undefined { 19 | const separated = candidate.split(".") 20 | 21 | if (separated.length === 1) { 22 | return { 23 | schema: undefined, 24 | candidate: separated[0], 25 | } 26 | 27 | } 28 | else if (separated.length === 2) { 29 | return { 30 | schema: separated[0], 31 | candidate: separated[1], 32 | } 33 | 34 | } 35 | else { 36 | return undefined 37 | 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /sample/definitions/type/type_empty.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "TypeName": { 9 | "names": [ 10 | { 11 | "String": { 12 | "str": "type_empty" 13 | } 14 | } 15 | ], 16 | "typemod": -1 17 | } 18 | } 19 | ], 20 | "removeType": "OBJECT_TYPE", 21 | "behavior": "DROP_CASCADE", 22 | "missing_ok": true 23 | } 24 | }, 25 | "stmt_len": 38 26 | } 27 | }, 28 | { 29 | "RawStmt": { 30 | "stmt": { 31 | "CompositeTypeStmt": { 32 | "typevar": { 33 | "relname": "type_empty", 34 | "relpersistence": "p" 35 | } 36 | } 37 | }, 38 | "stmt_len": 30 39 | } 40 | } 41 | ] -------------------------------------------------------------------------------- /server/src/__tests__/helpers/logger.ts: -------------------------------------------------------------------------------- 1 | interface Records { 2 | "log": string[], 3 | "info": string[], 4 | "warn": string[], 5 | "error": string[], 6 | } 7 | 8 | export class RecordLogger { 9 | 10 | private records: Records = { 11 | "log": [], 12 | "info": [], 13 | "warn": [], 14 | "error": [], 15 | } 16 | 17 | get(level: keyof Records): string[] { 18 | return this.records[level] 19 | } 20 | 21 | isEmpty(): boolean { 22 | return (Object.values(this.records) as string[][]).reduce( 23 | (total, value) => { return total + value.length }, 24 | 0, 25 | ) === 0 26 | } 27 | 28 | log(message: string): void { 29 | this.records["log"].push(message) 30 | } 31 | 32 | info(message: string): void { 33 | this.records["info"].push(message) 34 | } 35 | 36 | warn(message: string): void { 37 | this.records["warn"].push(message) 38 | } 39 | 40 | error(message: string): void { 41 | this.records["error"].push(message) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /sample/definitions/table/empty_table.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "List": { 9 | "items": [ 10 | { 11 | "String": { 12 | "str": "empty_table" 13 | } 14 | } 15 | ] 16 | } 17 | } 18 | ], 19 | "removeType": "OBJECT_TABLE", 20 | "behavior": "DROP_CASCADE", 21 | "missing_ok": true 22 | } 23 | }, 24 | "stmt_len": 40 25 | } 26 | }, 27 | { 28 | "RawStmt": { 29 | "stmt": { 30 | "CreateStmt": { 31 | "relation": { 32 | "relname": "empty_table", 33 | "inh": true, 34 | "relpersistence": "p" 35 | }, 36 | "oncommit": "ONCOMMIT_NOOP" 37 | } 38 | }, 39 | "stmt_len": 28 40 | } 41 | } 42 | ] -------------------------------------------------------------------------------- /server/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // Indicates which provider should be used to instrument code for coverage 8 | coverageProvider: "v8", 9 | 10 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 11 | moduleNameMapper: { 12 | "@/(.*)": "/src/$1", 13 | }, 14 | 15 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 16 | modulePathIgnorePatterns: ["out"], 17 | 18 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 19 | testPathIgnorePatterns: ["/node_modules/", "__tests__/helpers"], 20 | 21 | // A map from regular expressions to paths to transformers 22 | transform: { "\\.ts$": ["ts-jest"] }, 23 | 24 | testTimeout: 20000, 25 | } 26 | -------------------------------------------------------------------------------- /sample/definitions/index/users_id_name_index.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "IndexStmt": { 6 | "idxname": "users_id_name_index", 7 | "relation": { 8 | "schemaname": "public", 9 | "relname": "users", 10 | "inh": true, 11 | "relpersistence": "p" 12 | }, 13 | "accessMethod": "btree", 14 | "indexParams": [ 15 | { 16 | "IndexElem": { 17 | "name": "id", 18 | "ordering": "SORTBY_DEFAULT", 19 | "nulls_ordering": "SORTBY_NULLS_DEFAULT" 20 | } 21 | }, 22 | { 23 | "IndexElem": { 24 | "name": "name", 25 | "ordering": "SORTBY_DEFAULT", 26 | "nulls_ordering": "SORTBY_NULLS_DEFAULT" 27 | } 28 | } 29 | ], 30 | "if_not_exists": true 31 | } 32 | }, 33 | "stmt_len": 73 34 | } 35 | } 36 | ] -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "name": "Launch Client", 9 | "runtimeExecutable": "${execPath}", 10 | "args": [ 11 | "--extensionDevelopmentPath=${workspaceRoot}" 12 | ], 13 | "outFiles": [ 14 | "${workspaceRoot}/client/out/**/*.js" 15 | ], 16 | "preLaunchTask": "Watch", 17 | "sourceMaps": true 18 | }, 19 | { 20 | "type": "node", 21 | "request": "attach", 22 | "name": "Attach to Server", 23 | "port": 6017, 24 | "restart": true, 25 | "outFiles": [ 26 | "${workspaceRoot}/server/out/**/*.js" 27 | ], 28 | "sourceMaps": true 29 | } 30 | ], 31 | "compounds": [ 32 | { 33 | "name": "Client + Server", 34 | "configurations": [ 35 | "Launch Client", 36 | "Attach to Server" 37 | ] 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /server/src/__tests__/helpers/textDocuments.ts: -------------------------------------------------------------------------------- 1 | import { TextDocuments, URI } from "vscode-languageserver" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { 5 | DEFAULT_LOAD_FILE_OPTIONS, getSampleFileUri, LoadFileOptions, loadSampleFile, 6 | } from "./file" 7 | 8 | 9 | export class TestTextDocuments extends TextDocuments { 10 | documents = new Map() 11 | 12 | constructor() { 13 | super(TextDocument) 14 | } 15 | 16 | get(uri: URI): TextDocument | undefined { 17 | return this.documents.get(uri) 18 | } 19 | 20 | set(document: TextDocument): void { 21 | this.documents.set(document.uri, document) 22 | } 23 | } 24 | 25 | export async function loadSampleTextDocument( 26 | file: string, 27 | options: LoadFileOptions = DEFAULT_LOAD_FILE_OPTIONS, 28 | ): Promise { 29 | let context = await loadSampleFile(file) 30 | 31 | if (options.skipDisableComment) { 32 | context = context.split("\n").slice(1).join("\n") 33 | } 34 | 35 | return TextDocument.create( 36 | getSampleFileUri(file), 37 | "postgres", 38 | 0, 39 | context, 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "blockComment": [ 4 | "/*", 5 | "*/" 6 | ], 7 | "lineComment": "--" 8 | }, 9 | "brackets": [ 10 | [ 11 | "(", 12 | ")" 13 | ], 14 | [ 15 | "[", 16 | "]" 17 | ], 18 | [ 19 | "{", 20 | "}" 21 | ] 22 | ], 23 | "autoClosingPairs": [ 24 | { 25 | "open": "(", 26 | "close": ")" 27 | }, 28 | { 29 | "open": "[", 30 | "close": "]" 31 | }, 32 | { 33 | "open": "{", 34 | "close": "}" 35 | }, 36 | { 37 | "open": "\"", 38 | "close": "\"", 39 | "notIn": [ 40 | "string" 41 | ] 42 | }, 43 | { 44 | "open": "'", 45 | "close": "'", 46 | "notIn": [ 47 | "string", 48 | "comment" 49 | ] 50 | } 51 | ], 52 | "surroundingPairs": [ 53 | [ 54 | "(", 55 | ")" 56 | ], 57 | [ 58 | "[", 59 | "]" 60 | ], 61 | [ 62 | "{", 63 | "}" 64 | ], 65 | [ 66 | "\"", 67 | "\"" 68 | ], 69 | [ 70 | "'", 71 | "'" 72 | ] 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2021 Naoto Yasutani 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/src/services/definition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefinitionLink, 3 | Logger, 4 | Position, 5 | } from "vscode-languageserver" 6 | import { TextDocument } from "vscode-languageserver-textdocument" 7 | 8 | import { DefinitionsManager } from "@/server/definitionsManager" 9 | import { sanitizeWordCandidates } from "@/utilities/sanitizeWord" 10 | import { getWordRangeAtPosition } from "@/utilities/text" 11 | 12 | 13 | export async function getDefinitionLinks( 14 | definitionsManager: DefinitionsManager, 15 | document: TextDocument, 16 | position: Position, 17 | _logger: Logger, 18 | ): Promise { 19 | const wordRange = getWordRangeAtPosition(document, position) 20 | if (wordRange === undefined) { 21 | return undefined 22 | } 23 | 24 | const word = document.getText(wordRange) 25 | const sanitizedWordCandidates = sanitizeWordCandidates(word) 26 | 27 | for (const wordCandidate of sanitizedWordCandidates) { 28 | const definitionLinks = definitionsManager 29 | .getDefinitionLinks(wordCandidate) 30 | 31 | if (definitionLinks !== undefined) { 32 | return definitionLinks 33 | } 34 | } 35 | 36 | return undefined 37 | } 38 | -------------------------------------------------------------------------------- /server/src/postgres/pool.test.ts: -------------------------------------------------------------------------------- 1 | import { createConnection } from "vscode-languageserver/node" 2 | 3 | import { SettingsBuilder } from "@/__tests__/helpers/settings" 4 | import { Settings } from "@/settings" 5 | import { ConsoleLogger } from "@/utilities/logger" 6 | 7 | import { getPool, PostgresPool } from "./pool" 8 | 9 | 10 | describe("Postgres Pool Tests", () => { 11 | async function createPool( 12 | settings: Settings, 13 | ): Promise { 14 | process.argv.push("--node-ipc") 15 | 16 | const connection = createConnection() 17 | const logger = new ConsoleLogger(connection) 18 | 19 | return await getPool(new Map(), settings, logger) 20 | } 21 | 22 | describe("Settings Tests", function () { 23 | it("Correct Settings", async () => { 24 | const settings = new SettingsBuilder().build() 25 | 26 | const pool = await createPool(settings) 27 | expect(pool).toBeDefined() 28 | }) 29 | 30 | it("Wrong Settings", async () => { 31 | const settings = new SettingsBuilder() 32 | .with({ database: "NonExistentDatabase" }) 33 | .build() 34 | 35 | const pool = await createPool(settings) 36 | expect(pool).toBeUndefined() 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plpgsql-language-server", 3 | "description": "PL/pgSQL Language Server.", 4 | "version": "0.1.0", 5 | "license": "MIT", 6 | "publisher": "uniquevision", 7 | "engines": { 8 | "node": "*" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/UniqueVision/plpgsql-lsp" 13 | }, 14 | "scripts": { 15 | "test": "jest", 16 | "test:ci": "jest --ci", 17 | "lint": "eslint src --ext .ts,.tsx --fix" 18 | }, 19 | "dependencies": { 20 | "glob-promise": "^4.2.2", 21 | "libpg-query": "^13.2.5", 22 | "minimatch": "^5.1.0", 23 | "pg": "^8.7.3", 24 | "pg-native": "^3.0.1", 25 | "ts-dedent": "^2.2.0", 26 | "vscode-languageserver": "^7.0.0", 27 | "vscode-languageserver-textdocument": "^1.0.5", 28 | "vscode-uri": "^3.0.3" 29 | }, 30 | "devDependencies": { 31 | "@types/deep-equal": "^1.0.1", 32 | "@types/glob": "^7.2.0", 33 | "@types/jest": "^28.1.4", 34 | "@types/node": "^18.0.0", 35 | "@types/pg": "^8.6.5", 36 | "deep-equal": "^2.0.5", 37 | "jest": "^28.1.2", 38 | "ts-jest": "^28.0.5", 39 | "ts-node": "^10.8.1", 40 | "typescript": "^4.7.4" 41 | }, 42 | "peerDependency": { 43 | "glob": "^7.2.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/src/__tests__/helpers/file.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs" 2 | import path from "path" 3 | import { URI, WorkspaceFolder } from "vscode-languageserver" 4 | 5 | 6 | export type LoadFileOptions = { 7 | skipDisableComment: boolean, 8 | } 9 | 10 | export const DEFAULT_LOAD_FILE_OPTIONS = { 11 | skipDisableComment: false, 12 | } 13 | 14 | export function getSampleFileUri(file: string): URI { 15 | return `file://${path.join(sampleDirPath(), file)}` 16 | } 17 | 18 | export function getSampleWorkspace(): WorkspaceFolder { 19 | return { 20 | uri: `file://${sampleDirPath()}`, 21 | name: "sample", 22 | } 23 | } 24 | 25 | export async function loadSampleFile( 26 | filename: string, 27 | options: LoadFileOptions = DEFAULT_LOAD_FILE_OPTIONS, 28 | ): Promise { 29 | const fileText = ( 30 | await fs.readFile(path.join(sampleDirPath(), filename)) 31 | ).toString() 32 | if (options.skipDisableComment) { 33 | return skipDisableComment(fileText) 34 | } 35 | else { 36 | return fileText 37 | } 38 | } 39 | 40 | function sampleDirPath(): string { 41 | return path.join(__dirname, "..", "__fixtures__") 42 | } 43 | 44 | function skipDisableComment(fileText: string): string { 45 | return fileText.split("\n").slice(1).join("\n") 46 | } 47 | -------------------------------------------------------------------------------- /server/src/services/codeLens.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-jsonrpc/node" 2 | import { CodeLens, Range } from "vscode-languageserver-protocol/node" 3 | import { TextDocument } from "vscode-languageserver-textdocument/lib/umd/main" 4 | 5 | import { FILE_QUERY_COMMAND } from "@/commands/executeFileQuery" 6 | import { PostgresPool } from "@/postgres" 7 | import { getQueryParameterInfo } from "@/postgres/parameters" 8 | import { Settings } from "@/settings" 9 | 10 | import { isCorrectFileValidation } from "./validation" 11 | 12 | export async function getCodeLenses( 13 | pgPool: PostgresPool, 14 | document: TextDocument, 15 | settings: Settings, 16 | logger: Logger, 17 | ): Promise { 18 | const codeLenses: CodeLens[] = [] 19 | if ( 20 | settings.enableExecuteFileQueryCommand 21 | && getQueryParameterInfo(document, document.getText(), settings, logger) === null 22 | && await isCorrectFileValidation(pgPool, document, settings, logger) 23 | ) { 24 | codeLenses.push(makeExecuteFileQueryCommandCodeLens(document)) 25 | } 26 | 27 | return codeLenses 28 | } 29 | 30 | export function makeExecuteFileQueryCommandCodeLens(document: TextDocument): CodeLens { 31 | const codeLens = CodeLens.create(Range.create(0, 0, 0, 0)) 32 | codeLens.command = { 33 | title: FILE_QUERY_COMMAND.title, 34 | command: FILE_QUERY_COMMAND.name, 35 | arguments: [document.uri], 36 | } 37 | 38 | return codeLens 39 | } 40 | -------------------------------------------------------------------------------- /sample/queries/correct_query_with_migrations_run.pgsql: -------------------------------------------------------------------------------- 1 | do $BODY$ 2 | declare 3 | i int; 4 | ui uuid; 5 | user_ids uuid[]; 6 | begin 7 | for i in 1..10 loop 8 | insert into migrations_test.users (username , email) 9 | values ('user_' || i , 'user_' || i || '@email.com') 10 | returning 11 | user_id into ui; 12 | user_ids[i] = ui; 13 | end loop; 14 | 15 | insert into migrations_test.teams ("name") 16 | values ('team 1'); 17 | insert into migrations_test.teams ("name") 18 | values ('team 2'); 19 | 20 | insert into migrations_test.user_team (team_id , user_id) 21 | values (1 , user_ids[1]); 22 | insert into migrations_test.user_team (team_id , user_id) 23 | values (1 , user_ids[2]); 24 | insert into migrations_test.user_team (team_id , user_id) 25 | values (1 , user_ids[3]); 26 | insert into migrations_test.user_team (team_id , user_id) 27 | values (1 , user_ids[4]); 28 | insert into migrations_test.user_team (team_id , user_id) 29 | values (2 , user_ids[1]); 30 | insert into migrations_test.user_team (team_id , user_id) 31 | values (2 , user_ids[4]); 32 | insert into migrations_test.user_team (team_id , user_id) 33 | values (2 , user_ids[5]); 34 | insert into migrations_test.user_team (team_id , user_id) 35 | values (2 , user_ids[6]); 36 | insert into migrations_test.user_team (team_id , user_id) 37 | values (2 , user_ids[7]); 38 | 39 | end; 40 | $BODY$ 41 | language plpgsql; 42 | -------------------------------------------------------------------------------- /server/src/postgres/pool.ts: -------------------------------------------------------------------------------- 1 | import { Pool, PoolClient } from "pg" 2 | import { Logger } from "vscode-languageserver" 3 | 4 | import { Settings } from "@/settings" 5 | 6 | export type PostgresConfig = { 7 | host: string, 8 | port: number, 9 | database: string, 10 | user: string, 11 | password: string, 12 | } 13 | export type PostgresPool = Pool 14 | export type PostgresClient = PoolClient 15 | 16 | export type PostgresPoolMap = Map 17 | 18 | export async function getPool( 19 | pgPools: PostgresPoolMap, 20 | settings: Settings, 21 | logger: Logger, 22 | ): Promise { 23 | if ( 24 | settings.database === undefined 25 | || settings.user === undefined 26 | || settings.password === undefined 27 | ) { 28 | return undefined 29 | } 30 | 31 | const pgConfig: PostgresConfig = { 32 | host: settings.host, 33 | port: settings.port, 34 | database: settings.database, 35 | user: settings.user, 36 | password: settings.password, 37 | } 38 | 39 | let pgPool = pgPools.get(pgConfig) 40 | if (pgPool === undefined) { 41 | try { 42 | pgPool = new Pool(pgConfig) 43 | 44 | // Try connection. 45 | await pgPool.query("SELECT 1") 46 | } 47 | catch (error: unknown) { 48 | logger.error((error as Error).message) 49 | 50 | return undefined 51 | } 52 | 53 | pgPools.set(pgConfig, pgPool) 54 | } 55 | 56 | return pgPool 57 | } 58 | -------------------------------------------------------------------------------- /server/src/services/codeAction.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-jsonrpc/node" 2 | import { 3 | CodeAction, CodeActionKind, 4 | } from "vscode-languageserver-protocol/node" 5 | import { TextDocument } from "vscode-languageserver-textdocument/lib/umd/main" 6 | 7 | import { FILE_QUERY_COMMAND } from "@/commands/executeFileQuery" 8 | import { PostgresPool } from "@/postgres" 9 | import { getQueryParameterInfo } from "@/postgres/parameters" 10 | import { Settings } from "@/settings" 11 | 12 | import { isCorrectFileValidation } from "./validation" 13 | 14 | 15 | export async function getCodeActions( 16 | pgPool: PostgresPool, 17 | document: TextDocument, 18 | settings: Settings, 19 | logger: Logger, 20 | ): Promise { 21 | const actions: CodeAction[] = [] 22 | if ( 23 | settings.enableExecuteFileQueryCommand 24 | && getQueryParameterInfo(document, document.getText(), settings, logger) === null 25 | && await isCorrectFileValidation(pgPool, document, settings, logger) 26 | ) { 27 | actions.push(makeExecuteFileQueryCommandCodeAction(document)) 28 | } 29 | 30 | return actions 31 | } 32 | 33 | export function makeExecuteFileQueryCommandCodeAction( 34 | document: TextDocument, 35 | ): CodeAction { 36 | return CodeAction.create( 37 | FILE_QUERY_COMMAND.title, 38 | { 39 | title: FILE_QUERY_COMMAND.title, 40 | command: FILE_QUERY_COMMAND.name, 41 | arguments: [document.uri], 42 | }, 43 | CodeActionKind.Source, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /DEVELOP.ja.md: -------------------------------------------------------------------------------- 1 | # 開発者案内 2 | 3 | ## 開発方法 4 | 5 | ここは開発とリリース手順について情報を集める。 6 | 7 | 1. VSCode で推薦拡張をインストールする(`extensions.json`の内容)。 8 | 1. `npm`をインストール。 9 | 1. `npm install` 10 | 1. VSCode の中から`F5`。 11 | 12 | コードが変わると`> Reload Window`が必要になる。その前はコードの変更は反映されない。 13 | 14 | ### サンプルを用いた動作確認 15 | 16 | 1. データベースを準備する。 17 | 18 | ```sh 19 | cd $THIS_REPOSITORY_ROOT 20 | 21 | # Prepare database. 22 | docker-compose up -d 23 | ./sample/prepare.sh 24 | ``` 25 | 26 | 2. サンプルのワークスペース (`$THIS_REPOSITORY_ROOT/sample/sample.code-workspace`) を [Extension Development Host] ウィンドウで開く。 27 | 28 | ### 自動テスト 29 | 30 | ```sh 31 | cd $THIS_REPOSITORY_ROOT 32 | 33 | # Prepare database. 34 | docker-compose up -d 35 | ./sample/prepare.sh 36 | 37 | # Install packages. 38 | npm install 39 | 40 | # Run test. 41 | npm run test 42 | ``` 43 | 44 | ## リリース手順 45 | 46 | > :warning: [libpg-query](https://github.com/pyramation/libpg-query-node) が [native node module](https://github.com/microsoft/vscode/issues/658) であるために、Linux と Mac は別々にインストールしなければならない。Windows は現状 libpg-query をビルドできていないため、一旦リリース候補から外す。パーサの剪定をしなければならない。 47 | 48 | > :warning: 現状解決策を見つけておらず、Mac 用のパッケージのアップロードは、Mac でしなければいけない。 49 | 50 | 1. `package.json`のバージョン番号を上げる。 51 | 2. `npm install` でモジュールを更新する。 52 | 3. `npm run package:linux`で`vscode-plpgsql-lsp-#.#.#.vsix`を生成する(Mac の場合は `npm run package:mac` )。 53 | 4. [VSCode Marketplace](https://marketplace.visualstudio.com/manage/publishers/uniquevision)にログインする。 54 | 5. `PL/pgSQL Language Server`の`More Actions`の下、`Update`を選択して`.vsix`ファイルを入れる。 55 | -------------------------------------------------------------------------------- /sample/definitions/type/type_single_field.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "TypeName": { 9 | "names": [ 10 | { 11 | "String": { 12 | "str": "type_single_field" 13 | } 14 | } 15 | ], 16 | "typemod": -1 17 | } 18 | } 19 | ], 20 | "removeType": "OBJECT_TYPE", 21 | "behavior": "DROP_CASCADE", 22 | "missing_ok": true 23 | } 24 | }, 25 | "stmt_len": 45 26 | } 27 | }, 28 | { 29 | "RawStmt": { 30 | "stmt": { 31 | "CompositeTypeStmt": { 32 | "typevar": { 33 | "relname": "type_single_field", 34 | "relpersistence": "p" 35 | }, 36 | "coldeflist": [ 37 | { 38 | "ColumnDef": { 39 | "colname": "id", 40 | "typeName": { 41 | "names": [ 42 | { 43 | "String": { 44 | "str": "uuid" 45 | } 46 | } 47 | ], 48 | "typemod": -1 49 | }, 50 | "is_local": true 51 | } 52 | } 53 | ] 54 | } 55 | }, 56 | "stmt_len": 48 57 | } 58 | } 59 | ] -------------------------------------------------------------------------------- /sample/sample.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "name": "root", 5 | "path": "." 6 | } 7 | ], 8 | "settings": { 9 | "files.trimFinalNewlines": true, 10 | "files.trimTrailingWhitespace": true, 11 | "files.insertFinalNewline": true, 12 | "editor.tabSize": 4, 13 | "editor.formatOnSave": true, 14 | "editor.insertSpaces": true, 15 | "editor.comments.insertSpace": true, 16 | "editor.trimAutoWhitespace": true, 17 | "editor.useTabStops": true, 18 | "shellformat.flag": "-ln=posix -bn -ci -sr", 19 | "[markdown]": { 20 | "files.trimTrailingWhitespace": false, 21 | }, 22 | "[postgres]": { 23 | "editor.tabSize": 2, 24 | }, 25 | "plpgsqlLanguageServer.host": "localhost", 26 | "plpgsqlLanguageServer.user": "postgres", 27 | "plpgsqlLanguageServer.password": "password", 28 | "plpgsqlLanguageServer.database": "postgres", 29 | "plpgsqlLanguageServer.definitionFiles": [ 30 | "definitions/**/*.pgsql" 31 | ], 32 | "plpgsqlLanguageServer.workspaceValidationTargetFiles": [ 33 | "definitions/**/*.pgsql" 34 | ], 35 | "plpgsqlLanguageServer.statements": { 36 | "separatorPattern": "-- name:[\\s]+.*" 37 | }, 38 | "plpgsqlLanguageServer.migrations": { 39 | "upFiles": ["migrations/migrations_test/*.up.pgsql"], 40 | "downFiles": ["migrations/migrations_test/*.down.pgsql"], 41 | "target": "up/down" 42 | } 43 | }, 44 | "extensions": { 45 | "recommendations": [] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /plpgsql-lsp.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "name": "root", 5 | "path": "." 6 | }, 7 | { 8 | "name": "sample", 9 | "path": "sample" 10 | }, 11 | { 12 | "name": "client", 13 | "path": "client" 14 | }, 15 | { 16 | "name": "server", 17 | "path": "server" 18 | } 19 | ], 20 | "settings": { 21 | "files.trimFinalNewlines": true, 22 | "files.trimTrailingWhitespace": true, 23 | "files.insertFinalNewline": true, 24 | "editor.tabSize": 4, 25 | "editor.formatOnSave": true, 26 | "editor.insertSpaces": true, 27 | "editor.comments.insertSpace": true, 28 | "editor.trimAutoWhitespace": true, 29 | "editor.useTabStops": true, 30 | "shellformat.flag": "-ln=posix -bn -ci -sr", 31 | "eslint.format.enable": true, 32 | "eslint.validate": [ 33 | "javascript", 34 | "typescript" 35 | ], 36 | "[markdown]": { 37 | "files.trimTrailingWhitespace": false, 38 | }, 39 | "[typescript]": { 40 | "editor.codeActionsOnSave": { 41 | "source.fixAll.eslint": true 42 | }, 43 | "editor.tabSize": 2, 44 | "editor.rulers": [ 45 | 88 46 | ] 47 | }, 48 | "[javascript]": { 49 | "editor.codeActionsOnSave": { 50 | "source.fixAll.eslint": true 51 | }, 52 | "editor.tabSize": 2, 53 | "editor.rulers": [ 54 | 88 55 | ] 56 | } 57 | }, 58 | "extensions": { 59 | "recommendations": [ 60 | "dbaeumer.vscode-eslint" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /server/src/settings.ts: -------------------------------------------------------------------------------- 1 | export interface Settings { 2 | host: string; 3 | port: number; 4 | database?: string; 5 | user?: string; 6 | password?: string; 7 | definitionFiles: string[]; 8 | defaultSchema: string; 9 | queryParameterPattern: string | string[]; 10 | keywordQueryParameterPattern?: string | string[]; 11 | enableExecuteFileQueryCommand: boolean; 12 | workspaceValidationTargetFiles: string[]; 13 | migrations?: MigrationsSettings; 14 | statements?: StatementsSettings; 15 | validateOn: "save" | "change" 16 | } 17 | 18 | export interface StatementsSettings { 19 | diagnosticsLevels?: StatementsDiagnosticLevelSettings; 20 | separatorPattern: string; 21 | } 22 | 23 | export type DiagnosticLevel = "disable" | "warning"; 24 | 25 | export interface StatementsDiagnosticLevelSettings { 26 | disableFlag?: DiagnosticLevel; 27 | } 28 | 29 | export interface MigrationsSettings { 30 | upFiles: string[]; 31 | downFiles: string[]; 32 | postMigrationFiles?: string[]; 33 | target?: "all" | "up/down" 34 | } 35 | 36 | export const DEFAULT_SETTINGS: Settings = { 37 | host: "localhost", 38 | port: 5432, 39 | database: undefined, 40 | user: undefined, 41 | password: undefined, 42 | definitionFiles: ["**/*.psql", "**/*.pgsql"], 43 | defaultSchema: "public", 44 | queryParameterPattern: /\$[1-9][0-9]*/.source, 45 | keywordQueryParameterPattern: undefined, 46 | enableExecuteFileQueryCommand: true, 47 | workspaceValidationTargetFiles: [], 48 | migrations: undefined, 49 | statements: undefined, 50 | validateOn: "change", 51 | } 52 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryTriggerDefinitions.ts: -------------------------------------------------------------------------------- 1 | import dedent from "ts-dedent/dist" 2 | import { Logger } from "vscode-languageserver" 3 | 4 | import { PostgresPool } from "@/postgres" 5 | 6 | export interface TriggerDefinition { 7 | triggerName: string 8 | actionStatement: string 9 | } 10 | 11 | export async function queryTriggerDefinitions( 12 | pgPool: PostgresPool, 13 | schema: string | undefined, 14 | triggerName: string, 15 | defaultSchema: string, 16 | logger: Logger, 17 | ): Promise { 18 | let definitions: TriggerDefinition[] = [] 19 | 20 | const pgClient = await pgPool.connect() 21 | try { 22 | const results = await pgClient.query( 23 | ` 24 | SELECT 25 | trigger_name, 26 | action_statement 27 | FROM 28 | information_schema.triggers 29 | WHERE 30 | trigger_schema = $1 31 | AND trigger_name = $2 32 | `, 33 | [schema ?? defaultSchema, triggerName?.toLowerCase()], 34 | ) 35 | 36 | definitions = results.rows.map( 37 | (row) => ({ 38 | triggerName: row.trigger_name, 39 | actionStatement: row.action_statement, 40 | }), 41 | ) 42 | } 43 | catch (error: unknown) { 44 | logger.error(`${(error as Error).message}`) 45 | } 46 | finally { 47 | pgClient.release() 48 | } 49 | 50 | return definitions 51 | } 52 | 53 | export function makeTriggerDefinitionText(definition: TriggerDefinition): string { 54 | const { triggerName } = definition 55 | 56 | return dedent` 57 | TRIGGER ${triggerName} 58 | ` 59 | } 60 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryTablePartition.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-languageserver" 2 | 3 | import { PostgresPool } from "@/postgres" 4 | 5 | type TablePartitionKeyDefinition = string 6 | 7 | export async function queryTablePartitionKeyDefinition( 8 | pgPool: PostgresPool, 9 | schema: string | undefined, 10 | tableName: string, 11 | defaultSchema: string, 12 | logger: Logger, 13 | ): Promise { 14 | const pgClient = await pgPool.connect() 15 | let partitionKeyDefinition = null 16 | try { 17 | const results = await pgClient.query( 18 | ` 19 | SELECT 20 | pg_get_partkeydef(pg_class.oid) AS partition_key_definition 21 | FROM 22 | pg_class 23 | JOIN pg_namespace ON 24 | pg_class.relnamespace = pg_namespace.oid 25 | AND pg_namespace.nspname = $1 26 | AND pg_class.relname = $2 27 | LIMIT 1 28 | `, 29 | [schema ?? defaultSchema, tableName.toLowerCase()], 30 | ) 31 | 32 | partitionKeyDefinition = results.rows[0].partition_key_definition 33 | } 34 | catch (error: unknown) { 35 | logger.error(`${(error as Error).message}`) 36 | } 37 | finally { 38 | pgClient.release() 39 | } 40 | 41 | return partitionKeyDefinition 42 | } 43 | 44 | 45 | export function makeTablePartitionKeyDefinitionText( 46 | tablePartitionKeyDefinition: TablePartitionKeyDefinition | null, 47 | ): string | undefined { 48 | if (tablePartitionKeyDefinition === null) { 49 | return undefined 50 | } 51 | else { 52 | return `PARTITION BY ${tablePartitionKeyDefinition}` 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryViewDefinitions.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-languageserver" 2 | 3 | import { PostgresPool } from "@/postgres" 4 | import { makeSchemas } from "@/utilities/schema" 5 | 6 | export interface ViewDefinition { 7 | schema: string 8 | viewName: string 9 | } 10 | 11 | export async function queryViewDefinitions( 12 | pgPool: PostgresPool, 13 | schema: string | undefined, 14 | viewName: string | undefined, 15 | defaultSchema: string, 16 | logger: Logger, 17 | ): Promise { 18 | let definitions: ViewDefinition[] = [] 19 | 20 | const pgClient = await pgPool.connect() 21 | try { 22 | const results = await pgClient.query( 23 | ` 24 | SELECT 25 | table_schema as schema, 26 | table_name 27 | FROM 28 | information_schema.views 29 | WHERE 30 | table_schema = ANY($1) 31 | AND ($2::text IS NULL OR table_name = $2::text) 32 | ORDER BY 33 | table_schema, 34 | table_name 35 | `, 36 | [makeSchemas(schema, defaultSchema), viewName?.toLowerCase()], 37 | ) 38 | 39 | definitions = results.rows.map( 40 | (row) => ({ 41 | schema: row.schema, 42 | viewName: row.table_name, 43 | }), 44 | ) 45 | } 46 | catch (error: unknown) { 47 | logger.error(`${(error as Error).message}`) 48 | } 49 | finally { 50 | pgClient.release() 51 | } 52 | 53 | return definitions 54 | } 55 | 56 | 57 | export function makeViewDefinitionText(definition: ViewDefinition): string { 58 | const { schema, viewName } = definition 59 | 60 | return `VIEW ${schema}.${viewName}` 61 | } 62 | -------------------------------------------------------------------------------- /server/src/postgres/parameters/defaultParameters.test.ts: -------------------------------------------------------------------------------- 1 | import { NullLogger } from "vscode-languageserver" 2 | 3 | import { loadSampleTextDocument } from "@/__tests__/helpers/textDocuments" 4 | import { neverReach } from "@/utilities/neverReach" 5 | import { getTextAfterFirstLine } from "@/utilities/text" 6 | 7 | import { 8 | getDefaultQueryParameterInfo, sanitizeFileWithDefaultQueryParameters, 9 | } from "./defaultParameters" 10 | 11 | export type DefaultQueryParametersInfo = { 12 | type: "default", 13 | queryParameters: string[], 14 | queryParameterPattern: string 15 | } 16 | 17 | describe("Default Query Parameter Tests", () => { 18 | describe("Keyword Parameter Tests", function () { 19 | it("Check sanitized query length.", async () => { 20 | const document = await loadSampleTextDocument( 21 | "queries/correct_query_with_default_keyword_parameter.pgsql", 22 | { skipDisableComment: true }, 23 | ) 24 | const queryParametersInfo = getDefaultQueryParameterInfo( 25 | document, 26 | getTextAfterFirstLine(document), 27 | /:[A-Za-z_][A-Za-z0-9_]*/.source, 28 | NullLogger, 29 | ) 30 | 31 | expect(queryParametersInfo).toBeTruthy() 32 | if (queryParametersInfo === null) neverReach() 33 | 34 | expect(queryParametersInfo?.queryParameters).toStrictEqual([":id", ":names"]) 35 | 36 | const originalText = document.getText() 37 | const [sanitizedText] = sanitizeFileWithDefaultQueryParameters( 38 | originalText, queryParametersInfo, NullLogger, 39 | ) 40 | 41 | expect(sanitizedText.length).toEqual(originalText.length) 42 | }) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryIndexDefinitions.ts: -------------------------------------------------------------------------------- 1 | import dedent from "ts-dedent/dist" 2 | import { Logger } from "vscode-languageserver" 3 | 4 | import { PostgresPool } from "@/postgres" 5 | 6 | export interface IndexDefinition { 7 | indexName: string 8 | tableName: string 9 | indexDefinition: string 10 | } 11 | 12 | export async function queryIndexDefinitions( 13 | pgPool: PostgresPool, 14 | schema: string | undefined, 15 | indexName: string, 16 | defaultSchema: string, 17 | logger: Logger, 18 | ): Promise { 19 | let definitions: IndexDefinition[] = [] 20 | 21 | const pgClient = await pgPool.connect() 22 | try { 23 | const results = await pgClient.query( 24 | ` 25 | SELECT 26 | indexname, 27 | tablename, 28 | indexdef 29 | FROM 30 | pg_indexes 31 | WHERE 32 | schemaname = $1 33 | AND indexname = $2 34 | ORDER BY 35 | tablename, 36 | indexname 37 | `, 38 | [schema ?? defaultSchema, indexName?.toLowerCase()], 39 | ) 40 | 41 | definitions = results.rows.map( 42 | (row) => ({ 43 | indexName: row.indexname, 44 | tableName: row.tablename, 45 | indexDefinition: row.indexdef, 46 | }), 47 | ) 48 | } 49 | catch (error: unknown) { 50 | logger.error(`${(error as Error).message}`) 51 | } 52 | finally { 53 | pgClient.release() 54 | } 55 | 56 | return definitions 57 | } 58 | 59 | export function makeIndexDefinitionText(definition: IndexDefinition): string { 60 | const { indexName } = definition 61 | 62 | return dedent` 63 | INDEX ${indexName} 64 | ` 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/CI.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest] 15 | # os: [macos-latest, windows-latest, ubuntu-latest] 16 | 17 | services: 18 | postgres: 19 | image: yassun4dev/plpgsql-check:latest 20 | ports: 21 | - 5432:5432 22 | env: 23 | POSTGRES_DB: postgres 24 | POSTGRES_USER: postgres 25 | POSTGRES_PASSWORD: password 26 | options: >- 27 | --health-cmd pg_isready 28 | --health-interval 10s 29 | --health-timeout 5s 30 | --health-retries 5 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | - run: |- 35 | sudo apt-get update 36 | sudo apt-get install --yes build-essential git python3 libpq-dev postgresql-client 37 | - uses: actions/setup-node@v1 38 | with: 39 | node-version: 14 40 | cache: "npm" 41 | - run: npm install 42 | - run: npm run build 43 | - run: ./sample/prepare.sh 44 | - run: npm run test:ci 45 | 46 | # - name: Build VSIX Package 47 | # run: | 48 | # VERSION=$(node -p "require('./package.json').version") 49 | # npx vsce package -o vscode-plpgsql-lsp-${{ matrix.os }}-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}.vsix 50 | 51 | # - name: Upload Built VSIX 52 | # uses: actions/upload-artifact@v2 53 | # with: 54 | # name: vscode-plpgsql-lsp 55 | # path: vscode-plpgsql-lsp*.vsix 56 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Build", 6 | "type": "npm", 7 | "script": "build", 8 | "group": "build", 9 | "presentation": { 10 | "panel": "dedicated", 11 | "reveal": "never" 12 | }, 13 | "problemMatcher": [ 14 | "$tsc" 15 | ] 16 | }, 17 | { 18 | "label": "Watch", 19 | "type": "npm", 20 | "script": "watch", 21 | "isBackground": true, 22 | "group": { 23 | "kind": "build", 24 | "isDefault": true 25 | }, 26 | "presentation": { 27 | "panel": "dedicated", 28 | "reveal": "silent" 29 | }, 30 | "problemMatcher": { 31 | "owner": "typescript", 32 | "source": "ts", 33 | "applyTo": "closedDocuments", 34 | "fileLocation": "absolute", 35 | "severity": "error", 36 | "pattern": [ 37 | { 38 | "regexp": "\\[tsl\\] (ERROR|WARNING) in (.*)?\\((\\d+),(\\d+)\\)", 39 | "severity": 1, 40 | "file": 2, 41 | "line": 3, 42 | "column": 4 43 | }, 44 | { 45 | "regexp": "\\s*TS(\\d+):\\s*(.*)$", 46 | "code": 1, 47 | "message": 2 48 | } 49 | ], 50 | "background": { 51 | "activeOnStart": true, 52 | "beginsPattern": { 53 | "regexp": "[Cc]ompiling.*?|[Cc]ompil(ation|er) .*?starting" 54 | }, 55 | "endsPattern": { 56 | "regexp": "[Cc]ompiled (.*?successfully|with .*?error)|[Cc]ompil(ation|er) .*?finished" 57 | } 58 | } 59 | } 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryMaterializedViewDefinitions.ts: -------------------------------------------------------------------------------- 1 | import dedent from "ts-dedent/dist" 2 | import { Logger } from "vscode-languageserver" 3 | 4 | import { PostgresPool } from "@/postgres" 5 | import { makeSchemas } from "@/utilities/schema" 6 | 7 | export interface MaterializedViewDefinition { 8 | viewName: string 9 | schemaName: string 10 | } 11 | 12 | export async function queryMaterializedViewDefinitions( 13 | pgPool: PostgresPool, 14 | schema: string | undefined, 15 | viewName: string | undefined, 16 | defaultSchema: string, 17 | logger: Logger, 18 | ): Promise { 19 | let definitions: MaterializedViewDefinition[] = [] 20 | 21 | const pgClient = await pgPool.connect() 22 | try { 23 | const results = await pgClient.query( 24 | ` 25 | SELECT 26 | schemaname AS schema_name, 27 | matviewname AS view_name 28 | FROM 29 | pg_matviews 30 | WHERE 31 | schemaname = ANY($1) 32 | AND ($2::text IS NULL OR matviewname = $2::text) 33 | `, 34 | [makeSchemas(schema, defaultSchema), viewName?.toLowerCase()], 35 | ) 36 | 37 | definitions = results.rows.map( 38 | (row) => ({ 39 | viewName: row.view_name, 40 | schemaName: row.schema_name, 41 | }), 42 | ) 43 | } 44 | catch (error: unknown) { 45 | logger.error(`${(error as Error).message}`) 46 | } 47 | finally { 48 | pgClient.release() 49 | } 50 | 51 | return definitions 52 | } 53 | 54 | export function makeMaterializedViewDefinitionText( 55 | definition: MaterializedViewDefinition, 56 | ): string { 57 | const { schemaName, viewName } = definition 58 | 59 | return dedent` 60 | MATERIALIZED VIEW ${schemaName}.${viewName} 61 | ` 62 | } 63 | -------------------------------------------------------------------------------- /server/src/postgres/parameters/positionalParameters.ts: -------------------------------------------------------------------------------- 1 | import { Logger, uinteger } from "vscode-languageserver-protocol/node" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { getFirstLine } from "@/utilities/text" 5 | 6 | export type PositionalQueryParametersInfo = { 7 | type: "position", 8 | parameterNumber: uinteger 9 | } 10 | 11 | export function getPositionalQueryParameterInfo( 12 | document: TextDocument, 13 | statement: string, 14 | _logger: Logger, 15 | ): PositionalQueryParametersInfo | null { 16 | const firstLine = getFirstLine(document) 17 | 18 | for (const pattern of [ 19 | /^ *-- +plpgsql-language-server:use-positional-query-parameter( +number=[1-9][0-9]*)? *$/, // eslint-disable-line max-len 20 | /^ *\/\* +plpgsql-language-server:use-positional-query-parameter( +number=[1-9][0-9]*)? +\*\/$/, // eslint-disable-line max-len 21 | ]) { 22 | const found = firstLine.match(pattern) 23 | if (found !== null) { 24 | const queriesNumber = found[1] 25 | if (queriesNumber !== undefined) { 26 | return { 27 | type: "position", 28 | parameterNumber: Number(queriesNumber.replace(/^ +number=/, "")), 29 | } 30 | } 31 | else { 32 | // auto calculation. 33 | const queries = new Set([...statement.matchAll(/(\$[1-9][0-9]*)/g)] 34 | .map((found) => found[0])) 35 | 36 | return { 37 | type: "position", 38 | parameterNumber: queries.size, 39 | } 40 | } 41 | } 42 | } 43 | 44 | return null 45 | } 46 | 47 | export function sanitizeFileWithPositionalQueryParameters( 48 | fileText: string, 49 | queryParameterInfo: PositionalQueryParametersInfo, 50 | _logger: Logger, 51 | ): [string, uinteger] { 52 | return [fileText, queryParameterInfo.parameterNumber] 53 | } 54 | -------------------------------------------------------------------------------- /DEVELOP.md: -------------------------------------------------------------------------------- 1 | # Developer Guide 2 | 3 | Below is a series of steps for developing and releasing the extension. 4 | 5 | ## Development 6 | 7 | 1. Install the recommended VSCode extensions (inside `extensions.json`). 8 | 1. Install `npm` itself. 9 | 1. Run `npm`. 10 | 1. In VSCode, hit `F5`. 11 | 12 | When code is changed, you'll need to refresh with `> Reload Window`. Until then, any code changes are not reflected in the development window. 13 | 14 | ### Try sample 15 | 16 | 1. Prepare database. 17 | 18 | ```sh 19 | cd $THIS_REPOSITORY_ROOT 20 | 21 | # Prepare database. 22 | docker-compose up -d 23 | ./sample/prepare.sh 24 | ``` 25 | 26 | 2. Open the sample workspace (`$THIS_REPOSITORY_ROOT/sample/sample.code-workspace`) on [Extension Development Host] window. 27 | 28 | ### Test 29 | 30 | ```sh 31 | cd $THIS_REPOSITORY_ROOT 32 | 33 | # Prepare database. 34 | docker-compose up -d 35 | ./sample/prepare.sh 36 | 37 | # Install packages. 38 | npm install 39 | 40 | # Run test. 41 | npm run test 42 | ``` 43 | 44 | ## Release 45 | 46 | > :warning: Since [libpg-query](https://github.com/pyramation/libpg-query-node) is a [native node module](https://github.com/microsoft/vscode/issues/658), separate Linux and MacOS installations are required. In Windows, cannot build libpg-query, so it is excluded from the release target. 47 | 48 | > :warning: The current strategy is to use a Mac when compiling a build for MacOS. 49 | 50 | 1. Update the version number in `package.json`. 51 | 1. Install with `npm install`. 52 | 1. Execute `npm run package:linux` to produce `vscode-plpgsql-lsp-#.#.#.vsix`. (For MacOS, `npm run package:mac`.) 53 | 1. Log into [VSCode Marketplace](https://marketplace.visualstudio.com/manage/publishers/uniquevision) (Unique Vision users only at this time). 54 | 1. Under `PL/pgSQL Language Server`, select `More Actions`, then `Update` and upload the `.vsix` file. 55 | -------------------------------------------------------------------------------- /sample/definitions/type/type_user.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "TypeName": { 9 | "names": [ 10 | { 11 | "String": { 12 | "str": "type_user" 13 | } 14 | } 15 | ], 16 | "typemod": -1 17 | } 18 | } 19 | ], 20 | "removeType": "OBJECT_TYPE", 21 | "behavior": "DROP_CASCADE", 22 | "missing_ok": true 23 | } 24 | }, 25 | "stmt_len": 37 26 | } 27 | }, 28 | { 29 | "RawStmt": { 30 | "stmt": { 31 | "CompositeTypeStmt": { 32 | "typevar": { 33 | "relname": "type_user", 34 | "relpersistence": "p" 35 | }, 36 | "coldeflist": [ 37 | { 38 | "ColumnDef": { 39 | "colname": "id", 40 | "typeName": { 41 | "names": [ 42 | { 43 | "String": { 44 | "str": "uuid" 45 | } 46 | } 47 | ], 48 | "typemod": -1 49 | }, 50 | "is_local": true 51 | } 52 | }, 53 | { 54 | "ColumnDef": { 55 | "colname": "name", 56 | "typeName": { 57 | "names": [ 58 | { 59 | "String": { 60 | "str": "text" 61 | } 62 | } 63 | ], 64 | "typemod": -1 65 | }, 66 | "is_local": true 67 | } 68 | } 69 | ] 70 | } 71 | }, 72 | "stmt_len": 53 73 | } 74 | } 75 | ] -------------------------------------------------------------------------------- /server/src/utilities/sanitizeWord.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | sanitizeDynamicPartitionTable, 3 | sanitizeNumberPartitionTable, 4 | sanitizeQuotedTable, 5 | sanitizeTableRowType, 6 | sanitizeUuidPartitionTable, 7 | } from "./sanitizeWord" 8 | 9 | 10 | test.each([ 11 | ["public.table_name%ROWTYPE", "public.table_name"], 12 | ['public."table_name"%ROWTYPE', "public.table_name"], 13 | ])( 14 | "sanitizeTableRowType(%s)", (word, expected) => { 15 | expect( 16 | sanitizeTableRowType(word), 17 | ).toBe(expected) 18 | }, 19 | ) 20 | 21 | test.each([ 22 | ['public."table_name"', "public.table_name"], 23 | ['"table_name"', "table_name"], 24 | ])("sanitizeQuotedTable(%s)", (word, expected) => { 25 | expect(sanitizeQuotedTable(word)).toBe(expected) 26 | }) 27 | 28 | test.each([ 29 | ['public."table_name_$$', "public.table_name"], 30 | ['"table_name_$$', "table_name"], 31 | ])("sanitizeDynamicPartitionTable(%s)", (word, expected) => { 32 | expect(sanitizeDynamicPartitionTable(word)) 33 | .toBe(expected) 34 | }) 35 | 36 | test.each([ 37 | ["public.table_name_1234", "public.table_name"], 38 | ["table_name_1234", "table_name"], 39 | ['public."table_name_1234"', "public.table_name"], 40 | ['"table_name_1234"', "table_name"], 41 | ])("sanitizeNumberPartitionTable(%s)", (word, expected) => { 42 | expect(sanitizeNumberPartitionTable(word)) 43 | .toBe(expected) 44 | }) 45 | 46 | test.each([ 47 | [ 48 | "public.table_name_12345678-1234-1234-1234-123456789012", 49 | "public.table_name", 50 | ], 51 | [ 52 | "table_name_12345678-1234-1234-1234-123456789012", 53 | "table_name", 54 | ], 55 | [ 56 | 'public."table_name_12345678-1234-1234-1234-123456789012"', 57 | "public.table_name", 58 | ], 59 | [ 60 | '"table_name_12345678-1234-1234-1234-123456789012"', 61 | "table_name", 62 | ], 63 | ])("sanitizeUuidPartitionTable(%s)", (word, expected) => { 64 | expect(sanitizeUuidPartitionTable( 65 | word, 66 | )).toBe(expected) 67 | }) 68 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryDomainDefinitions.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-languageserver" 2 | 3 | import { PostgresPool } from "@/postgres" 4 | import { makeSchemas } from "@/utilities/schema" 5 | 6 | export interface DomainDefinition { 7 | schema: string 8 | domainName: string 9 | baseTypeName: string 10 | } 11 | 12 | export async function queryDomainDefinitions( 13 | pgPool: PostgresPool, 14 | schema: string | undefined, 15 | domainName: string | undefined, 16 | defaultSchema: string, 17 | logger: Logger, 18 | ): Promise { 19 | let definitions: DomainDefinition[] = [] 20 | 21 | const pgClient = await pgPool.connect() 22 | try { 23 | const results = await pgClient.query( 24 | ` 25 | SELECT 26 | nspname AS schema, 27 | pg_type.typname AS domain_name, 28 | base_type.typname AS base_type_name 29 | FROM 30 | pg_catalog.pg_type AS pg_type 31 | JOIN pg_catalog.pg_namespace ON 32 | pg_namespace.oid = pg_type.typnamespace 33 | INNER JOIN pg_catalog.pg_type base_type ON 34 | pg_type.typtype = 'd' 35 | AND base_type.oid = pg_type.typbasetype 36 | WHERE 37 | nspname::text = ANY($1) 38 | AND $2::text IS NULL OR pg_type.typname = $2::text 39 | `, 40 | [makeSchemas(schema, defaultSchema), domainName?.toLowerCase()], 41 | ) 42 | 43 | definitions = results.rows.map( 44 | (row) => ({ 45 | schema: row.schema, 46 | domainName: row.domain_name, 47 | baseTypeName: row.base_type_name, 48 | }), 49 | ) 50 | } 51 | catch (error: unknown) { 52 | logger.error(`${(error as Error).message}`) 53 | } 54 | finally { 55 | pgClient.release() 56 | } 57 | 58 | return definitions 59 | } 60 | 61 | export function makeDomainDefinitionText(definition: DomainDefinition): string { 62 | const { schema, domainName, baseTypeName } = definition 63 | 64 | return `DOMAIN ${schema}.${domainName} AS ${baseTypeName}` 65 | } 66 | -------------------------------------------------------------------------------- /webpack.config.default.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | /** @typedef {import('webpack').Configuration} WebpackConfig **/ 3 | 4 | "use strict" 5 | 6 | const path = require("path") 7 | const merge = require("merge-options") 8 | const nodeExternals = require("webpack-node-externals") 9 | 10 | module.exports = function withDefaults(/**@type WebpackConfig*/extConfig) { 11 | 12 | /** @type WebpackConfig */ 13 | let defaultConfig = { 14 | mode: "none", // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 15 | target: "node", // extensions run in a node context 16 | node: { 17 | __dirname: false, // leave the __dirname-behaviour intact 18 | }, 19 | resolve: { 20 | mainFields: ["module", "main"], 21 | extensions: [".ts", ".js", ".node"], // support ts-files and js-files 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.ts$/, 27 | exclude: /node_modules/, 28 | use: [ 29 | { 30 | // configure TypeScript loader: 31 | // * enable sources maps for end-to-end source maps 32 | loader: "ts-loader", 33 | options: { 34 | compilerOptions: { 35 | "sourceMap": true, 36 | }, 37 | }, 38 | }, 39 | ], 40 | }, 41 | { 42 | test: /\.node$/, 43 | loader: "node-loader", 44 | }, 45 | ], 46 | }, 47 | externalsPresets: { node: true }, 48 | externals: [ 49 | { 50 | "vscode": "commonjs vscode", // ignored because it doesn't exist 51 | }, 52 | nodeExternals(), 53 | ], 54 | output: { 55 | // all output goes into `dist`. 56 | // packaging depends on that and this must always be like it 57 | filename: "[name].js", 58 | path: path.join(extConfig.context, "out"), 59 | libraryTarget: "commonjs", 60 | }, 61 | // yes, really source maps 62 | devtool: "source-map", 63 | } 64 | 65 | return merge(defaultConfig, extConfig) 66 | } 67 | -------------------------------------------------------------------------------- /server/src/utilities/sanitizeWord.ts: -------------------------------------------------------------------------------- 1 | export function sanitizeWordCandidates(word: string): string[] { 2 | return [ 3 | // General match. 4 | sanitizeQuotedTable(sanitizeDynamicPartitionTable(word)), 5 | // Specific partition table match. 6 | sanitizeUuidPartitionTable(sanitizeNumberPartitionTable(word)), 7 | ].map(candidate => candidate.toLowerCase()) 8 | } 9 | 10 | /** 11 | * sanitize table row type. 12 | * e.g.) 13 | * public.table_name%ROWTYPE 14 | */ 15 | export function sanitizeTableRowType(word: string): string { 16 | return word.replace(/"?([a-zA-Z_]\w*)"?%(ROWTYPE|rowtype)$/, "$1") 17 | } 18 | 19 | /** 20 | * sanitize quoted table. 21 | * e.g.) 22 | * public."table_name" 23 | * "table_name" 24 | */ 25 | export function sanitizeQuotedTable(word: string): string { 26 | return word.replace(/(^[a-zA-Z_]\w*\.)?"([a-zA-Z_]\w*)"$/, "$1$2") 27 | } 28 | 29 | /** 30 | * sanitize dynamic partition table. 31 | * e.g.) 32 | * public."table_name_$$ || partition_key || $$" 33 | * "table_name_$$ || partition_key || $$" 34 | */ 35 | export function sanitizeDynamicPartitionTable(word: string): string { 36 | return word 37 | .replace(/"([a-zA-Z_]\w*)_\$\$$/, "$1") 38 | } 39 | 40 | /** 41 | * sanitize number partition table. 42 | * e.g.) 43 | * public.table_name_1234 44 | * table_name_1234 45 | * public."table_name_1234" 46 | * "table_name_1234" 47 | */ 48 | export function sanitizeNumberPartitionTable(word: string): string { 49 | return word.replace(/"?([a-zA-Z_]\w*)_[0-9]+"?$/, "$1") 50 | } 51 | 52 | /** 53 | * sanitize uuid partition table. 54 | * e.g.) 55 | * public.table_name_12345678-1234-1234-1234-123456789012 56 | * table_name_12345678-1234-1234-1234-123456789012 57 | * public."table_name_12345678-1234-1234-1234-123456789012" 58 | * "table_name_12345678-1234-1234-1234-123456789012" 59 | */ 60 | export function sanitizeUuidPartitionTable(word: string): string { 61 | return word.replace( 62 | /"?([a-zA-Z_]\w*)_[0-9]{8}-[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{12}"?$/, 63 | "$1", 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /client/src/options/clientOptions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OutputChannel, 3 | window, 4 | workspace, 5 | WorkspaceFolder, 6 | } from "vscode" 7 | import { ExecuteCommandSignature } from "vscode-languageclient" 8 | import { 9 | LanguageClientOptions, 10 | } from "vscode-languageclient/node" 11 | 12 | export const PLPGSQL_LANGUAGE_SERVER_SECTION = "plpgsqlLanguageServer" 13 | 14 | export function makeLanguageClientOptions( 15 | workspaceFolder?: WorkspaceFolder, 16 | ): LanguageClientOptions { 17 | let documentSelector 18 | if (workspaceFolder === undefined) { 19 | documentSelector = [{ scheme: "untitled", language: "postgres" }] 20 | } 21 | else { 22 | const pattern = `${workspaceFolder.uri.fsPath}/**/*` 23 | documentSelector = [{ scheme: "file", language: "postgres", pattern }] 24 | } 25 | const outputChannel: OutputChannel = window.createOutputChannel( 26 | PLPGSQL_LANGUAGE_SERVER_SECTION, 27 | ) 28 | 29 | return { 30 | documentSelector, 31 | synchronize: { 32 | fileEvents: workspace 33 | .createFileSystemWatcher("**/.clientrc"), 34 | }, 35 | diagnosticCollectionName: PLPGSQL_LANGUAGE_SERVER_SECTION, 36 | workspaceFolder, 37 | outputChannel, 38 | middleware: { 39 | executeCommand, 40 | }, 41 | } 42 | } 43 | 44 | 45 | async function executeCommand( 46 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 47 | command: string, args: any[], next: ExecuteCommandSignature, 48 | ): Promise { 49 | if ( 50 | window.activeTextEditor !== undefined 51 | && command === "plpgsql-lsp.executeFileQuery" 52 | ) { 53 | args.push(window.activeTextEditor.document.uri.toString()) 54 | next(command, args) 55 | } 56 | else if ( 57 | window.activeTextEditor !== undefined 58 | && command === "plpgsql-lsp.validateWorkspace" 59 | ) { 60 | const documentUri = window.activeTextEditor.document.uri 61 | const workspaceFolder = workspace.getWorkspaceFolder(documentUri) 62 | 63 | args.push(documentUri.toString()) 64 | args.push(workspaceFolder?.uri?.toString()) 65 | args.push(workspaceFolder?.name?.toString()) 66 | 67 | next(command, args) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /server/src/errors.ts: -------------------------------------------------------------------------------- 1 | import { TextDocument } from "vscode-languageserver-textdocument" 2 | 3 | export class PlpgsqlLanguageServerError extends Error { 4 | get name(): string { 5 | return this.constructor.name 6 | } 7 | } 8 | 9 | export class NeverReachError extends PlpgsqlLanguageServerError { 10 | } 11 | 12 | export class ParsedTypeError extends PlpgsqlLanguageServerError { 13 | } 14 | 15 | export class NotCoveredFileError extends PlpgsqlLanguageServerError { 16 | constructor() { 17 | super("This file is not covered by Language Server.") 18 | } 19 | } 20 | 21 | export class WorkspaceNotFound extends PlpgsqlLanguageServerError { 22 | constructor() { 23 | super("Workspace not found.") 24 | } 25 | } 26 | 27 | export class DisableLanguageServerError extends PlpgsqlLanguageServerError { 28 | constructor() { 29 | super("Disable Language Server.") 30 | } 31 | } 32 | 33 | export class PostgresPoolNotFoundError extends PlpgsqlLanguageServerError { 34 | constructor() { 35 | super("PostgresPool not found.") 36 | } 37 | } 38 | 39 | export class CommandNotFoundError extends PlpgsqlLanguageServerError { 40 | constructor(command: string) { 41 | super(`Command '${command}' not found`) 42 | } 43 | } 44 | 45 | export class WrongCommandArgumentsError extends PlpgsqlLanguageServerError { 46 | constructor() { 47 | super("Arguments of the command are wrong.") 48 | } 49 | } 50 | 51 | export class CannotExecuteCommandWithQueryParametersError 52 | extends PlpgsqlLanguageServerError { 53 | constructor() { 54 | super("Cannot execute the command with query parameters.") 55 | } 56 | } 57 | 58 | export class ExecuteFileQueryCommandDisabledError extends PlpgsqlLanguageServerError { 59 | constructor() { 60 | super("\"settings.enableExecuteFileQueryCommand\" is false.") 61 | } 62 | } 63 | 64 | export class WorkspaceValidationTargetFilesEmptyError 65 | extends PlpgsqlLanguageServerError { 66 | constructor() { 67 | super("\"settings.workspaceValidationTargetFiles\" is empty.") 68 | } 69 | } 70 | 71 | export class MigrationError 72 | extends PlpgsqlLanguageServerError { 73 | 74 | constructor(public document: TextDocument, message: string) { 75 | super(message) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryTableTriggers.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-languageserver" 2 | 3 | import { PostgresPool } from "@/postgres/pool" 4 | import { 5 | DefinitionsManager, 6 | makeTargetRelatedTableLink, 7 | } from "@/server/definitionsManager" 8 | 9 | interface TableTrigger { 10 | tableSchemaName: string, 11 | tableName: string, 12 | triggerSchemaName: string, 13 | triggerName: string, 14 | actionStatement: string, 15 | } 16 | 17 | export async function queryTableTriggers( 18 | pgPool: PostgresPool, 19 | schema: string | undefined, 20 | tableName: string, 21 | defaultSchema: string, 22 | logger: Logger, 23 | ): Promise { 24 | let tableTriggers: TableTrigger[] = [] 25 | const tableSchemaName = schema ?? defaultSchema 26 | 27 | const pgClient = await pgPool.connect() 28 | try { 29 | const results = await pgClient.query( 30 | ` 31 | SELECT 32 | trigger_schema, 33 | trigger_name, 34 | action_statement 35 | FROM 36 | information_schema.triggers 37 | WHERE 38 | event_object_schema = $1 39 | AND event_object_table = $2 40 | ORDER BY 41 | trigger_name 42 | `, 43 | [tableSchemaName, tableName.toLowerCase()], 44 | ) 45 | 46 | tableTriggers = results.rows.map( 47 | (row) => ({ 48 | tableSchemaName, 49 | tableName, 50 | triggerSchemaName: row.trigger_schema, 51 | triggerName: row.trigger_name, 52 | actionStatement: row.action_statement, 53 | }), 54 | ) 55 | } 56 | catch (error: unknown) { 57 | logger.error(`${(error as Error).message}`) 58 | } 59 | finally { 60 | pgClient.release() 61 | } 62 | 63 | return tableTriggers 64 | } 65 | 66 | 67 | export function makeTableTriggerText( 68 | tableIndex: TableTrigger, definitionsManager: DefinitionsManager, 69 | ): string { 70 | const { 71 | triggerName, 72 | tableSchemaName, 73 | tableName, 74 | actionStatement, 75 | } = tableIndex 76 | 77 | const targetLink = makeTargetRelatedTableLink( 78 | triggerName, tableSchemaName, tableName, definitionsManager, 79 | ) 80 | 81 | return `${targetLink} ${actionStatement}` 82 | } 83 | -------------------------------------------------------------------------------- /sample/definitions/materialized_view/my_users.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "List": { 9 | "items": [ 10 | { 11 | "String": { 12 | "str": "public" 13 | } 14 | }, 15 | { 16 | "String": { 17 | "str": "my_users" 18 | } 19 | } 20 | ] 21 | } 22 | } 23 | ], 24 | "removeType": "OBJECT_MATVIEW", 25 | "behavior": "DROP_CASCADE", 26 | "missing_ok": true 27 | } 28 | }, 29 | "stmt_len": 56 30 | } 31 | }, 32 | { 33 | "RawStmt": { 34 | "stmt": { 35 | "CreateTableAsStmt": { 36 | "query": { 37 | "SelectStmt": { 38 | "targetList": [ 39 | { 40 | "ResTarget": { 41 | "val": { 42 | "ColumnRef": { 43 | "fields": [ 44 | { 45 | "A_Star": {} 46 | } 47 | ] 48 | } 49 | } 50 | } 51 | } 52 | ], 53 | "fromClause": [ 54 | { 55 | "RangeVar": { 56 | "relname": "users", 57 | "inh": true, 58 | "relpersistence": "p" 59 | } 60 | } 61 | ], 62 | "limitOption": "LIMIT_OPTION_DEFAULT", 63 | "op": "SETOP_NONE" 64 | } 65 | }, 66 | "into": { 67 | "rel": { 68 | "schemaname": "public", 69 | "relname": "my_users", 70 | "inh": true, 71 | "relpersistence": "p" 72 | }, 73 | "onCommit": "ONCOMMIT_NOOP" 74 | }, 75 | "relkind": "OBJECT_MATVIEW" 76 | } 77 | }, 78 | "stmt_len": 77 79 | } 80 | } 81 | ] -------------------------------------------------------------------------------- /sample/definitions/function/correct_uppercase_function.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "CreateFunctionStmt": { 6 | "replace": true, 7 | "funcname": [ 8 | { 9 | "String": { 10 | "str": "constant_value" 11 | } 12 | } 13 | ], 14 | "returnType": { 15 | "names": [ 16 | { 17 | "String": { 18 | "str": "text" 19 | } 20 | } 21 | ], 22 | "typemod": -1 23 | }, 24 | "options": [ 25 | { 26 | "DefElem": { 27 | "defname": "as", 28 | "arg": { 29 | "List": { 30 | "items": [ 31 | { 32 | "String": { 33 | "str": "SELECT TEXT '00001'" 34 | } 35 | } 36 | ] 37 | } 38 | }, 39 | "defaction": "DEFELEM_UNSPEC" 40 | } 41 | }, 42 | { 43 | "DefElem": { 44 | "defname": "language", 45 | "arg": { 46 | "String": { 47 | "str": "sql" 48 | } 49 | }, 50 | "defaction": "DEFELEM_UNSPEC" 51 | } 52 | }, 53 | { 54 | "DefElem": { 55 | "defname": "volatility", 56 | "arg": { 57 | "String": { 58 | "str": "immutable" 59 | } 60 | }, 61 | "defaction": "DEFELEM_UNSPEC" 62 | } 63 | }, 64 | { 65 | "DefElem": { 66 | "defname": "parallel", 67 | "arg": { 68 | "String": { 69 | "str": "safe" 70 | } 71 | }, 72 | "defaction": "DEFELEM_UNSPEC" 73 | } 74 | } 75 | ] 76 | } 77 | }, 78 | "stmt_len": 122 79 | } 80 | } 81 | ] -------------------------------------------------------------------------------- /server/src/postgres/parameters/defaultParameters.ts: -------------------------------------------------------------------------------- 1 | import { Logger, uinteger } from "vscode-languageserver-protocol/node" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { escapeRegex } from "@/utilities/regex" 5 | import { getFirstLine } from "@/utilities/text" 6 | 7 | import { makePositionalParamter } from "./helpers" 8 | 9 | export type DefaultQueryParametersInfo = { 10 | type: "default", 11 | queryParameters: string[], 12 | queryParameterPattern: string[] 13 | } 14 | 15 | 16 | export function getDefaultQueryParameterInfo( 17 | document: TextDocument, 18 | statement: string, 19 | queryParameterPattern: string | string[], 20 | _logger: Logger, 21 | ): DefaultQueryParametersInfo | null { 22 | const firstLine = getFirstLine(document) 23 | 24 | for (const pattern of [ 25 | /^ *-- +plpgsql-language-server:use-query-parameter *$/, 26 | /^ *\/\* +plpgsql-language-server:use-query-parameter +\*\/$/, 27 | ]) { 28 | const found = firstLine.match(pattern) 29 | if (found !== null) { 30 | let queryParameterPatterns: string[] 31 | if (typeof queryParameterPattern === "string") { 32 | queryParameterPatterns = [queryParameterPattern] 33 | } 34 | else { 35 | queryParameterPatterns = queryParameterPattern 36 | } 37 | 38 | const queryParameters: string[] = [] 39 | queryParameterPatterns.forEach(pattern => { 40 | const queryRegExp = new RegExp(pattern, "g") 41 | 42 | queryParameters.push(...Array.from( 43 | new Set( 44 | [...statement.matchAll(queryRegExp)] 45 | .map((found) => found[0]), 46 | ), 47 | )) 48 | }) 49 | 50 | return { 51 | type: "default", 52 | queryParameters, 53 | queryParameterPattern: queryParameterPatterns, 54 | } 55 | } 56 | } 57 | 58 | return null 59 | } 60 | 61 | export function sanitizeFileWithDefaultQueryParameters( 62 | fileText: string, 63 | queryParameterInfo: DefaultQueryParametersInfo, 64 | _logger: Logger, 65 | ): [string, uinteger] { 66 | const queryParameters = new Set(queryParameterInfo.queryParameters) 67 | for (const [index, parameter] of Array.from(queryParameters.values()).entries()) { 68 | fileText = fileText.replace( 69 | new RegExp(escapeRegex(parameter), "g"), 70 | makePositionalParamter(index, parameter), 71 | ) 72 | } 73 | 74 | return [fileText, queryParameters.size] 75 | } 76 | -------------------------------------------------------------------------------- /server/src/postgres/parsers/parseFunctions.ts: -------------------------------------------------------------------------------- 1 | import { Logger, URI } from "vscode-languageserver" 2 | 3 | import { ParsedTypeError } from "@/errors" 4 | import { 5 | QueryParameterInfo, sanitizeFileWithQueryParameters, 6 | } from "@/postgres/parameters" 7 | import { parseStmtements, Statement } from "@/postgres/parsers/statement" 8 | import { readFileFromUri } from "@/utilities/text" 9 | 10 | export interface FunctionInfo { 11 | functionName: string, 12 | location: number | undefined, 13 | } 14 | 15 | export async function parseFunctions( 16 | uri: URI, 17 | queryParameterInfo: QueryParameterInfo | null, 18 | logger: Logger, 19 | ): Promise { 20 | const fileText = await readFileFromUri(uri) 21 | if (fileText === null) { 22 | return [] 23 | } 24 | 25 | const [sanitizedFileText] = sanitizeFileWithQueryParameters( 26 | fileText, queryParameterInfo, logger, 27 | ) 28 | 29 | const stmtements = await parseStmtements(uri, sanitizedFileText, logger) 30 | if (stmtements === undefined) { 31 | return [] 32 | } 33 | 34 | return stmtements.flatMap( 35 | (statement) => { 36 | if (statement?.stmt?.CreateFunctionStmt !== undefined) { 37 | try { 38 | return getCreateFunctions(statement) 39 | } 40 | catch (error: unknown) { 41 | logger.error(`ParseFunctionError: ${(error as Error).message} (${uri})`) 42 | } 43 | } 44 | 45 | return [] 46 | }, 47 | ) 48 | } 49 | 50 | function getCreateFunctions( 51 | statement: Statement, 52 | ): FunctionInfo[] { 53 | const createFunctionStmt = statement?.stmt?.CreateFunctionStmt 54 | if (createFunctionStmt === undefined) { 55 | return [] 56 | } 57 | const funcname = createFunctionStmt.funcname 58 | const options = createFunctionStmt.options 59 | if (funcname === undefined) { 60 | throw new ParsedTypeError("createFunctionStmt.funcname is undefined!") 61 | } 62 | if (options === undefined) { 63 | throw new ParsedTypeError("createFunctionStmt.options is undefined!") 64 | } 65 | 66 | return funcname.flatMap( 67 | (funcname) => { 68 | const functionName = funcname.String.str 69 | if (functionName === undefined) { 70 | return [] 71 | } 72 | 73 | const locationCandidates = options 74 | .filter((option) => option.DefElem.defname === "as") 75 | .map((option) => option.DefElem.location) 76 | 77 | return [ 78 | { 79 | functionName, 80 | location: locationCandidates?.[0] ?? undefined, 81 | }, 82 | ] 83 | }, 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /sample/definitions/procedure/correct_procedure.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "ObjectWithArgs": { 9 | "objname": [ 10 | { 11 | "String": { 12 | "str": "correct_procedure" 13 | } 14 | } 15 | ], 16 | "args_unspecified": true 17 | } 18 | } 19 | ], 20 | "removeType": "OBJECT_PROCEDURE", 21 | "behavior": "DROP_RESTRICT", 22 | "missing_ok": true 23 | } 24 | }, 25 | "stmt_len": 42 26 | } 27 | }, 28 | { 29 | "RawStmt": { 30 | "stmt": { 31 | "CreateFunctionStmt": { 32 | "is_procedure": true, 33 | "funcname": [ 34 | { 35 | "String": { 36 | "str": "correct_procedure" 37 | } 38 | } 39 | ], 40 | "parameters": [ 41 | { 42 | "FunctionParameter": { 43 | "name": "p1", 44 | "argType": { 45 | "names": [ 46 | { 47 | "String": { 48 | "str": "text" 49 | } 50 | } 51 | ], 52 | "typemod": -1 53 | }, 54 | "mode": "FUNC_PARAM_INOUT" 55 | } 56 | } 57 | ], 58 | "options": [ 59 | { 60 | "DefElem": { 61 | "defname": "as", 62 | "arg": { 63 | "List": { 64 | "items": [ 65 | { 66 | "String": { 67 | "str": "\nBEGIN\n p1 := '!! ' || p1 || ' !!';\n RAISE NOTICE 'Procedure Parameter: %', p1;\nEND;\n" 68 | } 69 | } 70 | ] 71 | } 72 | }, 73 | "defaction": "DEFELEM_UNSPEC" 74 | } 75 | }, 76 | { 77 | "DefElem": { 78 | "defname": "language", 79 | "arg": { 80 | "String": { 81 | "str": "plpgsql" 82 | } 83 | }, 84 | "defaction": "DEFELEM_UNSPEC" 85 | } 86 | } 87 | ] 88 | } 89 | }, 90 | "stmt_len": 167 91 | } 92 | } 93 | ] -------------------------------------------------------------------------------- /server/src/postgres/parameters/keywordParameters.test.ts: -------------------------------------------------------------------------------- 1 | import { NullLogger } from "vscode-languageserver" 2 | 3 | import { loadSampleTextDocument } from "@/__tests__/helpers/textDocuments" 4 | import { neverReach } from "@/utilities/neverReach" 5 | import { getTextAfterFirstLine } from "@/utilities/text" 6 | 7 | import { 8 | getKeywordQueryParameterInfo, sanitizeFileWithKeywordQueryParameters, 9 | } from "./keywordParameters" 10 | 11 | export type DefaultQueryParametersInfo = { 12 | type: "default", 13 | queryParameters: string[], 14 | queryParameterPattern: string 15 | } 16 | 17 | describe("Keyword Query Parameter Tests", () => { 18 | describe("Keyword Parameter Tests", function () { 19 | it("Check sanitized query length.", async () => { 20 | const document = await loadSampleTextDocument( 21 | "queries/correct_query_with_keyword_parameter.pgsql", 22 | ) 23 | const queryParametersInfo = getKeywordQueryParameterInfo( 24 | document, getTextAfterFirstLine(document), "@{keyword}", NullLogger, 25 | ) 26 | 27 | expect(queryParametersInfo).toBeTruthy() 28 | if (queryParametersInfo === null) neverReach() 29 | 30 | expect(queryParametersInfo?.keywordParameters).toStrictEqual(["@id", "@names"]) 31 | 32 | const originalText = document.getText() 33 | const [sanitizedText] = sanitizeFileWithKeywordQueryParameters( 34 | originalText, queryParametersInfo, NullLogger, 35 | ) 36 | 37 | expect(sanitizedText.length).toEqual(originalText.length) 38 | }) 39 | }) 40 | 41 | 42 | describe("Multiple Keyword Parameter Tests", function () { 43 | it("Check sanitized query length.", async () => { 44 | const document = await loadSampleTextDocument( 45 | "queries/correct_query_with_multiple_keyword_parameter.pgsql", 46 | ) 47 | const queryParametersInfo = getKeywordQueryParameterInfo( 48 | document, 49 | getTextAfterFirstLine(document), 50 | [ 51 | "@{keyword}", 52 | "sqlc\\.arg\\('{keyword}'\\)", 53 | "sqlc\\.narg\\('{keyword}'\\)", 54 | ], 55 | NullLogger, 56 | ) 57 | 58 | expect(queryParametersInfo).toBeTruthy() 59 | if (queryParametersInfo === null) neverReach() 60 | 61 | expect(queryParametersInfo?.keywordParameters).toStrictEqual( 62 | ["@names", "sqlc.arg('id')"], 63 | ) 64 | 65 | const originalText = document.getText() 66 | const [sanitizedText] = sanitizeFileWithKeywordQueryParameters( 67 | originalText, queryParametersInfo, NullLogger, 68 | ) 69 | 70 | expect(sanitizedText.length).toEqual(originalText.length) 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /server/src/commands/validateWorkspace.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-jsonrpc/node" 2 | import { Connection, Diagnostic, WorkspaceFolder } from "vscode-languageserver" 3 | import { TextDocument } from "vscode-languageserver-textdocument" 4 | 5 | import { PostgresPool } from "@/postgres" 6 | import { getQueryParameterInfo } from "@/postgres/parameters" 7 | import { validateTextDocument } from "@/services/validation" 8 | import { Settings } from "@/settings" 9 | import { disableValidation } from "@/utilities/disableLanguageServer" 10 | import { 11 | loadWorkspaceValidationTargetFiles, readTextDocumentFromUri, 12 | } from "@/utilities/text" 13 | 14 | 15 | export const WORKSPACE_VALIDATION_COMMAND = { 16 | title: "PL/pgSQL: Validate the Workspace Files", 17 | name: "plpgsql-lsp.validateWorkspace", 18 | execute: validateWorkspace, 19 | } as const 20 | 21 | export type ValidationOptions = { 22 | isComplete: boolean, 23 | hasDiagnosticRelatedInformationCapability: boolean 24 | } 25 | 26 | export async function validateWorkspace( 27 | connection: Connection, 28 | pgPool: PostgresPool, 29 | workspaceFolder: WorkspaceFolder, 30 | settings: Settings, 31 | options: ValidationOptions, 32 | logger: Logger, 33 | ): Promise { 34 | for (const file of await loadWorkspaceValidationTargetFiles( 35 | workspaceFolder, settings, 36 | )) { 37 | const document = await readTextDocumentFromUri(`${workspaceFolder.uri}/${file}`) 38 | await validateFile(connection, pgPool, document, settings, options, logger) 39 | } 40 | } 41 | 42 | export async function validateFile( 43 | connection: Connection, 44 | pgPool: PostgresPool, 45 | document: TextDocument, 46 | settings: Settings, 47 | options: ValidationOptions, 48 | logger: Logger, 49 | ): Promise { 50 | let diagnostics: Diagnostic[] | undefined = undefined 51 | 52 | if (!disableValidation(document)) { 53 | const queryParameterInfo = getQueryParameterInfo( 54 | document, document.getText(), settings, logger, 55 | ) 56 | 57 | if (queryParameterInfo === null || "type" in queryParameterInfo) { 58 | diagnostics = await validateTextDocument( 59 | pgPool, 60 | document, 61 | { 62 | isComplete: true, 63 | hasDiagnosticRelatedInformationCapability: 64 | options.hasDiagnosticRelatedInformationCapability, 65 | queryParameterInfo, 66 | statements: settings.statements, 67 | }, 68 | settings, 69 | logger, 70 | ) 71 | } 72 | else { 73 | diagnostics = [queryParameterInfo] 74 | } 75 | } 76 | 77 | connection.sendDiagnostics({ 78 | uri: document.uri, 79 | diagnostics: diagnostics ?? [], 80 | }) 81 | 82 | return diagnostics 83 | } 84 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "env": { 4 | "es2020": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier" 12 | ], 13 | "plugins": [ 14 | "simple-import-sort", 15 | "@typescript-eslint" 16 | ], 17 | "parserOptions": { 18 | "ecmaVersion": 12, 19 | "sourceType": "module" 20 | }, 21 | "rules": { 22 | "simple-import-sort/imports": "error", 23 | "simple-import-sort/exports": "error", 24 | "newline-before-return": "error", 25 | "camelcase": [ 26 | "error", 27 | { 28 | "properties": "never" 29 | } 30 | ], 31 | "semi": [ 32 | "error", 33 | "never" 34 | ], 35 | "quotes": [ 36 | "error", 37 | "double", 38 | { 39 | "avoidEscape": true 40 | } 41 | ], 42 | "eqeqeq": "error", 43 | "arrow-spacing": "error", 44 | "comma-spacing": "error", 45 | "no-unused-vars": "off", 46 | "space-before-blocks": "error", 47 | "object-curly-spacing": [ 48 | "error", 49 | "always" 50 | ], 51 | "no-multi-spaces": [ 52 | "error", 53 | { 54 | "ignoreEOLComments": false 55 | } 56 | ], 57 | "comma-dangle": [ 58 | "error", 59 | "always-multiline" 60 | ], 61 | "function-call-argument-newline": [ 62 | "error", 63 | "consistent" 64 | ], 65 | "@typescript-eslint/no-unused-vars": [ 66 | "error", 67 | { 68 | "argsIgnorePattern": "^_" 69 | } 70 | ], 71 | "no-constant-condition": [ 72 | "error", 73 | { 74 | "checkLoops": false 75 | } 76 | ], 77 | "indent": [ 78 | "error", 79 | 2, 80 | { 81 | "SwitchCase": 1 82 | } 83 | ], 84 | "max-len": [ 85 | "error", 86 | { 87 | "code": 88, 88 | "tabWidth": 4, 89 | "ignoreComments": true 90 | } 91 | ], 92 | "array-bracket-newline": [ 93 | "error", 94 | { 95 | "multiline": true 96 | } 97 | ], 98 | "array-bracket-spacing": [ 99 | "error", 100 | "never" 101 | ], 102 | "object-curly-newline": [ 103 | "error", 104 | { 105 | "consistent": true 106 | } 107 | ], 108 | "function-paren-newline": [ 109 | "error", 110 | "consistent" 111 | ], 112 | "@typescript-eslint/explicit-module-boundary-types": "off" 113 | }, 114 | "overrides": [ 115 | { 116 | "files": [ 117 | "*.js" 118 | ], 119 | "rules": { 120 | "@typescript-eslint/no-var-requires": "off" 121 | } 122 | } 123 | ] 124 | } 125 | -------------------------------------------------------------------------------- /sample/definitions/function/correct_function.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "ObjectWithArgs": { 9 | "objname": [ 10 | { 11 | "String": { 12 | "str": "correct_function" 13 | } 14 | } 15 | ], 16 | "args_unspecified": true 17 | } 18 | } 19 | ], 20 | "removeType": "OBJECT_FUNCTION", 21 | "behavior": "DROP_RESTRICT", 22 | "missing_ok": true 23 | } 24 | }, 25 | "stmt_len": 40 26 | } 27 | }, 28 | { 29 | "RawStmt": { 30 | "stmt": { 31 | "CreateFunctionStmt": { 32 | "funcname": [ 33 | { 34 | "String": { 35 | "str": "correct_function" 36 | } 37 | } 38 | ], 39 | "parameters": [ 40 | { 41 | "FunctionParameter": { 42 | "name": "p_id", 43 | "argType": { 44 | "names": [ 45 | { 46 | "String": { 47 | "str": "uuid" 48 | } 49 | } 50 | ], 51 | "typemod": -1 52 | }, 53 | "mode": "FUNC_PARAM_IN" 54 | } 55 | } 56 | ], 57 | "returnType": { 58 | "names": [ 59 | { 60 | "String": { 61 | "str": "uuid" 62 | } 63 | } 64 | ], 65 | "setof": true, 66 | "typemod": -1 67 | }, 68 | "options": [ 69 | { 70 | "DefElem": { 71 | "defname": "as", 72 | "arg": { 73 | "List": { 74 | "items": [ 75 | { 76 | "String": { 77 | "str": "\nDECLARE\nBEGIN\n RETURN QUERY\n SELECT\n p_id;\nEND;\n" 78 | } 79 | } 80 | ] 81 | } 82 | }, 83 | "defaction": "DEFELEM_UNSPEC" 84 | } 85 | }, 86 | { 87 | "DefElem": { 88 | "defname": "language", 89 | "arg": { 90 | "String": { 91 | "str": "plpgsql" 92 | } 93 | }, 94 | "defaction": "DEFELEM_UNSPEC" 95 | } 96 | } 97 | ] 98 | } 99 | }, 100 | "stmt_len": 163 101 | } 102 | } 103 | ] -------------------------------------------------------------------------------- /server/src/postgres/queries/queryTableConstraints.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-languageserver" 2 | 3 | import { PostgresPool } from "@/postgres/pool" 4 | import { 5 | DefinitionsManager, 6 | makeTargetRelatedTableLink, 7 | } from "@/server/definitionsManager" 8 | 9 | interface TableConstraint { 10 | type: "foreign_key" | "check", 11 | schemaName: string, 12 | tableName: string, 13 | constraintName: string, 14 | definition: string, 15 | } 16 | 17 | export async function queryTableConstraints( 18 | pgPool: PostgresPool, 19 | schema: string | undefined, 20 | tableName: string, 21 | defaultSchema: string, 22 | logger: Logger, 23 | ): Promise { 24 | let tableConstraints: TableConstraint[] = [] 25 | const schemaName = schema ?? defaultSchema 26 | 27 | const pgClient = await pgPool.connect() 28 | try { 29 | const results = await pgClient.query( 30 | ` 31 | WITH t_table_constraints AS ( 32 | SELECT 33 | DISTINCT 34 | CASE pg_constraint.contype 35 | WHEN 'f' THEN 36 | 'foreign_key' 37 | WHEN 'c' THEN 38 | 'check' 39 | END AS type, 40 | pg_constraint.conname AS name, 41 | pg_get_constraintdef(pg_constraint.oid, true) AS definition 42 | FROM 43 | pg_constraint 44 | INNER JOIN pg_namespace ON 45 | pg_namespace.oid = pg_constraint.connamespace 46 | AND pg_constraint.contype IN ('f', 'c') 47 | AND pg_namespace.nspname = $1 48 | JOIN pg_class ON 49 | pg_constraint.conrelid = pg_class.oid 50 | AND pg_class.relname = $2 51 | LEFT JOIN information_schema.constraint_column_usage ON 52 | pg_constraint.conname = constraint_column_usage.constraint_name 53 | AND pg_namespace.nspname = constraint_column_usage.constraint_schema 54 | ) 55 | SELECT 56 | * 57 | FROM 58 | t_table_constraints 59 | ORDER BY 60 | type, 61 | name 62 | `, 63 | [schemaName, tableName.toLowerCase()], 64 | ) 65 | 66 | tableConstraints = results.rows.map( 67 | (row) => ({ 68 | type: row.type, 69 | schemaName, 70 | tableName, 71 | constraintName: row.name, 72 | definition: row.definition, 73 | }), 74 | ) 75 | } 76 | catch (error: unknown) { 77 | logger.error(`${(error as Error).message}`) 78 | } 79 | finally { 80 | pgClient.release() 81 | } 82 | 83 | return tableConstraints 84 | } 85 | 86 | 87 | export function makeTableConastaintText( 88 | tableConstraint: TableConstraint, definitionsManager: DefinitionsManager, 89 | ): string { 90 | const { schemaName, tableName, constraintName, definition } = tableConstraint 91 | 92 | const targetLink = makeTargetRelatedTableLink( 93 | constraintName, schemaName, tableName, definitionsManager, 94 | ) 95 | 96 | return `${targetLink} ${definition}` 97 | } 98 | -------------------------------------------------------------------------------- /sample/definitions/domain/jp_postal_code.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "TypeName": { 9 | "names": [ 10 | { 11 | "String": { 12 | "str": "public" 13 | } 14 | }, 15 | { 16 | "String": { 17 | "str": "jp_postal_code" 18 | } 19 | } 20 | ], 21 | "typemod": -1 22 | } 23 | } 24 | ], 25 | "removeType": "OBJECT_DOMAIN", 26 | "behavior": "DROP_RESTRICT", 27 | "missing_ok": true 28 | } 29 | }, 30 | "stmt_len": 43 31 | } 32 | }, 33 | { 34 | "RawStmt": { 35 | "stmt": { 36 | "CreateDomainStmt": { 37 | "domainname": [ 38 | { 39 | "String": { 40 | "str": "public" 41 | } 42 | }, 43 | { 44 | "String": { 45 | "str": "jp_postal_code" 46 | } 47 | } 48 | ], 49 | "typeName": { 50 | "names": [ 51 | { 52 | "String": { 53 | "str": "text" 54 | } 55 | } 56 | ], 57 | "typemod": -1 58 | }, 59 | "constraints": [ 60 | { 61 | "Constraint": { 62 | "contype": "CONSTR_CHECK", 63 | "raw_expr": { 64 | "A_Expr": { 65 | "kind": "AEXPR_OP", 66 | "name": [ 67 | { 68 | "String": { 69 | "str": "~" 70 | } 71 | } 72 | ], 73 | "lexpr": { 74 | "ColumnRef": { 75 | "fields": [ 76 | { 77 | "String": { 78 | "str": "value" 79 | } 80 | } 81 | ] 82 | } 83 | }, 84 | "rexpr": { 85 | "A_Const": { 86 | "val": { 87 | "String": { 88 | "str": "^\\d{3}-\\d{4}$" 89 | } 90 | } 91 | } 92 | } 93 | } 94 | }, 95 | "initially_valid": true 96 | } 97 | } 98 | ] 99 | } 100 | }, 101 | "stmt_len": 81 102 | } 103 | } 104 | ] -------------------------------------------------------------------------------- /sample/definitions/function/constant_function.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "ObjectWithArgs": { 9 | "objname": [ 10 | { 11 | "String": { 12 | "str": "constant_function" 13 | } 14 | } 15 | ], 16 | "args_unspecified": true 17 | } 18 | } 19 | ], 20 | "removeType": "OBJECT_FUNCTION", 21 | "behavior": "DROP_RESTRICT", 22 | "missing_ok": true 23 | } 24 | }, 25 | "stmt_len": 41 26 | } 27 | }, 28 | { 29 | "RawStmt": { 30 | "stmt": { 31 | "CreateFunctionStmt": { 32 | "funcname": [ 33 | { 34 | "String": { 35 | "str": "constant_function" 36 | } 37 | } 38 | ], 39 | "returnType": { 40 | "names": [ 41 | { 42 | "String": { 43 | "str": "text" 44 | } 45 | } 46 | ], 47 | "typemod": -1 48 | }, 49 | "options": [ 50 | { 51 | "DefElem": { 52 | "defname": "as", 53 | "arg": { 54 | "List": { 55 | "items": [ 56 | { 57 | "String": { 58 | "str": "\nDECLARE\nBEGIN\n RETURN 'CONSTANT';\nEND;\n" 59 | } 60 | } 61 | ] 62 | } 63 | }, 64 | "defaction": "DEFELEM_UNSPEC" 65 | } 66 | }, 67 | { 68 | "DefElem": { 69 | "defname": "language", 70 | "arg": { 71 | "String": { 72 | "str": "plpgsql" 73 | } 74 | }, 75 | "defaction": "DEFELEM_UNSPEC" 76 | } 77 | }, 78 | { 79 | "DefElem": { 80 | "defname": "volatility", 81 | "arg": { 82 | "String": { 83 | "str": "immutable" 84 | } 85 | }, 86 | "defaction": "DEFELEM_UNSPEC" 87 | } 88 | }, 89 | { 90 | "DefElem": { 91 | "defname": "parallel", 92 | "arg": { 93 | "String": { 94 | "str": "safe" 95 | } 96 | }, 97 | "defaction": "DEFELEM_UNSPEC" 98 | } 99 | } 100 | ] 101 | } 102 | }, 103 | "stmt_len": 156 104 | } 105 | } 106 | ] -------------------------------------------------------------------------------- /sample/definitions/function/static_analysis_warning_function_unused_variable.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "ObjectWithArgs": { 9 | "objname": [ 10 | { 11 | "String": { 12 | "str": "warning_function_unused_variable" 13 | } 14 | } 15 | ], 16 | "args_unspecified": true 17 | } 18 | } 19 | ], 20 | "removeType": "OBJECT_FUNCTION", 21 | "behavior": "DROP_RESTRICT", 22 | "missing_ok": true 23 | } 24 | }, 25 | "stmt_len": 56 26 | } 27 | }, 28 | { 29 | "RawStmt": { 30 | "stmt": { 31 | "CreateFunctionStmt": { 32 | "replace": true, 33 | "funcname": [ 34 | { 35 | "String": { 36 | "str": "warning_function_unused_variable" 37 | } 38 | } 39 | ], 40 | "parameters": [ 41 | { 42 | "FunctionParameter": { 43 | "name": "p_id", 44 | "argType": { 45 | "names": [ 46 | { 47 | "String": { 48 | "str": "uuid" 49 | } 50 | } 51 | ], 52 | "typemod": -1 53 | }, 54 | "mode": "FUNC_PARAM_IN" 55 | } 56 | } 57 | ], 58 | "returnType": { 59 | "names": [ 60 | { 61 | "String": { 62 | "str": "uuid" 63 | } 64 | } 65 | ], 66 | "setof": true, 67 | "typemod": -1 68 | }, 69 | "options": [ 70 | { 71 | "DefElem": { 72 | "defname": "as", 73 | "arg": { 74 | "List": { 75 | "items": [ 76 | { 77 | "String": { 78 | "str": "\nDECLARE\n w_id uuid;\nBEGIN\n RETURN QUERY\n SELECT\n p_id;\nEND;\n" 79 | } 80 | } 81 | ] 82 | } 83 | }, 84 | "defaction": "DEFELEM_UNSPEC" 85 | } 86 | }, 87 | { 88 | "DefElem": { 89 | "defname": "language", 90 | "arg": { 91 | "String": { 92 | "str": "plpgsql" 93 | } 94 | }, 95 | "defaction": "DEFELEM_UNSPEC" 96 | } 97 | } 98 | ] 99 | } 100 | }, 101 | "stmt_len": 203 102 | } 103 | } 104 | ] -------------------------------------------------------------------------------- /server/src/server/settingsManager.ts: -------------------------------------------------------------------------------- 1 | import minimatch from "minimatch" 2 | import path from "path" 3 | import { Connection, URI, WorkspaceFolder } from "vscode-languageserver" 4 | 5 | import { DEFAULT_SETTINGS, Settings } from "@/settings" 6 | 7 | type DocumentSettings = { 8 | documentSettingsMap: Map> 9 | } 10 | 11 | type GlobalSettings = { 12 | globalSettings: Settings 13 | } 14 | 15 | export class SettingsManager { 16 | 17 | constructor( 18 | private connection: Connection, 19 | private settings: DocumentSettings | GlobalSettings, 20 | ) { 21 | } 22 | 23 | async get(uri: URI): Promise { 24 | if (isDocumentSettings(this.settings)) { 25 | let newSettings = this.settings.documentSettingsMap.get(uri) 26 | if (newSettings === undefined) { 27 | newSettings = this.connection.workspace.getConfiguration({ 28 | scopeUri: uri, 29 | section: "plpgsqlLanguageServer", 30 | }) 31 | this.settings.documentSettingsMap.set( 32 | uri, newSettings ?? DEFAULT_SETTINGS, 33 | ) 34 | } 35 | 36 | return newSettings 37 | } 38 | else { 39 | return this.settings.globalSettings 40 | } 41 | } 42 | 43 | delete(uri: URI): void { 44 | if (isDocumentSettings(this.settings)) { 45 | this.settings.documentSettingsMap.delete(uri) 46 | } 47 | } 48 | 49 | reset(settings?: Settings): void { 50 | if (isDocumentSettings(this.settings)) { 51 | this.settings.documentSettingsMap.clear() 52 | } 53 | else { 54 | this.settings.globalSettings = settings ?? DEFAULT_SETTINGS 55 | } 56 | } 57 | 58 | async getWorkspaceFolder( 59 | uri: URI, 60 | ): Promise { 61 | const workspaces = await this.connection.workspace.getWorkspaceFolders() 62 | if (workspaces === null) { 63 | return undefined 64 | } 65 | 66 | const workspaceCandidates = workspaces.filter( 67 | workspace => uri.startsWith(workspace.uri), 68 | ) 69 | 70 | if (workspaceCandidates.length === 0) { 71 | return undefined 72 | } 73 | 74 | return workspaceCandidates.sort( 75 | (a, b) => b.uri.length - a.uri.length, 76 | )[0] 77 | } 78 | 79 | async isDefinitionTarget(uri: URI): Promise { 80 | const settings = await this.get(uri) 81 | if (settings.definitionFiles === undefined) { 82 | return false 83 | } 84 | 85 | const workspaceFolder = await this.getWorkspaceFolder(uri) 86 | if (workspaceFolder === undefined) { 87 | return false 88 | } 89 | 90 | return settings.definitionFiles.some( 91 | filePattern => { 92 | return minimatch(uri, path.join(workspaceFolder.uri, filePattern)) 93 | }, 94 | ) 95 | } 96 | } 97 | 98 | function isDocumentSettings( 99 | settings: DocumentSettings | GlobalSettings, 100 | ): settings is DocumentSettings { 101 | return "documentSettingsMap" in settings 102 | } 103 | -------------------------------------------------------------------------------- /client/src/extension/handlers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExtensionContext, 3 | TextDocument, workspace, 4 | WorkspaceFolder, 5 | WorkspaceFoldersChangeEvent, 6 | } from "vscode" 7 | import { 8 | LanguageClient, 9 | uinteger, 10 | } from "vscode-languageclient/node" 11 | 12 | import { 13 | makeLanguageClientOptions, 14 | PLPGSQL_LANGUAGE_SERVER_SECTION, 15 | } from "../options/clientOptions" 16 | import { makeLanguageServerOptions } from "../options/serverOptions" 17 | import { ClientManager } from "./clientManager" 18 | 19 | export class Handlers { 20 | constructor( 21 | private context: ExtensionContext, 22 | private clientManager: ClientManager, 23 | ) { 24 | workspace.onDidOpenTextDocument((event) => this.onDidOpenTextDocument(event)) 25 | workspace.textDocuments.forEach((event) => this.onDidOpenTextDocument(event)) 26 | workspace.onDidChangeWorkspaceFolders( 27 | (event) => this.onDidChangeWorkspaceFolders(event), 28 | ) 29 | } 30 | 31 | onDidOpenTextDocument(document: TextDocument): void { 32 | let client: LanguageClient 33 | // We are only interested in language mode text 34 | if ( 35 | document.languageId !== "postgres" 36 | || !["file", "untitled"].includes(document.uri.scheme) 37 | ) { 38 | return 39 | } 40 | 41 | const uri = document.uri 42 | // Untitled files go to a default client. 43 | if (uri.scheme === "untitled" && this.clientManager.global === undefined) { 44 | client = createLanguageClient(this.context, 6170) 45 | client.start() 46 | this.clientManager.global = client 47 | } 48 | // Workspace folder files go to client Map. 49 | else { 50 | const workspaceFolder = workspace.getWorkspaceFolder(uri) 51 | if (!workspaceFolder) { 52 | return 53 | } 54 | 55 | const workspaceFolderUri = workspaceFolder.uri.toString() 56 | if (this.clientManager.workspaces.has(workspaceFolderUri)) { 57 | return 58 | } 59 | 60 | client = createLanguageClient( 61 | this.context, 6171 + this.clientManager.workspaces.size, workspaceFolder, 62 | ) 63 | client.start() 64 | this.clientManager.workspaces.set(workspaceFolderUri, client) 65 | } 66 | } 67 | 68 | onDidChangeWorkspaceFolders(event: WorkspaceFoldersChangeEvent): void { 69 | for (const folder of event.removed) { 70 | const client = this.clientManager.workspaces.get(folder.uri.toString()) 71 | if (client) { 72 | this.clientManager.workspaces.delete(folder.uri.toString()) 73 | client.stop() 74 | } 75 | } 76 | } 77 | } 78 | 79 | function createLanguageClient( 80 | context: ExtensionContext, port: uinteger, workspaceFolder?: WorkspaceFolder, 81 | ): LanguageClient { 82 | const debugOptions = { execArgv: ["--nolazy", `--inspect=${port}`] } 83 | const serverOptions = makeLanguageServerOptions(context, debugOptions) 84 | const clientOptions = makeLanguageClientOptions(workspaceFolder) 85 | 86 | return new LanguageClient( 87 | PLPGSQL_LANGUAGE_SERVER_SECTION, 88 | "PL/pgSQL Language Server", 89 | serverOptions, 90 | clientOptions, 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /sample/definitions/function/keyword_argument_function.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "ObjectWithArgs": { 9 | "objname": [ 10 | { 11 | "String": { 12 | "str": "keyword_argument_function" 13 | } 14 | } 15 | ], 16 | "args_unspecified": true 17 | } 18 | } 19 | ], 20 | "removeType": "OBJECT_FUNCTION", 21 | "behavior": "DROP_RESTRICT", 22 | "missing_ok": true 23 | } 24 | }, 25 | "stmt_len": 49 26 | } 27 | }, 28 | { 29 | "RawStmt": { 30 | "stmt": { 31 | "CreateFunctionStmt": { 32 | "funcname": [ 33 | { 34 | "String": { 35 | "str": "keyword_argument_function" 36 | } 37 | } 38 | ], 39 | "parameters": [ 40 | { 41 | "FunctionParameter": { 42 | "name": "i", 43 | "argType": { 44 | "names": [ 45 | { 46 | "String": { 47 | "str": "pg_catalog" 48 | } 49 | }, 50 | { 51 | "String": { 52 | "str": "int4" 53 | } 54 | } 55 | ], 56 | "typemod": -1 57 | }, 58 | "mode": "FUNC_PARAM_IN" 59 | } 60 | } 61 | ], 62 | "returnType": { 63 | "names": [ 64 | { 65 | "String": { 66 | "str": "pg_catalog" 67 | } 68 | }, 69 | { 70 | "String": { 71 | "str": "int4" 72 | } 73 | } 74 | ], 75 | "typemod": -1 76 | }, 77 | "options": [ 78 | { 79 | "DefElem": { 80 | "defname": "as", 81 | "arg": { 82 | "List": { 83 | "items": [ 84 | { 85 | "String": { 86 | "str": "\nBEGIN\n RETURN i + 1;\nEND;\n" 87 | } 88 | } 89 | ] 90 | } 91 | }, 92 | "defaction": "DEFELEM_UNSPEC" 93 | } 94 | }, 95 | { 96 | "DefElem": { 97 | "defname": "language", 98 | "arg": { 99 | "String": { 100 | "str": "plpgsql" 101 | } 102 | }, 103 | "defaction": "DEFELEM_UNSPEC" 104 | } 105 | } 106 | ] 107 | } 108 | }, 109 | "stmt_len": 127 110 | } 111 | } 112 | ] -------------------------------------------------------------------------------- /sample/definitions/view/public_deleted_users.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "List": { 9 | "items": [ 10 | { 11 | "String": { 12 | "str": "public" 13 | } 14 | }, 15 | { 16 | "String": { 17 | "str": "deleted_users" 18 | } 19 | } 20 | ] 21 | } 22 | } 23 | ], 24 | "removeType": "OBJECT_VIEW", 25 | "behavior": "DROP_CASCADE", 26 | "missing_ok": true 27 | } 28 | }, 29 | "stmt_len": 48 30 | } 31 | }, 32 | { 33 | "RawStmt": { 34 | "stmt": { 35 | "ViewStmt": { 36 | "view": { 37 | "schemaname": "public", 38 | "relname": "deleted_users", 39 | "inh": true, 40 | "relpersistence": "p" 41 | }, 42 | "query": { 43 | "SelectStmt": { 44 | "targetList": [ 45 | { 46 | "ResTarget": { 47 | "val": { 48 | "ColumnRef": { 49 | "fields": [ 50 | { 51 | "A_Star": {} 52 | } 53 | ] 54 | } 55 | } 56 | } 57 | } 58 | ], 59 | "fromClause": [ 60 | { 61 | "RangeVar": { 62 | "schemaname": "public", 63 | "relname": "users", 64 | "inh": true, 65 | "relpersistence": "p" 66 | } 67 | } 68 | ], 69 | "whereClause": { 70 | "A_Expr": { 71 | "kind": "AEXPR_OP", 72 | "name": [ 73 | { 74 | "String": { 75 | "str": "<>" 76 | } 77 | } 78 | ], 79 | "lexpr": { 80 | "ColumnRef": { 81 | "fields": [ 82 | { 83 | "String": { 84 | "str": "deleted_at" 85 | } 86 | } 87 | ] 88 | } 89 | }, 90 | "rexpr": { 91 | "A_Const": { 92 | "val": { 93 | "Null": {} 94 | } 95 | } 96 | } 97 | } 98 | }, 99 | "limitOption": "LIMIT_OPTION_DEFAULT", 100 | "op": "SETOP_NONE" 101 | } 102 | }, 103 | "withCheckOption": "NO_CHECK_OPTION" 104 | } 105 | }, 106 | "stmt_len": 95 107 | } 108 | } 109 | ] -------------------------------------------------------------------------------- /sample/definitions/view/campaign_deleted_participants.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "List": { 9 | "items": [ 10 | { 11 | "String": { 12 | "str": "campaign" 13 | } 14 | }, 15 | { 16 | "String": { 17 | "str": "deleted_participants" 18 | } 19 | } 20 | ] 21 | } 22 | } 23 | ], 24 | "removeType": "OBJECT_VIEW", 25 | "behavior": "DROP_CASCADE", 26 | "missing_ok": true 27 | } 28 | }, 29 | "stmt_len": 57 30 | } 31 | }, 32 | { 33 | "RawStmt": { 34 | "stmt": { 35 | "ViewStmt": { 36 | "view": { 37 | "schemaname": "campaign", 38 | "relname": "deleted_participants", 39 | "inh": true, 40 | "relpersistence": "p" 41 | }, 42 | "query": { 43 | "SelectStmt": { 44 | "targetList": [ 45 | { 46 | "ResTarget": { 47 | "val": { 48 | "ColumnRef": { 49 | "fields": [ 50 | { 51 | "A_Star": {} 52 | } 53 | ] 54 | } 55 | } 56 | } 57 | } 58 | ], 59 | "fromClause": [ 60 | { 61 | "RangeVar": { 62 | "schemaname": "campaign", 63 | "relname": "participants", 64 | "inh": true, 65 | "relpersistence": "p" 66 | } 67 | } 68 | ], 69 | "whereClause": { 70 | "A_Expr": { 71 | "kind": "AEXPR_OP", 72 | "name": [ 73 | { 74 | "String": { 75 | "str": "<>" 76 | } 77 | } 78 | ], 79 | "lexpr": { 80 | "ColumnRef": { 81 | "fields": [ 82 | { 83 | "String": { 84 | "str": "deleted_at" 85 | } 86 | } 87 | ] 88 | } 89 | }, 90 | "rexpr": { 91 | "A_Const": { 92 | "val": { 93 | "Null": {} 94 | } 95 | } 96 | } 97 | } 98 | }, 99 | "limitOption": "LIMIT_OPTION_DEFAULT", 100 | "op": "SETOP_NONE" 101 | } 102 | }, 103 | "withCheckOption": "NO_CHECK_OPTION" 104 | } 105 | }, 106 | "stmt_len": 113 107 | } 108 | } 109 | ] -------------------------------------------------------------------------------- /sample/definitions/function/syntax_error_function_column_does_not_exist.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "ObjectWithArgs": { 9 | "objname": [ 10 | { 11 | "String": { 12 | "str": "function_column_does_not_exist" 13 | } 14 | } 15 | ], 16 | "args_unspecified": true 17 | } 18 | } 19 | ], 20 | "removeType": "OBJECT_FUNCTION", 21 | "behavior": "DROP_RESTRICT", 22 | "missing_ok": true 23 | } 24 | }, 25 | "stmt_len": 104 26 | } 27 | }, 28 | { 29 | "RawStmt": { 30 | "stmt": { 31 | "CreateFunctionStmt": { 32 | "funcname": [ 33 | { 34 | "String": { 35 | "str": "function_column_does_not_exist" 36 | } 37 | } 38 | ], 39 | "parameters": [ 40 | { 41 | "FunctionParameter": { 42 | "name": "p_id", 43 | "argType": { 44 | "names": [ 45 | { 46 | "String": { 47 | "str": "pg_catalog" 48 | } 49 | }, 50 | { 51 | "String": { 52 | "str": "int4" 53 | } 54 | } 55 | ], 56 | "typemod": -1 57 | }, 58 | "mode": "FUNC_PARAM_IN" 59 | } 60 | } 61 | ], 62 | "returnType": { 63 | "names": [ 64 | { 65 | "String": { 66 | "str": "public" 67 | } 68 | }, 69 | { 70 | "String": { 71 | "str": "users" 72 | } 73 | } 74 | ], 75 | "setof": true, 76 | "typemod": -1 77 | }, 78 | "options": [ 79 | { 80 | "DefElem": { 81 | "defname": "as", 82 | "arg": { 83 | "List": { 84 | "items": [ 85 | { 86 | "String": { 87 | "str": "\nDECLARE\nBEGIN\n RETURN QUERY\n SELECT\n id,\n name,\n tags,\n deleted_at\n FROM\n public.users\n WHERE\n id = p_id;\nEND;\n" 88 | } 89 | } 90 | ] 91 | } 92 | }, 93 | "defaction": "DEFELEM_UNSPEC" 94 | } 95 | }, 96 | { 97 | "DefElem": { 98 | "defname": "language", 99 | "arg": { 100 | "String": { 101 | "str": "plpgsql" 102 | } 103 | }, 104 | "defaction": "DEFELEM_UNSPEC" 105 | } 106 | } 107 | ] 108 | } 109 | }, 110 | "stmt_len": 268 111 | } 112 | } 113 | ] -------------------------------------------------------------------------------- /server/src/postgres/parameters/index.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, DiagnosticSeverity, Logger, uinteger } from "vscode-languageserver" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { Settings } from "@/settings" 5 | import { neverReach } from "@/utilities/neverReach" 6 | import { getTextAllRange } from "@/utilities/text" 7 | 8 | import { 9 | DefaultQueryParametersInfo, 10 | getDefaultQueryParameterInfo, 11 | sanitizeFileWithDefaultQueryParameters, 12 | } from "./defaultParameters" 13 | import { 14 | getKeywordQueryParameterInfo, 15 | KeywordQueryParameterPatternNotDefinedError, 16 | KeywordQueryParametersInfo, 17 | sanitizeFileWithKeywordQueryParameters, 18 | } from "./keywordParameters" 19 | import { 20 | getPositionalQueryParameterInfo, 21 | PositionalQueryParametersInfo, 22 | sanitizeFileWithPositionalQueryParameters, 23 | } from "./positionalParameters" 24 | 25 | export type QueryParameterInfo = ( 26 | DefaultQueryParametersInfo 27 | | PositionalQueryParametersInfo 28 | | KeywordQueryParametersInfo 29 | ) 30 | 31 | export function getQueryParameterInfo( 32 | document: TextDocument, 33 | statement: string, 34 | settings: Settings, 35 | logger: Logger, 36 | ): QueryParameterInfo | Diagnostic | null { 37 | let queryParameterInfo 38 | 39 | // default query parameter 40 | queryParameterInfo = getDefaultQueryParameterInfo( 41 | document, statement, settings.queryParameterPattern, logger, 42 | ) 43 | if (queryParameterInfo !== null) { 44 | return queryParameterInfo 45 | } 46 | 47 | // positional query parameter. 48 | queryParameterInfo = getPositionalQueryParameterInfo( 49 | document, statement, logger, 50 | ) 51 | if (queryParameterInfo !== null) { 52 | return queryParameterInfo 53 | } 54 | 55 | // keyword query parameter. 56 | try{ 57 | queryParameterInfo = getKeywordQueryParameterInfo( 58 | document, statement, settings.keywordQueryParameterPattern, logger, 59 | ) 60 | } 61 | catch (error: unknown) { 62 | if (error instanceof KeywordQueryParameterPatternNotDefinedError) { 63 | return { 64 | severity: DiagnosticSeverity.Error, 65 | range: getTextAllRange(document), 66 | message: error.message, 67 | } 68 | } 69 | } 70 | if (queryParameterInfo !== null) { 71 | return queryParameterInfo 72 | } 73 | 74 | return null 75 | } 76 | 77 | export function sanitizeFileWithQueryParameters( 78 | fileText: string, 79 | queryParameterInfo: QueryParameterInfo | null, 80 | logger: Logger, 81 | ): [string, uinteger] { 82 | if (queryParameterInfo === null) { 83 | return [fileText, 0] 84 | } 85 | else { 86 | const parameterInfoType = queryParameterInfo.type 87 | switch (parameterInfoType) { 88 | case "default": { 89 | return sanitizeFileWithDefaultQueryParameters( 90 | fileText, queryParameterInfo, logger, 91 | ) 92 | } 93 | case "position": { 94 | return sanitizeFileWithPositionalQueryParameters( 95 | fileText, queryParameterInfo, logger, 96 | ) 97 | } 98 | case "keyword": { 99 | return sanitizeFileWithKeywordQueryParameters( 100 | fileText, queryParameterInfo, logger, 101 | ) 102 | } 103 | default: { 104 | const unknwonType: never = parameterInfoType 105 | neverReach( `"${unknwonType}" is unknown "queryParameterInfo.type".` ) 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /sample/definitions/view/deleted_users.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "List": { 9 | "items": [ 10 | { 11 | "String": { 12 | "str": "deleted_users" 13 | } 14 | } 15 | ] 16 | } 17 | } 18 | ], 19 | "removeType": "OBJECT_VIEW", 20 | "behavior": "DROP_CASCADE", 21 | "missing_ok": true 22 | } 23 | }, 24 | "stmt_len": 41 25 | } 26 | }, 27 | { 28 | "RawStmt": { 29 | "stmt": { 30 | "ViewStmt": { 31 | "view": { 32 | "relname": "deleted_users", 33 | "inh": true, 34 | "relpersistence": "p" 35 | }, 36 | "query": { 37 | "SelectStmt": { 38 | "targetList": [ 39 | { 40 | "ResTarget": { 41 | "val": { 42 | "ColumnRef": { 43 | "fields": [ 44 | { 45 | "String": { 46 | "str": "id" 47 | } 48 | } 49 | ] 50 | } 51 | } 52 | } 53 | }, 54 | { 55 | "ResTarget": { 56 | "val": { 57 | "ColumnRef": { 58 | "fields": [ 59 | { 60 | "String": { 61 | "str": "name" 62 | } 63 | } 64 | ] 65 | } 66 | } 67 | } 68 | } 69 | ], 70 | "fromClause": [ 71 | { 72 | "RangeVar": { 73 | "relname": "users", 74 | "inh": true, 75 | "relpersistence": "p" 76 | } 77 | } 78 | ], 79 | "whereClause": { 80 | "A_Expr": { 81 | "kind": "AEXPR_OP", 82 | "name": [ 83 | { 84 | "String": { 85 | "str": "<>" 86 | } 87 | } 88 | ], 89 | "lexpr": { 90 | "ColumnRef": { 91 | "fields": [ 92 | { 93 | "String": { 94 | "str": "deleted_at" 95 | } 96 | } 97 | ] 98 | } 99 | }, 100 | "rexpr": { 101 | "A_Const": { 102 | "val": { 103 | "Null": {} 104 | } 105 | } 106 | } 107 | } 108 | }, 109 | "limitOption": "LIMIT_OPTION_DEFAULT", 110 | "op": "SETOP_NONE" 111 | } 112 | }, 113 | "withCheckOption": "NO_CHECK_OPTION" 114 | } 115 | }, 116 | "stmt_len": 90 117 | } 118 | } 119 | ] -------------------------------------------------------------------------------- /sample/definitions/table/companies.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "List": { 9 | "items": [ 10 | { 11 | "String": { 12 | "str": "companies" 13 | } 14 | } 15 | ] 16 | } 17 | } 18 | ], 19 | "removeType": "OBJECT_TABLE", 20 | "behavior": "DROP_CASCADE", 21 | "missing_ok": true 22 | } 23 | }, 24 | "stmt_len": 38 25 | } 26 | }, 27 | { 28 | "RawStmt": { 29 | "stmt": { 30 | "CreateStmt": { 31 | "relation": { 32 | "relname": "companies", 33 | "inh": true, 34 | "relpersistence": "p" 35 | }, 36 | "tableElts": [ 37 | { 38 | "ColumnDef": { 39 | "colname": "id", 40 | "typeName": { 41 | "names": [ 42 | { 43 | "String": { 44 | "str": "pg_catalog" 45 | } 46 | }, 47 | { 48 | "String": { 49 | "str": "int4" 50 | } 51 | } 52 | ], 53 | "typemod": -1 54 | }, 55 | "is_local": true, 56 | "constraints": [ 57 | { 58 | "Constraint": { 59 | "contype": "CONSTR_NOTNULL" 60 | } 61 | }, 62 | { 63 | "Constraint": { 64 | "contype": "CONSTR_PRIMARY" 65 | } 66 | } 67 | ] 68 | } 69 | }, 70 | { 71 | "ColumnDef": { 72 | "colname": "name", 73 | "typeName": { 74 | "names": [ 75 | { 76 | "String": { 77 | "str": "pg_catalog" 78 | } 79 | }, 80 | { 81 | "String": { 82 | "str": "varchar" 83 | } 84 | } 85 | ], 86 | "typmods": [ 87 | { 88 | "A_Const": { 89 | "val": { 90 | "Integer": { 91 | "ival": 10 92 | } 93 | } 94 | } 95 | } 96 | ], 97 | "typemod": -1 98 | }, 99 | "is_local": true, 100 | "constraints": [ 101 | { 102 | "Constraint": { 103 | "contype": "CONSTR_NOTNULL" 104 | } 105 | }, 106 | { 107 | "Constraint": { 108 | "contype": "CONSTR_UNIQUE" 109 | } 110 | } 111 | ] 112 | } 113 | } 114 | ], 115 | "oncommit": "ONCOMMIT_NOOP" 116 | } 117 | }, 118 | "stmt_len": 98 119 | } 120 | } 121 | ] -------------------------------------------------------------------------------- /server/src/services/codeLens.test.ts: -------------------------------------------------------------------------------- 1 | import { CodeLens } from "vscode-languageserver" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { DEFAULT_LOAD_FILE_OPTIONS, LoadFileOptions } from "@/__tests__/helpers/file" 5 | import { RecordLogger } from "@/__tests__/helpers/logger" 6 | import { setupTestServer } from "@/__tests__/helpers/server" 7 | import { SettingsBuilder } from "@/__tests__/helpers/settings" 8 | import { 9 | loadSampleTextDocument, 10 | TestTextDocuments, 11 | } from "@/__tests__/helpers/textDocuments" 12 | import { Server } from "@/server" 13 | 14 | import { makeExecuteFileQueryCommandCodeLens } from "./codeLens" 15 | 16 | describe("CodeLens Tests", () => { 17 | let server: Server 18 | 19 | afterEach(async () => { 20 | for (const pgPool of server.pgPools.values()) { 21 | await pgPool.end() 22 | } 23 | }) 24 | 25 | async function getCodeLensesInfo( 26 | file: string, 27 | options: LoadFileOptions = DEFAULT_LOAD_FILE_OPTIONS, 28 | ): Promise<[CodeLens[] | undefined, TextDocument]> { 29 | const document = await loadSampleTextDocument( 30 | file, 31 | options, 32 | ); 33 | 34 | (server.documents as TestTextDocuments).set(document) 35 | 36 | if (server.handlers === undefined) { 37 | throw new Error("handlers is undefined") 38 | } 39 | 40 | const codeLenses = await server.handlers.onCodeLens({ 41 | textDocument: { uri: document.uri }, 42 | }) 43 | 44 | return [codeLenses, document] 45 | } 46 | 47 | describe("Enable Settings", function () { 48 | beforeEach(() => { 49 | const settings = new SettingsBuilder().build() 50 | server = setupTestServer(settings, new RecordLogger()) 51 | }) 52 | 53 | it("succeed on the correct query.", async () => { 54 | const [codeLenses, document] = await getCodeLensesInfo( 55 | "queries/correct_query.pgsql", 56 | ) 57 | 58 | expect(codeLenses).toStrictEqual([makeExecuteFileQueryCommandCodeLens(document)]) 59 | }) 60 | 61 | it("is to be empty on the query with positional parameters.", async () => { 62 | const [codeLenses] = await getCodeLensesInfo( 63 | "queries/correct_query_with_positional_parameter.pgsql", 64 | ) 65 | 66 | expect(codeLenses).toStrictEqual([]) 67 | }) 68 | 69 | it("is to be undefined on the Language Server disable file.", async () => { 70 | const [codeLenses] = await getCodeLensesInfo( 71 | "queries/" 72 | + "syntax_error_query_with_language_server_disable_comment.pgsql", 73 | ) 74 | 75 | expect(codeLenses).toBeUndefined() 76 | }) 77 | 78 | it("is to be empty on the Language Server disable validation file.", async () => { 79 | const [codeLenses] = await getCodeLensesInfo( 80 | "queries/" 81 | + "syntax_error_query_with_language_server_disable_validation_comment.pgsql", 82 | ) 83 | 84 | expect(codeLenses).toStrictEqual([]) 85 | }) 86 | }) 87 | 88 | describe("Disable Settings", function () { 89 | beforeEach(() => { 90 | const settings = new SettingsBuilder() 91 | .with({ enableExecuteFileQueryCommand: false }) 92 | .build() 93 | server = setupTestServer(settings, new RecordLogger()) 94 | }) 95 | 96 | it("is to be empty on the correct query.", async () => { 97 | const [codeLenses] = await getCodeLensesInfo( 98 | "queries/correct_query.pgsql", 99 | ) 100 | 101 | expect(codeLenses).toStrictEqual([]) 102 | }) 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryTableDefinitions.ts: -------------------------------------------------------------------------------- 1 | import dedent from "ts-dedent/dist" 2 | import { Logger } from "vscode-languageserver" 3 | 4 | import { PostgresPool } from "@/postgres" 5 | import { makeSchemas } from "@/utilities/schema" 6 | 7 | interface TableDefinition { 8 | schema: string 9 | tableName: string 10 | fields: { 11 | columnName: string, 12 | dataType: string, 13 | isNullable: boolean, 14 | columnDefault: string | null 15 | }[] 16 | } 17 | 18 | export async function queryTableDefinitions( 19 | pgPool: PostgresPool, 20 | schema: string | undefined, 21 | tableName: string | undefined, 22 | defaultSchema: string, 23 | logger: Logger, 24 | ): Promise { 25 | let definitions: TableDefinition[] = [] 26 | 27 | const pgClient = await pgPool.connect() 28 | try { 29 | const results = await pgClient.query( 30 | ` 31 | SELECT 32 | t_tables.table_schema as schema, 33 | t_tables.table_name as table_name, 34 | COALESCE( 35 | json_agg( 36 | json_build_object( 37 | 'columnName', t_columns.column_name, 38 | 'dataType', t_columns.data_type, 39 | 'isNullable', t_columns.is_nullable = 'YES', 40 | 'columnDefault', t_columns.column_default 41 | ) 42 | ORDER BY 43 | t_columns.ordinal_position 44 | ) 45 | FILTER (WHERE t_columns.column_name IS NOT NULL), 46 | '[]' 47 | ) AS fields 48 | FROM 49 | information_schema.tables AS t_tables 50 | LEFT JOIN information_schema.columns AS t_columns ON 51 | t_columns.table_schema = t_tables.table_schema 52 | AND t_columns.table_name = t_tables.table_name 53 | WHERE 54 | t_tables.table_type = 'BASE TABLE' 55 | AND t_tables.table_schema = ANY($1) 56 | AND ($2::text IS NULL OR t_tables.table_name = $2::text) 57 | GROUP BY 58 | t_tables.table_schema, 59 | t_tables.table_name 60 | ORDER BY 61 | t_tables.table_schema, 62 | t_tables.table_name 63 | `, 64 | [makeSchemas(schema, defaultSchema), tableName?.toLowerCase()], 65 | ) 66 | 67 | definitions = results.rows.map( 68 | (row) => ({ 69 | schema: row.schema, 70 | tableName: row.table_name, 71 | fields: row.fields as { 72 | columnName: string, 73 | dataType: string, 74 | isNullable: boolean, 75 | columnDefault: string | null 76 | }[], 77 | }), 78 | ) 79 | } 80 | catch (error: unknown) { 81 | logger.error(`${(error as Error).message}`) 82 | } 83 | finally { 84 | pgClient.release() 85 | } 86 | 87 | return definitions 88 | } 89 | 90 | export function makeTableDefinitionText(definition: TableDefinition): string { 91 | const { 92 | schema, tableName, fields, 93 | } = definition 94 | 95 | if (fields.length === 0) { 96 | return `TABLE ${schema}.${tableName}()` 97 | } 98 | else { 99 | const tableFields = fields.map( 100 | ({ columnName, dataType, isNullable, columnDefault }) => [ 101 | columnName, 102 | dataType, 103 | isNullable ? null : "not null", 104 | columnDefault ? `default ${columnDefault}` : null, 105 | ] 106 | .filter(elem => elem !== null) 107 | .join(" "), 108 | ) 109 | 110 | return dedent` 111 | TABLE ${schema}.${tableName}( 112 | ${tableFields.join(",\n")} 113 | ) 114 | ` 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /server/src/commands/validateWorkspace.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_LOAD_FILE_OPTIONS, getSampleWorkspace, LoadFileOptions, 3 | } from "@/__tests__/helpers/file" 4 | import { RecordLogger } from "@/__tests__/helpers/logger" 5 | import { setupTestServer } from "@/__tests__/helpers/server" 6 | import { SettingsBuilder } from "@/__tests__/helpers/settings" 7 | import { 8 | loadSampleTextDocument, 9 | TestTextDocuments, 10 | } from "@/__tests__/helpers/textDocuments" 11 | import { 12 | PostgresPoolNotFoundError, 13 | WorkspaceValidationTargetFilesEmptyError, 14 | } from "@/errors" 15 | import { Server } from "@/server" 16 | 17 | import { WORKSPACE_VALIDATION_COMMAND } from "./validateWorkspace" 18 | 19 | describe("CommandExecuter.validateWorkspace Tests", () => { 20 | let server: Server 21 | 22 | afterEach(async () => { 23 | for (const pgPool of server.pgPools.values()) { 24 | await pgPool.end() 25 | } 26 | }) 27 | 28 | async function executeCommand( 29 | file: string, 30 | options: LoadFileOptions = DEFAULT_LOAD_FILE_OPTIONS, 31 | ): Promise { 32 | const document = await loadSampleTextDocument( 33 | file, 34 | options, 35 | ) 36 | const workspace = getSampleWorkspace(); 37 | 38 | (server.documents as TestTextDocuments).set(document) 39 | 40 | if (server.handlers === undefined) { 41 | throw new Error("handlers is undefined") 42 | } 43 | if (server.commandExecuter === undefined) { 44 | throw new Error("commandExecuter is undefined") 45 | } 46 | 47 | await server.commandExecuter?.execute( 48 | { 49 | command: WORKSPACE_VALIDATION_COMMAND.name, 50 | arguments: [document.uri, workspace.uri, workspace.name], 51 | }, 52 | ) 53 | } 54 | 55 | describe("Enable Settings", function () { 56 | beforeEach(() => { 57 | const settings = new SettingsBuilder() 58 | .with({ 59 | workspaceValidationTargetFiles: [ 60 | "**/*.psql", 61 | "**/*.pgsql", 62 | ], 63 | }) 64 | .build() 65 | server = setupTestServer(settings, new RecordLogger()) 66 | server.start() 67 | }) 68 | 69 | it("pass workspace validation", async () => { 70 | await executeCommand("queries/correct_query.pgsql") 71 | }) 72 | }) 73 | 74 | describe("Disable Settings", function () { 75 | beforeEach(() => { 76 | const settings = new SettingsBuilder() 77 | .build() 78 | server = setupTestServer(settings, new RecordLogger()) 79 | }) 80 | 81 | it( 82 | "throw WorkspaceValidationTargetFilesEmptyError on the correct query", 83 | async () => { 84 | await expect(executeCommand("queries/correct_query.pgsql")) 85 | .rejects 86 | .toThrowError(WorkspaceValidationTargetFilesEmptyError) 87 | }, 88 | ) 89 | }) 90 | 91 | describe("Wrong Postgres Settings", function () { 92 | beforeEach(() => { 93 | const settings = new SettingsBuilder() 94 | .with({ 95 | database: "NonExistentDatabase", 96 | workspaceValidationTargetFiles: [ 97 | "**/*.psql", 98 | "**/*.pgsql", 99 | ], 100 | }) 101 | .build() 102 | 103 | server = setupTestServer(settings, new RecordLogger()) 104 | }) 105 | 106 | it("throw PostgresPoolNotFoundError on the query file.", async () => { 107 | await expect(executeCommand("queries/correct_query.pgsql")) 108 | .rejects 109 | .toThrowError(PostgresPoolNotFoundError) 110 | }) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /sample/definitions/trigger/user_update.pgsql.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RawStmt": { 4 | "stmt": { 5 | "DropStmt": { 6 | "objects": [ 7 | { 8 | "ObjectWithArgs": { 9 | "objname": [ 10 | { 11 | "String": { 12 | "str": "update_user_update_at" 13 | } 14 | } 15 | ], 16 | "args_unspecified": true 17 | } 18 | } 19 | ], 20 | "removeType": "OBJECT_FUNCTION", 21 | "behavior": "DROP_CASCADE", 22 | "missing_ok": true 23 | } 24 | }, 25 | "stmt_len": 53 26 | } 27 | }, 28 | { 29 | "RawStmt": { 30 | "stmt": { 31 | "CreateFunctionStmt": { 32 | "funcname": [ 33 | { 34 | "String": { 35 | "str": "update_user_update_at" 36 | } 37 | } 38 | ], 39 | "returnType": { 40 | "names": [ 41 | { 42 | "String": { 43 | "str": "trigger" 44 | } 45 | } 46 | ], 47 | "typemod": -1 48 | }, 49 | "options": [ 50 | { 51 | "DefElem": { 52 | "defname": "as", 53 | "arg": { 54 | "List": { 55 | "items": [ 56 | { 57 | "String": { 58 | "str": "\nBEGIN\n UPDATE users SET updated_at = now();\nEND;\n" 59 | } 60 | } 61 | ] 62 | } 63 | }, 64 | "defaction": "DEFELEM_UNSPEC" 65 | } 66 | }, 67 | { 68 | "DefElem": { 69 | "defname": "language", 70 | "arg": { 71 | "String": { 72 | "str": "plpgsql" 73 | } 74 | }, 75 | "defaction": "DEFELEM_UNSPEC" 76 | } 77 | } 78 | ] 79 | } 80 | }, 81 | "stmt_len": 149 82 | } 83 | }, 84 | { 85 | "RawStmt": { 86 | "stmt": { 87 | "DropStmt": { 88 | "objects": [ 89 | { 90 | "List": { 91 | "items": [ 92 | { 93 | "String": { 94 | "str": "users" 95 | } 96 | }, 97 | { 98 | "String": { 99 | "str": "check_update_trigger" 100 | } 101 | } 102 | ] 103 | } 104 | } 105 | ], 106 | "removeType": "OBJECT_TRIGGER", 107 | "behavior": "DROP_CASCADE", 108 | "missing_ok": true 109 | } 110 | }, 111 | "stmt_len": 63 112 | } 113 | }, 114 | { 115 | "RawStmt": { 116 | "stmt": { 117 | "CreateTrigStmt": { 118 | "trigname": "check_update_trigger", 119 | "relation": { 120 | "relname": "users", 121 | "inh": true, 122 | "relpersistence": "p" 123 | }, 124 | "funcname": [ 125 | { 126 | "String": { 127 | "str": "update_user_update_at" 128 | } 129 | } 130 | ], 131 | "events": 16 132 | } 133 | }, 134 | "stmt_len": 108 135 | } 136 | } 137 | ] -------------------------------------------------------------------------------- /server/src/services/codeAction.test.ts: -------------------------------------------------------------------------------- 1 | import { CodeAction, Range } from "vscode-languageserver" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { DEFAULT_LOAD_FILE_OPTIONS, LoadFileOptions } from "@/__tests__/helpers/file" 5 | import { RecordLogger } from "@/__tests__/helpers/logger" 6 | import { setupTestServer } from "@/__tests__/helpers/server" 7 | import { SettingsBuilder } from "@/__tests__/helpers/settings" 8 | import { 9 | loadSampleTextDocument, 10 | TestTextDocuments, 11 | } from "@/__tests__/helpers/textDocuments" 12 | import { Server } from "@/server" 13 | 14 | import { makeExecuteFileQueryCommandCodeAction } from "./codeAction" 15 | 16 | describe("CodeAction Tests", () => { 17 | let server: Server 18 | 19 | afterEach(async () => { 20 | for (const pgPool of server.pgPools.values()) { 21 | await pgPool.end() 22 | } 23 | }) 24 | 25 | async function getCodeActionsInfo( 26 | file: string, 27 | options: LoadFileOptions = DEFAULT_LOAD_FILE_OPTIONS, 28 | ): Promise<[CodeAction[] | undefined, TextDocument]> { 29 | const document = await loadSampleTextDocument( 30 | file, 31 | options, 32 | ); 33 | 34 | (server.documents as TestTextDocuments).set(document) 35 | 36 | if (server.handlers === undefined) { 37 | throw new Error("handlers is undefined") 38 | } 39 | 40 | const codeLenses = await server.handlers.onCodeAction({ 41 | textDocument: { uri: document.uri }, 42 | range: Range.create(0, 0, 0, 0), 43 | context: { diagnostics: [] }, 44 | }) 45 | 46 | return [codeLenses, document] 47 | } 48 | 49 | describe("Enable Settings", function () { 50 | beforeEach(() => { 51 | const settings = new SettingsBuilder().build() 52 | server = setupTestServer(settings, new RecordLogger()) 53 | }) 54 | 55 | it("succeed on the correct query.", async () => { 56 | const [codeLenses, document] = await getCodeActionsInfo( 57 | "queries/correct_query.pgsql", 58 | ) 59 | 60 | expect(codeLenses).toStrictEqual( 61 | [makeExecuteFileQueryCommandCodeAction(document)], 62 | ) 63 | }) 64 | 65 | it("succeed on the query with positional parameters.", async () => { 66 | const [codeLenses] = await getCodeActionsInfo( 67 | "queries/correct_query_with_positional_parameter.pgsql", 68 | ) 69 | 70 | expect(codeLenses).toStrictEqual([]) 71 | }) 72 | 73 | it("is to be undefined on the Language Server disable file.", async () => { 74 | const [codeLenses] = await getCodeActionsInfo( 75 | "queries/" 76 | + "syntax_error_query_with_language_server_disable_comment.pgsql", 77 | ) 78 | 79 | expect(codeLenses).toBeUndefined() 80 | }) 81 | 82 | it("is to be empty on the Language Server disable validation file.", async () => { 83 | const [codeLenses] = await getCodeActionsInfo( 84 | "queries/" 85 | + "syntax_error_query_with_language_server_disable_validation_comment.pgsql", 86 | ) 87 | 88 | expect(codeLenses).toStrictEqual([]) 89 | }) 90 | }) 91 | 92 | describe("Disable Settings", function () { 93 | beforeEach(() => { 94 | const settings = new SettingsBuilder() 95 | .with({ enableExecuteFileQueryCommand: false }) 96 | .build() 97 | server = setupTestServer(settings, new RecordLogger()) 98 | }) 99 | 100 | it("is to be empty on the correct query.", async () => { 101 | const [codeLenses] = await getCodeActionsInfo( 102 | "queries/correct_query.pgsql", 103 | ) 104 | 105 | expect(codeLenses).toStrictEqual([]) 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryFileStaticAnalysis.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Range, uinteger } from "vscode-languageserver" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { PostgresPool } from "@/postgres" 5 | import { 6 | QueryParameterInfo, 7 | sanitizeFileWithQueryParameters, 8 | } from "@/postgres/parameters" 9 | import { FunctionInfo } from "@/postgres/parsers/parseFunctions" 10 | import { getLineRangeFromBuffer, getTextAllRange } from "@/utilities/text" 11 | 12 | export interface StaticAnalysisErrorRow { 13 | procedure: string 14 | lineno: uinteger 15 | statement: string 16 | sqlstate: string 17 | message: string 18 | detail: string 19 | hint: string 20 | level: string 21 | position: string 22 | query: string 23 | context: string 24 | } 25 | 26 | export interface StaticAnalysisError { 27 | level: string 28 | range: Range 29 | message: string 30 | } 31 | 32 | export type StaticAnalysisOptions = { 33 | isComplete: boolean, 34 | queryParameterInfo: QueryParameterInfo | null 35 | } 36 | 37 | export async function queryFileStaticAnalysis( 38 | pgPool: PostgresPool, 39 | document: TextDocument, 40 | functionInfos: FunctionInfo[], 41 | options: StaticAnalysisOptions, 42 | logger: Logger, 43 | ): Promise { 44 | const errors: StaticAnalysisError[] = [] 45 | const [fileText, parameterNumber] = sanitizeFileWithQueryParameters( 46 | document.getText(), options.queryParameterInfo, logger, 47 | ) 48 | 49 | const pgClient = await pgPool.connect() 50 | try { 51 | await pgClient.query("BEGIN") 52 | await pgClient.query( 53 | fileText, Array(parameterNumber).fill(null), 54 | ) 55 | const extensionCheck = await pgClient.query(` 56 | SELECT 57 | extname 58 | FROM 59 | pg_extension 60 | WHERE 61 | extname = 'plpgsql_check' 62 | `) 63 | 64 | if (extensionCheck.rowCount === 0) { 65 | return [] 66 | } 67 | 68 | for (const { functionName, location } of functionInfos) { 69 | const result = await pgClient.query( 70 | ` 71 | SELECT 72 | (pcf).functionid::regprocedure AS procedure, 73 | (pcf).lineno AS lineno, 74 | (pcf).statement AS statement, 75 | (pcf).sqlstate AS sqlstate, 76 | (pcf).message AS message, 77 | (pcf).detail AS detail, 78 | (pcf).hint AS hint, 79 | (pcf).level AS level, 80 | (pcf)."position" AS position, 81 | (pcf).query AS query, 82 | (pcf).context AS context 83 | FROM 84 | plpgsql_check_function_tb($1) AS pcf 85 | `, 86 | [functionName], 87 | ) 88 | 89 | const rows: StaticAnalysisErrorRow[] = result.rows 90 | if (rows.length === 0) { 91 | continue 92 | } 93 | 94 | rows.forEach( 95 | (row) => { 96 | const range = (() => { 97 | return (location === undefined) 98 | ? getTextAllRange(document) 99 | : getLineRangeFromBuffer( 100 | fileText, 101 | location, 102 | row.lineno ? row.lineno - 1 : 0, 103 | ) ?? getTextAllRange(document) 104 | })() 105 | 106 | errors.push({ 107 | level: row.level, range, message: row.message, 108 | }) 109 | }, 110 | ) 111 | } 112 | } 113 | catch (error: unknown) { 114 | if (options.isComplete) { 115 | const message = (error as Error).message 116 | logger.error(`StaticAnalysisError: ${message} (${document.uri})`) 117 | } 118 | } 119 | finally { 120 | await pgClient.query("ROLLBACK") 121 | pgClient.release() 122 | } 123 | 124 | return errors 125 | } 126 | -------------------------------------------------------------------------------- /server/src/services/symbol.test.ts: -------------------------------------------------------------------------------- 1 | import { Logger, SymbolInformation, URI } from "vscode-languageserver" 2 | 3 | import { getSampleFileUri } from "@/__tests__/helpers/file" 4 | import { RecordLogger } from "@/__tests__/helpers/logger" 5 | import { setupTestServer } from "@/__tests__/helpers/server" 6 | import { SettingsBuilder } from "@/__tests__/helpers/settings" 7 | import { 8 | loadSampleTextDocument, TestTextDocuments, 9 | } from "@/__tests__/helpers/textDocuments" 10 | import { Server } from "@/server" 11 | import { neverReach } from "@/utilities/neverReach" 12 | import { readTextDocumentFromUri } from "@/utilities/text" 13 | 14 | expect.extend({ 15 | toSymbolUriEqual( 16 | symbols: SymbolInformation[] | undefined, expectedUri: URI, 17 | ) { 18 | expect(symbols).toBeDefined() 19 | if (symbols === undefined) neverReach() 20 | 21 | if (symbols.length !== 0 && symbols[0].location.uri === expectedUri) { 22 | return { 23 | pass: true, 24 | message: () => 25 | `expected not to equal Symbol URI ${expectedUri}`, 26 | } 27 | } 28 | else { 29 | return { 30 | pass: false, 31 | message: () => 32 | `expected to equal Symbol URI ${expectedUri}`, 33 | } 34 | } 35 | }, 36 | }) 37 | 38 | describe("Definition Tests", () => { 39 | let logger: Logger 40 | let server: Server 41 | 42 | beforeEach(() => { 43 | const settings = new SettingsBuilder().build() 44 | logger = new RecordLogger() 45 | server = setupTestServer(settings, logger) 46 | }) 47 | 48 | afterEach(async () => { 49 | for (const pgPool of server.pgPools.values()) { 50 | await pgPool.end() 51 | } 52 | }) 53 | 54 | async function onDocumentSymbol( 55 | documentUri: URI, 56 | ): Promise { 57 | const textDocument = await loadSampleTextDocument(documentUri); 58 | 59 | (server.documents as TestTextDocuments).set(textDocument) 60 | 61 | await server.symbolsManager.updateDocumentSymbols( 62 | await readTextDocumentFromUri(documentUri), 63 | await server.settingsManager.get(textDocument.uri), 64 | logger, 65 | ) 66 | 67 | if (server.handlers === undefined) { 68 | throw new Error("handlers is undefined") 69 | } 70 | 71 | return server.handlers.onDocumentSymbol({ 72 | textDocument, 73 | }) 74 | } 75 | 76 | describe("DocumentSymbol", function () { 77 | test.each([ 78 | "definitions/table/companies.pgsql", 79 | "definitions/table/public_users.pgsql", 80 | "definitions/table/schedule.pgsql", 81 | "definitions/table/campaign_participants.pgsql", 82 | "definitions/table/empty_table.pgsql", 83 | "definitions/view/deleted_users.pgsql", 84 | "definitions/view/deleted_users.pgsql", 85 | "definitions/view/campaign_deleted_participants.pgsql", 86 | "definitions/materialized_view/my_users.pgsql", 87 | "definitions/function/positional_argument_function.pgsql", 88 | "definitions/function/positional_argument_function.pgsql", 89 | "definitions/function/keyword_argument_function.pgsql", 90 | "definitions/procedure/correct_procedure.pgsql", 91 | "definitions/function/constant_function.pgsql", 92 | "definitions/type/type_user.pgsql", 93 | "definitions/type/type_user.pgsql", 94 | "definitions/type/type_single_field.pgsql", 95 | "definitions/type/type_single_field.pgsql", 96 | "definitions/type/type_empty.pgsql", 97 | "definitions/domain/us_postal_code.pgsql", 98 | "definitions/domain/jp_postal_code.pgsql", 99 | "definitions/index/users_id_name_index.pgsql", 100 | ])( 101 | "can go to symbol (%s)", async (source) => { 102 | const documentUri = getSampleFileUri(source) 103 | const definition = await onDocumentSymbol(source) 104 | 105 | expect(definition).toSymbolUriEqual(documentUri) 106 | }, 107 | ) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryTableIndexes.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vscode-languageserver" 2 | 3 | import { PostgresPool } from "@/postgres/pool" 4 | import { 5 | DefinitionsManager, 6 | makeTargetRelatedTableLink, 7 | } from "@/server/definitionsManager" 8 | 9 | interface TableIndex { 10 | schemaName: string, 11 | tableName: string, 12 | indexName: string, 13 | accessMethodName: string, 14 | columnNames: string[] 15 | isPrimaryKey: boolean, 16 | isUnique: boolean, 17 | isExcludeUsing: boolean, 18 | } 19 | 20 | export async function queryTableIndexes( 21 | pgPool: PostgresPool, 22 | schema: string | undefined, 23 | tableName: string, 24 | defaultSchema: string, 25 | logger: Logger, 26 | ): Promise { 27 | let tableIndexes: TableIndex[] = [] 28 | const schemaName = schema ?? defaultSchema 29 | 30 | const pgClient = await pgPool.connect() 31 | try { 32 | const results = await pgClient.query( 33 | ` 34 | WITH t_table_indexes AS ( 35 | SELECT 36 | DISTINCT 37 | index_class.relname AS index_name, 38 | pg_index.indisprimary AS is_primary_key, 39 | pg_index.indisunique AS is_unique, 40 | pg_index.indisexclusion AS is_exclude_using, 41 | pg_am.amname AS access_method_name, 42 | string_agg(pg_attribute.attname, ',') AS column_names 43 | FROM 44 | pg_class AS index_class 45 | JOIN pg_namespace ON 46 | index_class.relnamespace = pg_namespace.oid 47 | AND pg_namespace.nspname = $1 48 | JOIN pg_index ON 49 | index_class.oid = pg_index.indexrelid 50 | AND index_class.relkind = 'i' 51 | AND index_class.relname NOT LIKE 'pg_%' 52 | JOIN pg_class AS table_class ON 53 | table_class.oid = pg_index.indrelid 54 | AND table_class.relname = $2 55 | JOIN pg_am ON 56 | pg_am.oid=index_class.relam 57 | LEFT JOIN pg_attribute ON 58 | pg_attribute.attrelid = table_class.oid 59 | AND pg_attribute.attnum = ANY(pg_index.indkey) 60 | GROUP BY 61 | index_class.relname, 62 | pg_index.indisprimary, 63 | pg_index.indisunique, 64 | pg_index.indisexclusion, 65 | pg_am.amname 66 | ) 67 | SELECT 68 | * 69 | FROM 70 | t_table_indexes 71 | ORDER BY 72 | is_primary_key DESC, 73 | index_name 74 | `, 75 | [schemaName, tableName.toLowerCase()], 76 | ) 77 | 78 | tableIndexes = results.rows.map( 79 | (row) => ({ 80 | schemaName, 81 | tableName, 82 | indexName: row.index_name, 83 | accessMethodName: row.access_method_name, 84 | columnNames: row.column_names.split(","), 85 | isPrimaryKey: row.is_primary_key, 86 | isUnique: row.is_unique, 87 | isExcludeUsing: row.is_exclude_using, 88 | }), 89 | ) 90 | } 91 | catch (error: unknown) { 92 | logger.error(`${(error as Error).message}`) 93 | } 94 | finally { 95 | pgClient.release() 96 | } 97 | 98 | return tableIndexes 99 | } 100 | 101 | 102 | export function makeTableIndexText( 103 | tableIndex: TableIndex, definitionsManager: DefinitionsManager, 104 | ): string { 105 | const { 106 | schemaName, 107 | tableName, 108 | indexName, 109 | accessMethodName, 110 | columnNames, 111 | isPrimaryKey, 112 | isUnique, 113 | isExcludeUsing, 114 | } = tableIndex 115 | 116 | const targetLink = makeTargetRelatedTableLink( 117 | indexName, schemaName, tableName, definitionsManager, 118 | ) 119 | 120 | return [ 121 | targetLink, 122 | isPrimaryKey ? "PRIMARY KEY," : null, 123 | !isPrimaryKey && isUnique ? "UNIQUE," : null, 124 | isExcludeUsing ? "EXCLUDE USING" : null, 125 | accessMethodName, 126 | `(${columnNames.join(", ")})`, 127 | ] 128 | .filter(elem => elem !== null) 129 | .join(" ") 130 | } 131 | -------------------------------------------------------------------------------- /server/src/services/validation.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, DiagnosticSeverity, Logger } from "vscode-languageserver" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { PostgresPool } from "@/postgres" 5 | import { QueryParameterInfo } from "@/postgres/parameters" 6 | import { parseFunctions } from "@/postgres/parsers/parseFunctions" 7 | import { queryFileStaticAnalysis } from "@/postgres/queries/queryFileStaticAnalysis" 8 | import { queryFileSyntaxAnalysis } from "@/postgres/queries/queryFileSyntaxAnalysis" 9 | import { Settings, StatementsSettings } from "@/settings" 10 | 11 | type ValidateTextDocumentOptions = { 12 | isComplete: boolean, 13 | hasDiagnosticRelatedInformationCapability: boolean, 14 | queryParameterInfo: QueryParameterInfo | null, 15 | statements?: StatementsSettings, 16 | } 17 | 18 | export async function validateTextDocument( 19 | pgPool: PostgresPool, 20 | document: TextDocument, 21 | options: ValidateTextDocumentOptions, 22 | settings: Settings, 23 | logger: Logger, 24 | ): Promise { 25 | let diagnostics: Diagnostic[] = [] 26 | diagnostics = await validateSyntaxAnalysis( 27 | pgPool, 28 | document, 29 | options, 30 | settings, 31 | logger, 32 | ) 33 | 34 | // TODO static analysis for statements 35 | // if (diagnostics.filter(d => d.severity === DiagnosticSeverity.Error).length === 0) { 36 | if (diagnostics.length === 0) { 37 | diagnostics = await validateStaticAnalysis( 38 | pgPool, 39 | document, 40 | options, 41 | logger, 42 | ) 43 | } 44 | 45 | return diagnostics 46 | } 47 | 48 | export async function isCorrectFileValidation( 49 | pgPool: PostgresPool, 50 | document: TextDocument, 51 | settings: Settings, 52 | logger: Logger, 53 | ): Promise { 54 | const diagnostics = await validateTextDocument( 55 | pgPool, 56 | document, 57 | { 58 | isComplete: false, 59 | queryParameterInfo: null, 60 | hasDiagnosticRelatedInformationCapability: false, 61 | }, 62 | settings, 63 | logger, 64 | ) 65 | 66 | // Check file has no validation error. 67 | return diagnostics.filter( 68 | diagnostic => diagnostic.severity === DiagnosticSeverity.Error, 69 | ).length === 0 70 | } 71 | 72 | async function validateSyntaxAnalysis( 73 | pgPool: PostgresPool, 74 | document: TextDocument, 75 | options: ValidateTextDocumentOptions, 76 | settings: Settings, 77 | logger: Logger, 78 | ): Promise { 79 | return await queryFileSyntaxAnalysis( 80 | pgPool, 81 | document, 82 | options, 83 | settings, 84 | logger, 85 | ) 86 | } 87 | 88 | async function validateStaticAnalysis( 89 | pgPool: PostgresPool, 90 | document: TextDocument, 91 | options: ValidateTextDocumentOptions, 92 | logger: Logger, 93 | ): Promise { 94 | const errors = await queryFileStaticAnalysis( 95 | pgPool, 96 | document, 97 | await parseFunctions(document.uri, options.queryParameterInfo, logger), 98 | { 99 | isComplete: options.isComplete, 100 | queryParameterInfo: options.queryParameterInfo, 101 | }, 102 | logger, 103 | ) 104 | 105 | return errors.flatMap( 106 | ({ level, range, message }) => { 107 | const severity = (() => { 108 | return (["warning", "warning extra"].includes(level)) 109 | ? DiagnosticSeverity.Warning 110 | : DiagnosticSeverity.Error 111 | })() 112 | 113 | const diagnostic: Diagnostic = { 114 | severity, 115 | range, 116 | message, 117 | } 118 | 119 | if (options.hasDiagnosticRelatedInformationCapability) { 120 | diagnostic.relatedInformation = [ 121 | { 122 | location: { 123 | uri: document.uri, 124 | range: Object.assign({}, diagnostic.range), 125 | }, 126 | message: `Static analysis ${level}: ${message}`, 127 | }, 128 | ] 129 | } 130 | 131 | return diagnostic 132 | }, 133 | ) 134 | } 135 | -------------------------------------------------------------------------------- /server/src/postgres/parameters/keywordParameters.ts: -------------------------------------------------------------------------------- 1 | import { Logger, uinteger } from "vscode-languageserver-protocol/node" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { Settings } from "@/settings" 5 | import { escapeRegex } from "@/utilities/regex" 6 | import { getFirstLine } from "@/utilities/text" 7 | 8 | import { makePositionalParamter } from "./helpers" 9 | 10 | export type KeywordQueryParametersInfo = { 11 | type: "keyword", 12 | keywordParameters: string[], 13 | keywordQueryParameterPattern: string[] 14 | } 15 | 16 | export class KeywordQueryParameterPatternNotDefinedError extends Error { 17 | constructor() { 18 | super( 19 | "'plpgsqlLanguageServer.keywordQueryParameterPattern'" 20 | + " does not set in the settings.", 21 | ) 22 | this.name = "KeywordQueryParameterPatternNotDefinedError" 23 | } 24 | } 25 | 26 | export function getKeywordQueryParameterInfo( 27 | document: TextDocument, 28 | statement: string, 29 | keywordQueryParameterPattern: Settings["keywordQueryParameterPattern"], 30 | _logger: Logger, 31 | ): KeywordQueryParametersInfo | null { 32 | const firstLine = getFirstLine(document) 33 | 34 | for (const pattern of [ 35 | /^ *-- +plpgsql-language-server:use-keyword-query-parameter( +keywords=\[ *([A-Za-z_][A-Za-z0-9_]*)?((, *([A-Za-z_][A-Za-z0-9_]*))*),? *\])? *$/, // eslint-disable-line max-len 36 | /^ *\/\* +plpgsql-language-server:use-keyword-query-parameter( +keywords=\[ *([A-Za-z_][A-Za-z0-9_]*)?((, *([A-Za-z_][A-Za-z0-9_]*))*),? *\])? +\*\/$/, // eslint-disable-line max-len 37 | ]) { 38 | const found = firstLine.match(pattern) 39 | 40 | if (found !== null) { 41 | 42 | if (keywordQueryParameterPattern === undefined) { 43 | throw new KeywordQueryParameterPatternNotDefinedError() 44 | } 45 | let keywordQueryParameterPatterns: string[] 46 | if (typeof keywordQueryParameterPattern === "string") { 47 | keywordQueryParameterPatterns = [keywordQueryParameterPattern] 48 | } 49 | else { 50 | keywordQueryParameterPatterns = keywordQueryParameterPattern 51 | } 52 | 53 | const keywordParameters: string[] = [] 54 | const headWord = found[2] 55 | const tailWords = found[3] 56 | 57 | if (headWord !== undefined) { 58 | keywordQueryParameterPatterns.forEach( 59 | pattern => keywordParameters.push(pattern.replace("{keyword}", headWord)), 60 | ) 61 | 62 | if (tailWords !== "") { 63 | tailWords 64 | .split(",") 65 | .map(word => word.trim()) 66 | .filter(word => word !== "") 67 | .forEach(word => { 68 | keywordQueryParameterPatterns.forEach( 69 | pattern => keywordParameters.push(pattern.replace("{keyword}", word)), 70 | ) 71 | }) 72 | } 73 | } 74 | else { 75 | // auto calculation. 76 | keywordQueryParameterPatterns.forEach(pattern => { 77 | 78 | const keywordRegExp = new RegExp( 79 | pattern.replace("{keyword}", "[A-Za-z_][A-Za-z0-9_]*"), 80 | "g", 81 | ) 82 | keywordParameters.push(...Array.from( 83 | new Set( 84 | [...statement.matchAll(keywordRegExp)] 85 | .map((found) => found[0]), 86 | ), 87 | )) 88 | }) 89 | } 90 | 91 | return { 92 | type: "keyword", 93 | keywordParameters, 94 | keywordQueryParameterPattern: keywordQueryParameterPatterns, 95 | } 96 | } 97 | } 98 | 99 | return null 100 | } 101 | 102 | export function sanitizeFileWithKeywordQueryParameters( 103 | fileText: string, 104 | queryParameterInfo: KeywordQueryParametersInfo, 105 | _logger: Logger, 106 | ): [string, uinteger] { 107 | const keywordParameters = new Set(queryParameterInfo.keywordParameters) 108 | for ( 109 | const [index, keywordParameter] of Array.from(keywordParameters.values()).entries() 110 | ) { 111 | fileText = fileText.replace( 112 | new RegExp(escapeRegex(keywordParameter), "g"), 113 | makePositionalParamter(index, keywordParameter), 114 | ) 115 | } 116 | 117 | return [fileText, keywordParameters.size] 118 | } 119 | -------------------------------------------------------------------------------- /server/src/postgres/queries/queryTypeDefinitions.ts: -------------------------------------------------------------------------------- 1 | import dedent from "ts-dedent/dist" 2 | import { Logger } from "vscode-languageserver" 3 | 4 | import { PostgresPool } from "@/postgres" 5 | import { makeSchemas } from "@/utilities/schema" 6 | 7 | export interface TypeDefinition { 8 | schema: string 9 | typeName: string 10 | fields: { 11 | columnName: string, 12 | dataType: string, 13 | }[] 14 | } 15 | 16 | export async function queryTypeDefinitions( 17 | pgPool: PostgresPool, 18 | schema: string | undefined, 19 | typeName: string | undefined, 20 | defaultSchema: string, 21 | logger: Logger, 22 | ): Promise { 23 | let definitions: TypeDefinition[] = [] 24 | 25 | const pgClient = await pgPool.connect() 26 | try { 27 | // https://stackoverflow.com/questions/3660787/how-to-list-custom-types-using-postgres-information-schema 28 | const results = await pgClient.query( 29 | ` 30 | WITH t_types AS ( 31 | SELECT 32 | t.typrelid, 33 | n.nspname AS schema, 34 | pg_catalog.format_type(t.oid, NULL) AS type_name 35 | FROM 36 | pg_catalog.pg_type t 37 | JOIN pg_catalog.pg_namespace n ON 38 | n.oid = t.typnamespace 39 | WHERE ( 40 | t.typrelid = 0 41 | OR ( 42 | SELECT 43 | c.relkind = 'c' 44 | FROM 45 | pg_catalog.pg_class c 46 | WHERE 47 | c.oid = t.typrelid 48 | ) 49 | ) 50 | AND NOT EXISTS ( 51 | SELECT 52 | 1 53 | FROM 54 | pg_catalog.pg_type el 55 | WHERE 56 | el.oid = t.typelem 57 | AND el.typarray = t.oid 58 | ) 59 | AND t.typbasetype = 0 60 | AND n.nspname <> 'information_schema' 61 | AND n.nspname !~ '^pg_toast' 62 | AND n.nspname::text = ANY($1) 63 | AND ($2::text IS NULL OR pg_catalog.format_type(t.oid, NULL) = $2::text) 64 | ) 65 | SELECT 66 | t_types.schema, 67 | t_types.type_name, 68 | COALESCE( 69 | json_agg( 70 | json_build_object( 71 | 'columnName', t_attributes.attname::text, 72 | 'dataType', pg_catalog.format_type( 73 | t_attributes.atttypid, 74 | t_attributes.atttypmod 75 | ) 76 | ) 77 | ORDER BY 78 | t_attributes.attnum 79 | ) 80 | FILTER (WHERE t_attributes.attname::text IS NOT NULL), 81 | '[]' 82 | ) AS fields 83 | FROM 84 | t_types 85 | LEFT OUTER JOIN pg_catalog.pg_attribute AS t_attributes ON 86 | t_attributes.attrelid = t_types.typrelid 87 | AND t_attributes.attnum > 0 88 | AND NOT t_attributes.attisdropped 89 | GROUP BY 90 | t_types.schema, 91 | t_types.type_name 92 | ORDER BY 93 | t_types.schema, 94 | t_types.type_name 95 | `, 96 | [makeSchemas(schema, defaultSchema), typeName?.toLowerCase()], 97 | ) 98 | 99 | definitions = results.rows.map( 100 | (row) => ({ 101 | schema: row.schema, 102 | typeName: row.type_name, 103 | fields: row["fields"] as { 104 | columnName: string, dataType: string 105 | }[], 106 | }), 107 | ) 108 | } 109 | catch (error: unknown) { 110 | logger.error(`${(error as Error).message}`) 111 | } 112 | finally { 113 | pgClient.release() 114 | } 115 | 116 | return definitions 117 | } 118 | 119 | export function makeTypeDefinitionText(definition: TypeDefinition): string { 120 | const { 121 | schema, typeName, fields, 122 | } = definition 123 | 124 | if (fields.length === 0) { 125 | return `TYPE ${schema}.${typeName}()` 126 | } 127 | else { 128 | const typeFields = fields.map( 129 | ({ columnName, dataType }) => `${columnName} ${dataType}`, 130 | ) 131 | 132 | return dedent` 133 | TYPE ${schema}.${typeName}( 134 | ${typeFields.join(",\n")} 135 | ) 136 | ` 137 | } 138 | } 139 | --------------------------------------------------------------------------------