Skip to content

feat(types): create and edit PostgreSQL enums, composites and domains - #3736

Open
datlechin wants to merge 3 commits into
mainfrom
feat/3606-postgres-type-editing
Open

datlechin wants to merge 3 commits into
mainfrom
feat/3606-postgres-type-editing

Conversation

@datlechin

@datlechin datlechin commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Summary

  • A type's tab edits the type: General (name, comment, a domain's default and Not Null), Labels, Fields or Constraints, and Definition. Changes queue in the tab, each row marked New, Was "…" or Dropped, with Preview SQL (Cmd+Shift+P) and Save Changes (Cmd+S, toolbar commit control), through Safe Mode.
  • Enum: add a label at the end or before/after one, rename one (PostgreSQL 10+). The tab says a label cannot be removed or moved. Domain: default, NOT NULL, add/drop a check, add one NOT VALID and Validate it. Composite: add, drop, rename a field, change its type.
  • A save runs on the schema-change connection, in one transaction unless the driver says a statement cannot run in one (ADD VALUE before PostgreSQL 12). The server's message shows under the change it refused; changes that stood leave the queue.
  • Types section: New Type… sheet for an enum, composite or domain, showing the statement. Type row: Rename in place, Drop Type… with Cascade (one confirmation, the gate does not ask again).
  • Saving refreshes the Types section, the type picker and the table tabs on that database in every window. Close, tab move and window close treat a queue like staged structure edits.
  • PluginKit (additive, kit stays 40): PluginUserDefinedTypeInfo carries comment, default, NOT NULL and constraints; PluginUserDefinedTypeChange, PluginUserDefinedTypeDefinition; alterUserDefinedTypeStatements, createUserDefinedTypeStatements, generateDropTypeSQL(type:cascade:), userDefinedTypeChangeRunsOutsideTransaction.
  • PostgreSQL: PostgreSQLTypeStatements builds every statement; the listing reads convalidated.
  • Fixed: a domain with a NOT VALID check showed a CREATE DOMAIN … NOT VALID definition that does not parse; it now follows as ALTER DOMAIN … ADD CONSTRAINT … NOT VALID.
  • Fixed: reopening an object after its comment changed opened a second tab; tabs now match by name, schema and signature, with the driver identity only as an extra check.
  • Tests changed on purpose: DatabaseTreeMenuSpecTests (Types sections offered only Refresh), PostgreSQLTypeQueryTests.projectionOrderMatchesParser (new constraint_validated column), UserTypeDroppingTests (adapter takes cascade:).

Test plan

  • New: PostgreSQLTypeStatementsTests, UserTypeDraftTests, UserTypeChangeRunnerTests, UserTypeEditorModelTests, UserTypeTabStateTests, DatabaseObjectRefIdentityTests, plus cases in PostgreSQLTypeDefinitionTests, DatabaseTreeMenuSpecTests, ToolbarContextResolverTests, TriggerStructTests, UserTypeDroppingTests.
  • apple/scripts/probes/check-postgres-type-editing.sh against PostgreSQL 11, 13, 14, 15, 16, 17 and 18: every create, edit, refusal, rename and drop lands as the catalog reports it, and every shown definition rebuilds the same type.
  • verify.sh build, test, plugins, abi (additive, kit 40), ios, lint, l10n.

Offscreen renders of the tab with a queue (the hosting view, no window chrome):

Labels with a queued rename, a new label and a refused placement
Fields with a dropped, a retyped and a new field
Domain checks with a new check beside an unvalidated one

Fixes #3606

@datlechin
datlechin enabled auto-merge (squash) October 21, 2026 02:45

@J2TeamNNL J2TeamNNL left a comment

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.

Automated code review: 7 findings, inline.

