Repository navigation
Conversation
Between -- +goose ENVSUB ON and OFF, goose replaces ${NAME} with environment variables. The analysis now reads each braced expansion as the named parameter $NAME, padded to the same length, so it no longer causes syntax errors. Fixes that would edit inside an expansion are dropped, and the formatter leaves statements with expansions alone. Named parameters after more keywords that name objects, like REFERENCES and INTO, are now identifiers.
psteinroe
left a comment
There was a problem hiding this comment.
at this point, we should properly structure the different adapters and cleanup their integration
… plpgsql leftovers (#924) Stacked on #899. A few section marker mistakes went unreported, and some leftovers from #862/#863 remained. With section markers configured, both of these passed without a diagnostic: ```sql create table users (id bigint); -- before the first marker: dbmate and goose reject the file -- migrate:up ... -- Migrate:Down -- dbmate's markers are case-sensitive: this is a plain comment ``` **What changed** - A new `migrations/sectionMarkers` diagnostic (warning) for: - statements before the first marker. dbmate fails with "dbmate does not support statements preceding the '-- migrate:up' block" ([migration.go](https://github.com/amacneil/dbmate/blob/v2.37.0/pkg/dbmate/migration.go#L75)), and goose with "must start with '-- +goose Up' annotation"; - lines that are markers once trimmed or lowercased, but not as written: dbmate's `-- Migrate:Up` or an indented ` -- migrate:up`, and goose's `-- +Goose Down`. The tools read these as plain comments, so the file silently has fewer sections. goose matches the annotation itself case-insensitively, but not `+goose`. An indented `-- +goose` annotation is different: goose [rejects it](https://github.com/pressly/goose/blob/v3.29.0/internal/sqlparser/parser.go#L321), and the message says so. Both are reported only when `sectionMarkers` is set. The statement ranges come from a `Document::statement_ranges` accessor, the same one #910 adds, so whichever merges second has an identical hunk. A marker inside a dollar-quoted body is still not reported: dbmate splits there too, so our sections match what the tool runs. - plpgsql_check: a query position that isn't a number no longer panics (`expect` on `parse`). The diagnostic falls back to the statement's span like other unlocatable issues. - `TypecheckDiagnosticsMapper` no longer computes the SQL function signature, which #863 left in place unused. - Docs (F15): the rules index marks rules with a fix with 🔧. The linting docs' example of a safe fix now uses `requireConcurrentReindex`, which is in the recommended preset, instead of `CREATE INDEX`, which needs a rule that isn't. They also say how to enable the other fixable rules and that 🛠 rules only run in `migrationsDir`. The migrations guide mentions the new diagnostics. **Tests** Unit tests for the new diagnostics: a statement before the first marker for dbmate and goose, wrong-case and indented dbmate markers, goose `+Goose`, and no diagnostics without markers. A workspace test checks that they're reported through `pull_file_diagnostics`. A plpgsql_check unit test covers a non-numeric query position. The mapper tests are updated. **Not covered** - goose `ENVSUB` (F19). - Single-quoted SQL function bodies with `''` still can't be located (F20). That needs a different way to find the body in the source than `find`. **Follow-ups:** goose `ENVSUB` is handled by #949. Single-quoted bodies with `''` are located by scanner token in #918 and #941 (on the #888 stack).
The ENVSUB substitution moves into the goose adapter as `MigrationTool::preprocess`, so it only applies to files that the settings run with goose. The document takes the preprocessed text from its migration tool when it's opened, edited, or its settings change. Fixes on statements with an expansion are no longer offered at all, since a fix must now keep the statement parseable as written.
| // Process the next top-level statement | ||
| let range = self.ranges.next()?; | ||
| let content = &self.parser.content[*range]; | ||
| let content = &self.parser.text()[*range]; |
There was a problem hiding this comment.
Reading the preprocessed text here also changes the content returned by ExecuteStatementMapper, which still returns id.content(). With Goose section markers enabled, this statement:
-- +goose Up
-- +goose ENVSUB ON
insert into audit_log(message) values ('${MESSAGE}');is returned to the execution path as insert into audit_log(message) values ('$MESSAGE ');. It parses successfully, and execute_statement forwards that content directly to PostgreSQL once execution is enabled for the connection. This can therefore write synthetic analysis text into the database.
We should disable execution for statements containing expansions, or keep the original source separate from the analysis text so the execution path cannot send placeholders.
| } else { | ||
| "e".to_string() | ||
| }; | ||
| let placeholder = format!("${name}"); |
There was a problem hiding this comment.
The padding preserves the expansion's total length, but the lexer only sees the two-byte named parameter token in $A . This valid Goose input panics during analysis:
-- +goose Up
-- +goose ENVSUB ON
select ${A}, ${B}, ${C}, ${D}, ${E}, ${F}, ${G}, ${H}, ${I}, ${J};The tenth distinct parameter becomes $10, which cannot fit in the two-byte $J token, triggering named parameter replacement must preserve source length in push_padded_replacement. The original ${J} span has enough space, but the converter cannot use its trailing padding. This exposes an existing named-parameter width limitation through the new preprocessing path
| SyntaxKind::FROM_KW, | ||
| SyntaxKind::INTO_KW, | ||
| SyntaxKind::JOIN_KW, | ||
| SyntaxKind::ON_KW, |
There was a problem hiding this comment.
ON also introduces a join condition, so it cannot always classify the next parameter as an identifier:
select * from orders join customers on :enabled;This now becomes ... on enabled instead of ... on $1, and has_identifier_parameters is set to true. That flag disables typechecking for the entire statement. With an empty catalog, I reproduced the loss of both unknownRelation diagnostics that the value-parameter interpretation produces. This affects existing named-parameter SQL even without Goose.
We should make this classification depend on the surrounding SQL construct, keeping the identifier behavior for cases such as CREATE INDEX ... ON <relation> while preserving value parameters in join conditions.
| "e".to_string() | ||
| }; | ||
| let placeholder = format!("${name}"); | ||
| out.push_str(&placeholder); |
There was a problem hiding this comment.
With Goose section markers enabled, a quoted expansion is substituted inside the quotes:
-- +goose Up
-- +goose ENVSUB ON
select * from "${SCHEMA}".events;The analysis text becomes select * from "$SCHEMA ".events;. The lexer treats "$SCHEMA " as an ordinary quoted identifier rather than a named parameter, so has_identifier_parameters remains false. With a catalog available, this produces a false unknownSchema diagnostic for the literal name $SCHEMA . Goose's interpolation library accepts this input and produces "public".events when SCHEMA=public.
We should carry the placeholder-range information into identifier classification so quoted expansions also suppress checks that depend on their unknown names.
| }; | ||
| let placeholder = format!("${name}"); | ||
| out.push_str(&placeholder); | ||
| out.push_str(&" ".repeat(expansion.len().saturating_sub(placeholder.len()))); |
There was a problem hiding this comment.
Inserting whitespace immediately after the placeholder splits a valid interpolated identifier:
-- +goose Up
-- +goose ENVSUB ON
create table ${PREFIX}_events (id bigint);This becomes create table $PREFIX _events ..., leaving two separate identifiers after parameter conversion. The statement still produces a syntax error and receives no linting. Goose's interpolation library accepts it and produces create table app_events (id bigint); when PREFIX=app.
Maybe we could represent the complete composite identifier as a placeholder, placing any padding after the complete token rather than between the expansion and its suffix.
| match c { | ||
| '{' => depth += 1, | ||
| '}' => depth -= 1, | ||
| _ => {} | ||
| } |
There was a problem hiding this comment.
interpolate treats a bare { in a default expression as literal text; only another expansion introduces recursive parsing.
For example, this input is accepted by Goose's interpolation library:
-- +goose Up
-- +goose ENVSUB ON
create schema ${SCHEMA:-app{test};With SCHEMA=public, it produces create schema public;. This scan instead increments the depth for the literal {, fails to find the expansion's end, and leaves ${SCHEMA:-app{test} unchanged, producing a SQL syntax error.
I think this scan should follow interpolate's expression grammar when finding the closing brace, distinguishing literal braces from nested ${...} expansions.
…s a value ON was an identifier context for every named parameter, so the condition of `join customers on :enabled` became an identifier and the statement wasn't typechecked. ON now only names an object (CREATE INDEX, GRANT, CREATE TRIGGER, ...) unless the statement has a JOIN before it or is a MERGE. A value parameter whose index doesn't fit in its text, like a tenth `:a`, panicked. It's now read as an identifier, so the statement still parses and isn't typechecked.
The execute action sent the analysed text of a statement to the database,
so `values ('${MESSAGE}')` inserted the placeholder `$MESSAGE`. Neither
that text nor the text as written is what goose runs, so a statement with
parts that the migration tool replaces is refused, and its execute action
is disabled with the reason.
The expansions are now found with a port of interpolate's parser, which
goose uses: a `{` in a default is text, `${A:?}` is rejected, and the
unbraced `$NAME` is an expansion too. A line that interpolate rejects
stays as written, since goose fails on it.
The placeholder now replaces the whole identifier that an expansion is
part of, like `${PREFIX}_events` or `"${SCHEMA}"`, so these are
identifier parameters rather than a broken identifier or a quoted name.
It's padded with `_` instead of spaces, so the parameter token has the
length of the expansion, and room for its index: the tenth expansion
panicked. Expansions in string literals and comments stay as written, and
are still treated as placeholders.
…eters (#1037) Since #906, `Analyser::analyse` only keeps a fix if the fixed statement still parses. The check parsed the statement as written. The linter itself first converts placeholders to parameters (`pgls_lexer::convert_to_positional_params`, which the workspace runs before parsing each statement). So a statement with a psql variable never passed the check, and lost every fix: ```sql create index users_tenant_idx on :"schema".users (email) where tenant = :'tenant'; -- before: requireConcurrentIndexCreation is reported, but has no fix -- after: create index concurrently users_tenant_idx on :"schema".users (email) where tenant = :'tenant'; ``` `keeps_statement_valid` now parses the fixed statement with its placeholders converted, the same way the statement was parsed. The conversion keeps the text length, so the edits apply unchanged. The placeholders stay as written in the fixed file, because the fix is applied to the source, not to the converted text. `pgls_lexer` moves from a dev-dependency of `pgls_analyser` to a normal dependency. It depends only on the tokenizer and the text crates, so there's no cycle, and the WASM build already pulls it in through the workspace. The spec harness (`rules_tests.rs`) now parses statements the way the workspace does: placeholders converted, with `with_identifier_parameters` set. Its "a fix must keep the input valid" assertion uses the same conversion, so specs can contain psql variables. No existing snapshot changes. ## Tests - Unit `fixes_of_statements_with_placeholders_are_checked_with_parameters`: `:name`, `:'name'`, `$name` and `:"name"`. A valid fix is kept and a broken one is still dropped. - Specs `requireConcurrentIndexCreation/fix_psql_variables` (value placeholders in a `WHERE`) and `constraintMissingNotValid/fix_psql_variables` (an identifier placeholder as the schema). Both now show the fix. - Workspace `test_fix_file_fixes_statements_with_psql_variables`: `fix_file` adds `CONCURRENTLY` and keeps `:"schema"`, `:'tenant'` and `$plan` as written. - All three fail with the old check (verified by reverting the one-line change). - `cargo test -p pgls_analyser -p pgls_workspace` (including the Postgres regression test), `cargo clippy -p pgls_analyser -p pgls_workspace --all-targets -- -D warnings`. ## Not covered - goose `${...}` expansions only exist with #949, which isn't merged, so there's no test for them here. With #949, the document already replaces an expansion with a `$NAME` placeholder before parsing, so this change would let those statements get fixes again. #949 also drops fixes that edit inside an expansion, and its `test_goose_envsub_expansions_stay_as_written` expects no fixes at all. Whichever PR merges second needs to update that test and the line in the migrations guide. - Fixes with edits after the end of their statement are still not checked (L1). **Update:** merged main after #1044, which replaced `keeps_statement_valid` with `keeps_sql_valid`. Both of its paths now parse with placeholders converted, through a small `parses` helper: the fix applied to the statement, and for a fix that edits after its statement, the file text with and without the fix. Before, a file with a placeholder never passed the "parses without the fix" guard, so such fixes were kept unchecked. New tests: unit `fixes_after_statements_with_placeholders_are_checked_with_parameters`, and spec `banUncommittedTransaction/no_semicolon_at_the_end_psql_variables` (the invalid `commit;` fix is dropped; without the conversion in the guard, the harness fails with "produces invalid SQL"). This also covers the L1 point under "Not covered".
# Conflicts: # crates/pgls_workspace/src/migrations.rs # crates/pgls_workspace/src/migrations/goose.rs # crates/pgls_workspace/src/workspace/server/document.rs
# Conflicts: # crates/pgls_migrations/src/tools/goose.rs # crates/pgls_workspace/src/migrations.rs # crates/pgls_workspace/src/workspace/server.rs # crates/pgls_workspace/src/workspace/server.tests.rs # crates/pgls_workspace/src/workspace/server/document.rs
Stacked on #993.
goose's
-- +goose ENVSUB ONreplaces${NAME}with environment variables before it runs the SQL (parser.go, using interpolate). We parsed the expansions as written, so every statement with one was a syntax error and got no linting:Behaviour
Real substitution doesn't fit a linter: the environment of the editor or CI isn't the one goose runs in, and a missing variable becomes an empty string. Instead, between
ENVSUB ONandENVSUB OFF, each braced expansion (${NAME},${NAME:-default},${NAME:?}, … with nested braces) is read as the named parameter$NAME, padded with spaces to the same length:Named parameters already get the psql-variable treatment (
:name,@name,$name): they are identifiers where an object name is expected and value parameters elsewhere, and statements with identifier parameters aren't typechecked. The unbraced$NAMEthat interpolate also expands is a named parameter already. The escapes\$,$$and\\are left as written, as is$(. The text keeps its length, so diagnostics, fixes and LSP positions need no offset mapping.Documentkeeps the content as written (get_document_content), and splits and parses the substituted text.fix_file. Without that,constraintMissingNotValidputNOT VALIDinside${PARENT}, once per pass, because the end of the constraint was the padded placeholder. There's a test for that. Fixes elsewhere in the statement still apply, and the expansion stays as written.sectionMarkers: "goose". The annotation is specific enough.To avoid new syntax errors, I had to extend the identifier heuristic of the named-parameter conversion in
pgls_lexer: parameters afterINTO,JOIN,ON,UPDATE,TRUNCATE,REFERENCES,EXISTS,VIEW,SEQUENCE,EXTENSION,FUNCTIONandTRIGGERare now identifiers.references ${PARENT}was converted to a value parameter, which doesn't parse. This also helps psql variables, e.g.insert into :items.Tests
Unit tests for the substitution (ON/OFF regions, nested defaults, escapes, offsets, no annotation). Workspace tests: no syntax error between ON and OFF but one after OFF, with the rules still running;
fix_filekeeping${SCHEMA}while fixing around it; no fix inside an expansion; the formatter leaving the statement unchanged. A lexer test covers the new identifier contexts. The migrations guide describes the behaviour.Not covered
$$inside anENVSUBregion becomes$for goose, so dollar-quoted bodies break there in goose itself. We still read them as dollar quotes.Update: Stacked on #993, which moves the migration tools into one adapter each (
pgls_workspace::migrations) and lets the document take its tool from the settings. This PR now builds on that:MigrationTool::preprocessof the goose adapter (migrations/goose.rs), returning the text with placeholders and their ranges (Preprocessed). Other tools don't preprocess."sectionMarkers": "goose", no longer to any file with the annotation. The document doesn't run it unconditionally anymore: it preprocesses with the tool from the settings of its file when it's opened or edited, and again when the settings change.fix_filekeeps the tool for its intermediate documents.test_goose_envsub_expansions_stay_as_writtenand the guide say so. Thetouches_placeholderfilter stays as a guard.test_goose_envsub_needs_the_goose_section_markers(no substitution with dbmate or without markers) andtest_goose_envsub_follows_settings_changes(switching to goose re-splits the open document).Update (review follow-up):
values ('${MESSAGE}')inserted the placeholder. Sending the text as written instead wouldn't help: it isn't what goose runs either.migrations/goose/interpolate.rs). A{in a default is text (${SCHEMA:-app{test}), the unbraced$NAMEis an expansion too, and a line that interpolate rejects, like one with${NAME:?}, stays as written, since goose fails on it.${PREFIX}_eventsbecomes$PREFIX_events__and"${SCHEMA}"becomes$SCHEMA____, both identifier parameters. It's padded with_rather than spaces, so the token has room for its index (the tenth expansion panicked). Expansions in string literals and comments stay as written, but still count as expansions for fixes, formatting and execution.pgls_lexer:ONonly makes the next named parameter an identifier if it names an object (create index ... on :rel,grant ... on :t,create trigger ... on :t). After aJOIN, or in aMERGE, it's the condition, sojoin customers on :enabledstays a value parameter and is typechecked. A value parameter whose index doesn't fit in its text, like a tenth:a, is now read as an identifier instead of panicking.Not covered: an expansion in a string literal is analysed as written, so
'${N}'::intis still reported as an invalid integer, as before this PR. Fixing that needs a way to skip the typecheck of a statement without tainting the catalog.Update (redesign): Merged #993's redesign and main. The substitution moved with goose into
pgls_migrations:Goose::readreturns it asScript::preprocessed(templating::Preprocessed, with the expansion parsing intemplating/{envsub,interpolate}.rs), and the document splits and parses its text as before. The guards moved with main's code action split: lint fixes andfix_fileinfeatures/code_actions/lint.rs, the disabled execute action andPLACEHOLDER_EXECUTION_DISABLEDinfeatures/code_actions/execute_statement.rs, the formatter inworkspace/server/formatting.rs.Behaviour change from main's #1037 (fixes are checked with parameters converted): statements with an expansion get fixes again, as long as no edit touches an expansion, e.g.
create table if not exists ${SCHEMA}.eventsandreferences ${PARENT} not valid.test_goose_envsub_expansions_stay_as_written,test_no_fix_inside_a_goose_envsub_expansionand the guide say so.