Skip to content

feat(workspace): analyse goose ENVSUB expansions as parameters - #949

Open
psteinroe wants to merge 10 commits into
refactor/migration-tool-adaptersfrom
feat/goose-envsub
Open

psteinroe wants to merge 10 commits into
refactor/migration-tool-adaptersfrom
feat/goose-envsub

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #993.

goose's -- +goose ENVSUB ON replaces ${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:

-- +goose Up
-- +goose ENVSUB ON
create table ${SCHEMA}.events (id bigint, at timestamp);   -- before: syntax error at or near "$"

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 ON and ENVSUB 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:

create table ${SCHEMA}.events (...)   ->   create table $SCHEMA  .events (...)

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 $NAME that 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.

  • Document keeps the content as written (get_document_content), and splits and parses the substituted text.
  • Fixes with an edit inside an expansion are dropped in code actions and in fix_file. Without that, constraintMissingNotValid put NOT VALID inside ${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.
  • The formatter leaves statements with expansions as they are, so it can't write the placeholder back.
  • It applies to any file with the annotation, also without 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 after INTO, JOIN, ON, UPDATE, TRUNCATE, REFERENCES, EXISTS, VIEW, SEQUENCE, EXTENSION, FUNCTION and TRIGGER are 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_file keeping ${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 an ENVSUB region 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:

  • The substitution is MigrationTool::preprocess of the goose adapter (migrations/goose.rs), returning the text with placeholders and their ranges (Preprocessed). Other tools don't preprocess.
  • It only applies with "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_file keeps the tool for its intermediate documents.
  • Since fix(analyser): only offer fixes that keep the SQL valid #906 on main, a fix is only offered if the fixed statement parses as written, which a statement with an expansion doesn't. So statements with an expansion now get no fixes at all, rather than fixes around the expansion; test_goose_envsub_expansions_stay_as_written and the guide say so. The touches_placeholder filter stays as a guard.
  • New tests: test_goose_envsub_needs_the_goose_section_markers (no substitution with dbmate or without markers) and test_goose_envsub_follows_settings_changes (switching to goose re-splits the open document).

Update (review follow-up):

  • Executing a statement with an expansion is refused, and its execute action is disabled with the reason. The action sent the analysed text, so values ('${MESSAGE}') inserted the placeholder. Sending the text as written instead wouldn't help: it isn't what goose runs either.
  • The expansions are found with a port of interpolate's parser (migrations/goose/interpolate.rs). A { in a default is text (${SCHEMA:-app{test}), the unbraced $NAME is an expansion too, and a line that interpolate rejects, like one with ${NAME:?}, stays as written, since goose fails on it.
  • The placeholder replaces the whole identifier that an expansion is part of: ${PREFIX}_events becomes $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: ON only makes the next named parameter an identifier if it names an object (create index ... on :rel, grant ... on :t, create trigger ... on :t). After a JOIN, or in a MERGE, it's the condition, so join customers on :enabled stays 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}'::int is 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::read returns it as Script::preprocessed (templating::Preprocessed, with the expansion parsing in templating/{envsub,interpolate}.rs), and the document splits and parses its text as before. The guards moved with main's code action split: lint fixes and fix_file in features/code_actions/lint.rs, the disabled execute action and PLACEHOLDER_EXECUTION_DISABLED in features/code_actions/execute_statement.rs, the formatter in workspace/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}.events and references ${PARENT} not valid. test_goose_envsub_expansions_stay_as_written, test_no_fix_inside_a_goose_envsub_expansion and the guide say so.

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 psteinroe left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

at this point, we should properly structure the different adapters and cleanup their integration

psteinroe added a commit that referenced this pull request Oct 26, 2026
… 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.
@psteinroe
psteinroe changed the base branch from main to refactor/migration-tool-adapters October 26, 2026 15:05
// Process the next top-level statement
let range = self.ranges.next()?;
let content = &self.parser.content[*range];
let content = &self.parser.text()[*range];

@edjubert edjubert Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread crates/pgls_lexer/src/params.rs Outdated
SyntaxKind::FROM_KW,
SyntaxKind::INTO_KW,
SyntaxKind::JOIN_KW,
SyntaxKind::ON_KW,

@edjubert edjubert Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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())));

@edjubert edjubert Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +152 to +156
match c {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}

@edjubert edjubert Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.
@psteinroe
psteinroe added this pull request to stack #1092 November 1, 2026 09:16
psteinroe added a commit that referenced this pull request Nov 2, 2026
…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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants