Fix UNLOGGED handling for partitioned tables and their partitions. #9057, #9836 (#10538)

PostgreSQL 18 rejects UNLOGGED on a partitioned table, in both CREATE
TABLE and ALTER TABLE ... SET UNLOGGED/LOGGED, so on PG18 and later
the Unlogged switch is now disabled for partitioned tables (including
a partitioned partition) and the generated CREATE, ALTER and reverse
engineered SQL no longer include it. Leaf partitions can still be
unlogged, and older servers are unchanged.

The Partitions SQL section of a partitioned table's script also now
keeps UNLOGGED for partitions that are unlogged, which it previously
dropped.
This commit is contained in:
Dave Page committed 2026-10-13 04:03:21 +01:00
1 parent 6a90e4f837
commit 3ec0aafa36
12 files changed
+365 -1

No files matched your search

@@ -961,6 +961,8 @@ class TableView(BaseTableView, DataTypeReader, SchemaDiffTableCompare):
partitions_sql = ''
if self.is_table_partitioned(data):
data['relkind'] = 'p'
if self._unlogged_unsupported(data, self.manager.version):
data['relpersistence'] = False
# create partition scheme
data['partition_scheme'] = self.get_partition_scheme(data)
partitions_sql = self.get_partitions_sql(data)
@@ -141,6 +141,10 @@ export default class PartitionTableSchema extends BaseUISchema {
return false;
}
isUnloggedUnsupported(state) {
return Boolean(state.is_partitioned) && this.getServerVersion() >= 180000;
}
isPartitioned(state) {
if(state.is_partitioned) {
return true;
@@ -260,7 +264,16 @@ export default class PartitionTableSchema extends BaseUISchema {
},{
id: 'relpersistence', label: gettext('Unlogged?'), cell: 'switch',
type: 'switch', mode: ['properties', 'create', 'edit'],
disabled: obj.inSchemaWithModelCheck,
deps: ['is_partitioned'],
// PostgreSQL 18 and later do not allow a partitioned table to be
// unlogged, and that includes a partition that is itself partitioned.
disabled: (state) => obj.inSchemaWithModelCheck(state) ||
obj.isUnloggedUnsupported(state),
depChange: (state) => {
if (obj.isUnloggedUnsupported(state) && state.relpersistence) {
return {relpersistence: false};
}
},
group: 'advanced',
},{
id: 'conname', label: gettext('Primary key'), cell: 'text',
@@ -525,6 +525,10 @@ export default class TableSchema extends BaseUISchema {
return this.inCatalog();
}
isUnloggedUnsupported(state) {
return Boolean(state.is_partitioned) && this.getServerVersion() >= 180000;
}
changeColumnOptions(columns) {
let colOptions = (columns||[]).map(
(c) => ({label: c.name, value: c.name, image:'icon-column'})
@@ -917,6 +921,15 @@ export default class TableSchema extends BaseUISchema {
id: 'relpersistence', label: gettext('Unlogged?'), cell: 'switch',
type: 'switch', mode: ['properties', 'create', 'edit'],
readonly: obj.inSchemaWithModelCheck,
deps: ['is_partitioned'],
// PostgreSQL 18 and later do not allow a partitioned table to be
// unlogged (its partitions still can be).
disabled: (state) => obj.isUnloggedUnsupported(state),
depChange: (state) => {
if (obj.isUnloggedUnsupported(state) && state.relpersistence) {
return {relpersistence: false};
}
},
group: 'advanced',
},{
id: 'conname', label: gettext('Primary key'), cell: 'text',
@@ -0,0 +1,24 @@
-- Table: public.unlogged_partitioned_tbl
-- DROP TABLE IF EXISTS public.unlogged_partitioned_tbl;
CREATE TABLE IF NOT EXISTS public.unlogged_partitioned_tbl
(
id integer,
status text COLLATE pg_catalog."default"
) PARTITION BY LIST (status);
ALTER TABLE IF EXISTS public.unlogged_partitioned_tbl
OWNER to <OWNER>;
COMMENT ON TABLE public.unlogged_partitioned_tbl
IS 'unlogged partitioned table updated';
-- Partitions SQL
CREATE UNLOGGED TABLE public.unlogged_partitioned_tbl_p1 PARTITION OF public.unlogged_partitioned_tbl
FOR VALUES IN ('ACTIVE')
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.unlogged_partitioned_tbl_p1
OWNER to <OWNER>;
@@ -0,0 +1,2 @@
COMMENT ON TABLE public.unlogged_partitioned_tbl
IS 'unlogged partitioned table updated';
@@ -0,0 +1,24 @@
-- Table: public.unlogged_partitioned_tbl
-- DROP TABLE IF EXISTS public.unlogged_partitioned_tbl;
CREATE TABLE IF NOT EXISTS public.unlogged_partitioned_tbl
(
id integer,
status text COLLATE pg_catalog."default"
) PARTITION BY LIST (status);
ALTER TABLE IF EXISTS public.unlogged_partitioned_tbl
OWNER to <OWNER>;
COMMENT ON TABLE public.unlogged_partitioned_tbl
IS 'unlogged partitioned table';
-- Partitions SQL
CREATE TABLE public.unlogged_partitioned_tbl_p1 PARTITION OF public.unlogged_partitioned_tbl
FOR VALUES IN ('ACTIVE')
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.unlogged_partitioned_tbl_p1
OWNER to <OWNER>;
@@ -0,0 +1,14 @@
CREATE TABLE public.unlogged_partitioned_tbl
(
id integer,
status text
) PARTITION BY LIST (status);
ALTER TABLE IF EXISTS public.unlogged_partitioned_tbl
OWNER to <OWNER>;
COMMENT ON TABLE public.unlogged_partitioned_tbl
IS 'unlogged partitioned table';
CREATE TABLE public.unlogged_partitioned_tbl_p1 PARTITION OF public.unlogged_partitioned_tbl
FOR VALUES IN ('ACTIVE');
@@ -1676,6 +1676,168 @@
"name": "table_with_patition_$%{}[]()&*^!@\"'`\\/#"
}
},
{
"type": "create",
"name": "Create Table with list partition and Unlogged set (PG18+ ignores it)",
"endpoint": "NODE-table.obj",
"sql_endpoint": "NODE-table.sql_id",
"msql_endpoint": "NODE-table.msql",
"precondition_sql": "SELECT CASE WHEN current_setting('server_version_num')::int >= 180000 THEN 1 ELSE 0 END",
"data": {
"name": "unlogged_partitioned_tbl",
"relowner": "<OWNER>",
"relacl": [],
"description": "unlogged partitioned table",
"coll_inherits": "[]",
"hastoasttable": true,
"toast_autovacuum_enabled": "x",
"autovacuum_enabled": "x",
"primary_key": [],
"partitions": [
{
"is_attach": false,
"partition_name": "unlogged_partitioned_tbl_p1",
"values_in": "'ACTIVE'",
"is_sub_partitioned": false,
"sub_partition_type": "range",
"sub_partition_keys": []
}
],
"partition_type": "list",
"is_partitioned": true,
"schema": "public",
"columns": [
{
"name": "id",
"cltype": "integer",
"attacl": [],
"is_primary_key": false,
"attnotnull": false,
"attlen": null,
"attprecision": null,
"attidentity": "a",
"colconstype": "n",
"attoptions": [],
"seclabels": []
},
{
"name": "status",
"cltype": "text",
"attacl": [],
"is_primary_key": false,
"attnotnull": false,
"attlen": null,
"attprecision": null,
"attidentity": "a",
"colconstype": "n",
"attoptions": [],
"seclabels": []
}
],
"foreign_key": [],
"check_constraint": [],
"unique_constraint": [],
"exclude_constraint": [],
"partition_keys": [
{
"key_type": "column",
"pt_column": "status"
}
],
"vacuum_table": [
{
"name": "autovacuum_analyze_scale_factor"
},
{
"name": "autovacuum_analyze_threshold"
},
{
"name": "autovacuum_freeze_max_age"
},
{
"name": "autovacuum_vacuum_cost_delay"
},
{
"name": "autovacuum_vacuum_cost_limit"
},
{
"name": "autovacuum_vacuum_scale_factor"
},
{
"name": "autovacuum_vacuum_threshold"
},
{
"name": "autovacuum_freeze_min_age"
},
{
"name": "autovacuum_freeze_table_age"
}
],
"vacuum_toast": [
{
"name": "autovacuum_freeze_max_age"
},
{
"name": "autovacuum_vacuum_cost_delay"
},
{
"name": "autovacuum_vacuum_cost_limit"
},
{
"name": "autovacuum_vacuum_scale_factor"
},
{
"name": "autovacuum_vacuum_threshold"
},
{
"name": "autovacuum_freeze_min_age"
},
{
"name": "autovacuum_freeze_table_age"
}
],
"seclabels": [],
"forcerlspolicy": false,
"like_default_value": false,
"like_constraints": false,
"like_indexes": false,
"like_storage": false,
"like_comments": false,
"like_identity": false,
"like_statistics": false,
"like_generated": false,
"like_compression": false,
"autovacuum_custom": false,
"relpersistence": true
},
"store_object_id": true,
"expected_sql_file": "create_unlogged_partitioned_table.sql",
"expected_msql_file": "create_unlogged_partitioned_table_msql.sql"
},
{
"type": "alter",
"name": "Alter partitioned Table to set Unlogged (PG18+ ignores it) with an unlogged partition",
"endpoint": "NODE-table.obj_id",
"sql_endpoint": "NODE-table.sql_id",
"msql_endpoint": "NODE-table.msql_id",
"precondition_sql": "SELECT CASE WHEN current_setting('server_version_num')::int >= 180000 THEN 1 ELSE 0 END",
"pre_scenario_sql": "ALTER TABLE public.unlogged_partitioned_tbl_p1 SET UNLOGGED;",
"data": {
"relpersistence": true,
"description": "unlogged partitioned table updated"
},
"expected_sql_file": "alter_unlogged_partitioned_table.sql",
"expected_msql_file": "alter_unlogged_partitioned_table_msql.sql"
},
{
"type": "delete",
"name": "Delete Table with list partition and Unlogged set",
"endpoint": "NODE-table.obj_id",
"precondition_sql": "SELECT CASE WHEN current_setting('server_version_num')::int >= 180000 THEN 1 ELSE 0 END",
"data": {
"name": "unlogged_partitioned_tbl"
}
},
{
"type": "create",
"name": "Create Table with range partition",
@@ -0,0 +1,43 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""Unit tests for BaseTableView._unlogged_unsupported(), which stops
UNLOGGED being generated for a partitioned table on PostgreSQL 18 and
later, where the server rejects it (#9057).
"""
from pgadmin.browser.server_groups.servers.databases.schemas.tables.utils \
import BaseTableView
from pgadmin.utils.route import BaseTestGenerator
class TestUnloggedUnsupported(BaseTestGenerator):
"""Unit tests for BaseTableView._unlogged_unsupported()."""
scenarios = [
('Partitioned table on PostgreSQL 18',
dict(data={'is_partitioned': True}, version=180000,
expected=True)),
('Partitioned table on a later release',
dict(data={'is_partitioned': True}, version=190000,
expected=True)),
('Partitioned table on PostgreSQL 17',
dict(data={'is_partitioned': True}, version=170000,
expected=False)),
('Leaf partition or plain table on PostgreSQL 18',
dict(data={'is_partitioned': False}, version=180000,
expected=False)),
('Table data without is_partitioned on PostgreSQL 18',
dict(data={}, version=180000, expected=False)),
]
def runTest(self):
self.assertEqual(
BaseTableView._unlogged_unsupported(self.data, self.version),
self.expected)
@@ -679,6 +679,16 @@ class BaseTableView(PGChildNodeView, BasePartitionTable, VacuumSettings):
c['cltype'], c['hasSqrBracket'] = \
column_utils.type_formatter(c['cltype'])
@staticmethod
def _unlogged_unsupported(data, server_version):
"""
PostgreSQL 18 and later reject UNLOGGED on a partitioned table,
both in CREATE TABLE and in ALTER TABLE ... SET UNLOGGED/LOGGED,
whereas a leaf partition may still be unlogged.
"""
return server_version >= 180000 and \
bool(data.get('is_partitioned', False))
def _get_resql_for_table(self, did, scid, tid, data, json_resp, main_sql,
add_not_exists_clause=False):
"""
@@ -718,6 +728,9 @@ class BaseTableView(PGChildNodeView, BasePartitionTable, VacuumSettings):
driver.qtIdent(None, acl['grantee'])
if acl['grantee'] != 'PUBLIC' else 'PUBLIC')
if self._unlogged_unsupported(data, self.manager.version):
data['relpersistence'] = False
# if table is partitions then
if 'relispartition' in data and data['relispartition']:
table_sql = \
@@ -929,6 +942,9 @@ class BaseTableView(PGChildNodeView, BasePartitionTable, VacuumSettings):
part_data['name'] = row['name']
part_data['partition_value'] = row['partition_value']
part_data['is_partitioned'] = row['is_partitioned']
part_data['relpersistence'] = \
row['relpersistence'] and not self._unlogged_unsupported(
row, self.manager.version)
part_data['partition_scheme'] = row['partition_scheme']
part_data['description'] = row['description']
part_data['relowner'] = row['relowner']
@@ -1554,6 +1570,8 @@ class BaseTableView(PGChildNodeView, BasePartitionTable, VacuumSettings):
partitions_sql = ''
if 'is_partitioned' in data and data['is_partitioned']:
data['relkind'] = 'p'
if self._unlogged_unsupported(data, self.manager.version):
data['relpersistence'] = False
# create partition scheme
data['partition_scheme'] = self.get_partition_scheme(data)
partitions_sql = self.get_partitions_sql(data)
@@ -1616,6 +1634,9 @@ class BaseTableView(PGChildNodeView, BasePartitionTable, VacuumSettings):
# Update the vacuum toast table settings.
self.update_vacuum_settings('vacuum_toast', old_data, data)
if self._unlogged_unsupported(old_data, self.manager.version):
data.pop('relpersistence', None)
sql = render_template(
"/".join([self.table_template_path, self._UPDATE_SQL]),
o_data=old_data, data=data, conn=self.conn
@@ -84,6 +84,32 @@ describe('PartitionTableSchema', ()=>{
});
});
it('relpersistence on a partitioned partition', () => {
const field = _.find(schemaObj.fields, (f)=>f.id=='relpersistence');
const verSpy = jest.spyOn(schemaObj, 'getServerVersion');
const newSpy = jest.spyOn(schemaObj, 'isNew').mockReturnValue(true);
// PostgreSQL 18 and later reject UNLOGGED on a partitioned table.
verSpy.mockReturnValue(180000);
expect(field.disabled({is_partitioned: true})).toBe(true);
expect(field.depChange({is_partitioned: true, relpersistence: true})).toEqual({
relpersistence: false,
});
expect(field.disabled({is_partitioned: false})).toBe(false);
expect(field.depChange({is_partitioned: false, relpersistence: true})).toBeUndefined();
// Older servers still accept it.
verSpy.mockReturnValue(170000);
expect(field.disabled({is_partitioned: true})).toBe(false);
expect(field.depChange({is_partitioned: true, relpersistence: true})).toBeUndefined();
// An existing partition stays read-only, as before.
newSpy.mockReturnValue(false);
expect(field.disabled({is_partitioned: false})).toBe(true);
verSpy.mockRestore();
newSpy.mockRestore();
});
it('validate', ()=>{
let state = {is_partitioned: true};
let setError = jest.fn();
@@ -304,6 +304,26 @@ describe('TableSchema', () => {
});
});
it('relpersistence on a partitioned table', () => {
const field = _.find(schemaObj.fields, (f)=>f.id=='relpersistence');
const spy = jest.spyOn(schemaObj, 'getServerVersion');
// PostgreSQL 18 and later reject UNLOGGED on a partitioned table.
spy.mockReturnValue(180000);
expect(field.disabled({is_partitioned: true})).toBe(true);
expect(field.depChange({is_partitioned: true, relpersistence: true})).toEqual({
relpersistence: false,
});
expect(field.disabled({is_partitioned: false})).toBe(false);
expect(field.depChange({is_partitioned: false, relpersistence: true})).toBeUndefined();
// Older servers still accept it.
spy.mockReturnValue(170000);
expect(field.disabled({is_partitioned: true})).toBe(false);
expect(field.depChange({is_partitioned: true, relpersistence: true})).toBeUndefined();
spy.mockRestore();
});
it('depChange column rename propagation', () => {
jest.spyOn(schemaObj, 'getServerVersion').mockReturnValue(110000);
schemaObj.constraintsObj.top = schemaObj;