Skip to content

feat(transform): extension + role routing (portable installs, symbol qualification, role renaming) - #321

Merged
pyramation merged 1 commit into
mainfrom
feat/extension-role-routing
Jul 30, 2026
Merged

feat(transform): extension + role routing (portable installs, symbol qualification, role renaming)#321
pyramation merged 1 commit into
mainfrom
feat/extension-role-routing

Conversation

@pyramation

Copy link
Copy Markdown
Collaborator

Summary

Adds two new routing dimensions to @pgsql/transform, alongside the existing SchemaRouter, so SQL can be made portable across databases that install extensions into different schemas or use different role names. This is the first transform that changes AST shape — constructing/removing nodes — not just rewriting string fields on existing nodes.

Everything is platform-agnostic (no product/platform names): callers supply the route spec and (for extensions) a symbol inventory.

1. Extension classification (facts.ts)

New StatementKind: 'extension' + ExtensionFact { name, schema, action, ifNotExists? } on StatementFacts, recognizing the three distinct node forms:

CREATE EXTENSION x [WITH] SCHEMA s   -> CreateExtensionStmt      action 'create'
ALTER EXTENSION x SET SCHEMA s       -> AlterObjectSchemaStmt     action 'set_schema'   (objectType OBJECT_EXTENSION)
DROP EXTENSION x                     -> DropStmt                  action 'drop'         (removeType OBJECT_EXTENSION)

2. ExtensionRouter (extension-router.ts)

A bare crypt(...) gives no hint it belongs to pgcrypto, so routing extension symbols needs an inventory of which symbols each extension provides. The inventory is version-aware: a symbol can graduate into core and stop being extension-owned — gen_random_uuid() is pgcrypto's before PG13 and core pg_catalog from 13 on, so it must never be routed on 13+.

router.resolveInstall('pgcrypto')                 // -> target schema | null (bare) | undefined (leave)
router.resolveSymbol('public', 'crypt', 'function') // -> { to: 'extensions' } | undefined
ExtensionRouter.toSchema('extensions')            // move all inventoried exts to one schema
ExtensionRouter.toSchema(null, { from: ['extensions'] }) // reverse: strip qualification
  • A schema of null denotes the unqualified site (bare reference / no SCHEMA clause), making the router fully bidirectional (null -> 'extensions' qualifies, 'extensions' -> null strips).
  • fixedSchema extensions (control-file pinned, non-relocatable) are refused. COMMON_EXTENSIONS ships a curated, conservative starter inventory (pgcrypto, uuid-ossp, citext, pg_trgm, ltree, hstore, unaccent); callers can extend/replace it.

3. Extension transform (extension-transform.ts) — the node-construction part

CREATE EXTENSION pgcrypto;                 -> ... WITH SCHEMA extensions   (constructs a `schema` DefElem, attaches to options)
CREATE EXTENSION pgcrypto WITH SCHEMA public; -> ... SCHEMA extensions      (rewrites existing DefElem)
CREATE EXTENSION pgcrypto WITH SCHEMA extensions; (to:null) -> CREATE EXTENSION pgcrypto;  (removes DefElem)
crypt('x', gen_salt('bf'))                 -> extensions.crypt(..., extensions.gen_salt(...))  (unshift schema node)
email citext                               -> email extensions.citext

Rewrites references in plain statements, LANGUAGE sql bodies, and hydrated PL/pgSQL bodies (mirrors the qualify.ts machinery). Symbols not in the inventory (app.crypt, my_helper()) and core-graduated symbols are left untouched.

4. Role routing (role-router.ts, role-transform.ts)

RoleRouter renames role identifiers only — deliberately not role attributes (BYPASSRLS, LOGIN, ...): two roles sharing a name across conventions don't necessarily share privilege scope, so fabricating attribute changes would misrepresent the security model. A rename is pure substitution, so RoleRouter.invert() gives bidirectional translation.

Most role positions flow through one RoleSpec node (grantees, OWNER TO, CREATE POLICY ... TO, ALTER DEFAULT PRIVILEGES [FOR ROLE] ... TO, ALTER ROLE, DROP ROLE, membership grantees, GRANTED BY, REASSIGN OWNED BY). Four carry a name outside a RoleSpec and are handled explicitly: CREATE ROLE (string), GRANT <role> TO granted roles (AccessPriv.priv_name), SET ROLE/SET SESSION AUTHORIZATION (string const), ALTER ROLE ... RENAME TO. PUBLIC/CURRENT_USER/SESSION_USER (non-CSTRING roletype) are never rewritten.

Testing

219 transform tests pass (160 pre-existing + 59 new across facts, extension-router, extension-transform, role-transform). New tests include parse → deparse → parse round-trip assertions on every transformed form, covering the newly constructed/removed nodes. npm run build (CJS+ESM) + tsc --noEmit clean.

Note: npm run lint fails repo-wide with the pre-existing ESLint 9 vs legacy .eslintrc incompatibility (not introduced here; unchanged from prior merged PRs).

Follow-ups (not in this PR)

  • pg-harness coverage for actual relocatable / non-relocatable install + ALTER EXTENSION ... SET SCHEMA behavior.
  • Downstream wiring into pgpm apply / extension-install manifests once this is published.

Link to Devin session: https://app.devin.ai/sessions/025fb88043964fdbb335ac5e39df2478
Requested by: @pyramation

@pyramation pyramation self-assigned this Jul 30, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pyramation
pyramation merged commit a635ee2 into main Jul 30, 2026
11 checks passed
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.

1 participant