\(rangeMultirange) AS range_multirange,
pg_catalog.quote_ident(n.nspname) || '.' || pg_catalog.quote_ident(t.typname) AS spelling
pg_catalog.quote_ident(n.nspname) || '.' || pg_catalog.quote_ident(t.typname) AS spelling,
\(domainCheckArray("con.convalidated::text")) AS constraint_validated

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.

con.convalidated is selected unconditionally, but that pg_constraint column only exists from PostgreSQL 9.2. On PG 9.1 or older domainCheckArray("con.convalidated::text") raises "column does not exist", so every type listing and fetchUserDefinedType fails and Types vanishes from the sidebar. Gate it with capabilities.hasDomainConstraintValidation and select NULL::text otherwise.

) async throws {
guard let statements = try await statements(for: definition, in: scope), !statements.isEmpty else {
throw DatabaseObjectCommandError.unsupported
}

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.

Create runs CREATE ... and COMMENT ON ... as separate statements. On a non-transactional route, a failing COMMENT (e.g. no permission) leaves the type created while the call throws. The sheet stays open, and pressing Create again fails with "already exists".

static func build(
type: UserDefinedTypeInfo,
changes: [StagedTypeChange],
driver: DatabaseDriver

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.

UserTypeChangePlan.build relies on the editor keeping a rename last. Every step addresses the type by its old name, so any caller building .rename followed by another change emits statements against a name that no longer exists. Enforce or sort the rename last in build.

}

func remove(_ changeId: UUID) {
changes.removeAll { $0.id == changeId }

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.

restoresServerValue compares a rename without trimming, and UserTypeDraft.nonEmpty returns the untrimmed value. " orders_t" passes validation and runs RENAME TO " orders_t", creating a leading-space identifier. Trim before comparing and queuing.

var next = changes
if let property = staged.property {
for replaced in next where replaced.property == property {
serverErrors.removeValue(forKey: replaced.id)

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.

When a property edit restores the server value, stage() returns early without clearing saveSummary, so a previous partial-failure banner stays visible next to the shrunken queue.

let result = try await DatabaseManager.shared.withScopedDriver(
scope: scope,
route: route,
cancellation: .protectedWrite

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.

History records every statement in ran as successful, including statements of a step that failed partway. applied excludes that step, so it stays queued while the server holds half of it (non-transactional route), and replaying can hit duplicate conflicts.

func generateRenameEnumLabelSQL(type: PluginUserDefinedTypeInfo, from oldLabel: String, to newLabel: String) -> String?
func generateDropTypeSQL(type: PluginUserDefinedTypeInfo) -> String?

/// The statements that create a type. Nil for a kind the engine cannot create from a statement,

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.

Four new requirements are added to the public PluginDatabaseDriver protocol and PluginUserDefinedTypeChange crosses the plugin boundary, with no PluginKit version bump in the diff. If the version check is how incompatible third-party drivers are rejected, a bump may be needed. Not confirmed.

@J2TeamNNL

Copy link
Copy Markdown
Contributor

Follow-up to the review, fixes on a fork branch (swiftlint could not run locally, no build was done):

  • convalidated on PG 9.1: fixed, gated by hasDomainConstraintValidation. J2TeamNNL@ba2ff1b
  • rename not last in plan: fixed, build sorts the rename last. Same commit.
  • padded rename accepted: fixed, the draft now rejects a name with leading or trailing space. Same commit.
  • stale saveSummary: fixed. Same commit.
  • Create plus COMMENT on a non-transactional route: skipped, rare edge and fixing it needs a runner change beyond this PR.
  • History records a failed step's earlier statements: skipped, history should show what actually ran on the server.
  • PluginKit version: skipped, TABLEPRO_PLUGINKIT_VERSION is already 40, one past v0.83.0-beta.1 (39).

To take the fix: git fetch https://github.com/J2TeamNNL/TablePro review-fix/3736 && git cherry-pick ba2ff1b9887ac18eaa042d1d0519e140612bde49

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.

Create and edit PostgreSQL enums, domains and composite types

2 participants