diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/tests/test_parse_nextval_sequence_unit.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/tests/test_parse_nextval_sequence_unit.py new file mode 100644 index 00000000000..87754450457 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/tests/test_parse_nextval_sequence_unit.py @@ -0,0 +1,60 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Unit tests for parse_nextval_sequence(), covering the schema-qualified +identifier it extracts out of a column's ``nextval(...)`` default, and in +particular the SQL string-literal quote-doubling PostgreSQL applies when +the sequence name itself contains a single quote (#10318). +""" + +from pgadmin.browser.server_groups.servers.databases.schemas.tables.\ + columns.utils import parse_nextval_sequence +from pgadmin.utils.route import BaseTestGenerator + + +class TestParseNextvalSequence(BaseTestGenerator): + """Unit tests for parse_nextval_sequence().""" + + scenarios = [ + ('No default value returns None', + dict(test_method='test_none_defval')), + ('A non-nextval default returns None', + dict(test_method='test_non_nextval_defval')), + ('A plain schema-qualified sequence name is extracted verbatim', + dict(test_method='test_plain_sequence_name')), + ('A sequence name containing a single quote has the doubled ' + 'quote decoded back to one', + dict(test_method='test_quoted_sequence_name_with_embedded_quote')), + ] + + def runTest(self): + getattr(self, self.test_method)() + + def test_none_defval(self): + self.assertIsNone(parse_nextval_sequence(None)) + + def test_non_nextval_defval(self): + self.assertIsNone(parse_nextval_sequence('1')) + + def test_plain_sequence_name(self): + seq_name = parse_nextval_sequence( + "nextval('public.t_id_seq'::regclass)") + self.assertEqual(seq_name, 'public.t_id_seq') + + def test_quoted_sequence_name_with_embedded_quote(self): + # PostgreSQL renders the sequence "id'seq" as the double-quoted + # identifier "id'seq", and then - because the whole thing is the + # argument of a string literal - doubles the embedded single + # quote: nextval('public."id''seq"'::regclass). The extracted + # identifier must have that doubling undone, since it is spliced + # verbatim into CREATE SEQUENCE / ALTER SEQUENCE DDL rather than + # back into a string literal. + seq_name = parse_nextval_sequence( + 'nextval(\'public."id\'\'seq"\'::regclass)') + self.assertEqual(seq_name, 'public."id\'seq"') diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/utils.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/utils.py index 35db1bbb1f2..9fc809d83e7 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/utils.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/utils.py @@ -269,6 +269,14 @@ def reproject_serial_column(col): with, so that callers can emit round-trippable DDL. Columns that are not SERIAL, including ones already reprojected, are left untouched. + The real ``nextval(...)`` expression is kept alongside the emptied + ``defval`` (under ``serial_defval``) rather than discarded, because + Schema Diff needs it back verbatim whenever it finds this column + genuinely differs in "serialness" from its counterpart: converting a + column to or from SERIAL means creating or dropping the sequence + behind it, which the pseudo-type's own implied default can't drive by + itself (#10292). + :param col: Column properties, modified in place :return: The same column """ @@ -280,11 +288,42 @@ def reproject_serial_column(col): col['displaytypname'] = serial_type col['cltype'] = serial_type col['typname'] = serial_type + col['serial_defval'] = col['defval'] col['defval'] = '' return col +def parse_nextval_sequence(defval): + """ + Extract the schema-qualified sequence name out of a ``nextval(...)`` + column default expression, e.g. ``nextval('public.t_id_seq'::regclass)`` + yields ``public.t_id_seq``. The identifier is returned exactly as + PostgreSQL would render it as a bare identifier (already quoted if it + needs to be), so callers should use it verbatim rather than + re-quoting it. + + PostgreSQL renders the argument to ``::regclass`` as a string literal, + so any single quote that is part of the identifier itself (e.g. a + sequence named ``id'seq``, which the server prints as the + double-quoted identifier ``"id'seq"``) is doubled per standard SQL + string-literal escaping: ``nextval('public."id''seq"'::regclass)``. + That doubling has to be undone before the extracted text is usable + outside of a string literal, i.e. spliced directly into + ``CREATE SEQUENCE``/``ALTER SEQUENCE`` DDL, or the doubled quote would + be read back as two literal characters instead of one (#10318). + + :param defval: A column's default value expression, or None + :return: The schema-qualified sequence name, or None if it is not a + nextval() default + """ + if not defval: + return None + + match = re.match(r"nextval\('(.+)'::regclass\)$", defval) + return match.group(1).replace("''", "'") if match else None + + @get_template_path def get_formatted_columns(conn, tid, data, other_columns, table_or_type, template_path=None, diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/16_plus/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/16_plus/update.sql index 102c1429d63..affd48944e0 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/16_plus/update.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/16_plus/update.sql @@ -20,6 +20,26 @@ ALTER TABLE IF EXISTS {{conn|qtIdent(data.schema, data.table)}} {% if data.col_type_conversion is defined and data.col_type_conversion == False %} -- {% endif %} ALTER COLUMN {% if data.name %}{{conn|qtTypeIdent(data.name)}}{% else %}{{conn|qtTypeIdent(o_data.name)}}{% endif %} TYPE {{ GET_TYPE.UPDATE_TYPE_SQL(conn, data, o_data) }}{% if data.collspcname and data.collspcname != o_data.collspcname and data.cltype != '"char"' %} COLLATE {{data.collspcname}}{% elif o_data.collspcname and data.cltype != '"char"' %} COLLATE {{o_data.collspcname}}{% endif %}; {% endif %} +{### Create the sequence a column becoming SERIAL needs, before its default below can reference it (#10292). IF NOT EXISTS is deliberately not used here: it would silently skip an existing, unrelated relation of the same name (without checking it is even a sequence), and the unconditional ALTER SEQUENCE ... OWNED BY below would then reassign ownership of that unrelated object instead of failing loudly (#10318). ###} +{% if data.serial_seq_create is defined %} +CREATE SEQUENCE {{data.serial_seq_create.name}}{% if data.serial_seq_create.cycled %} + + CYCLE{% endif %}{% if data.serial_seq_create.increment is not none %} + + INCREMENT {{data.serial_seq_create.increment|int}}{% endif %}{% if data.serial_seq_create.start is not none %} + + START {{data.serial_seq_create.start|int}}{% endif %}{% if data.serial_seq_create.minimum is not none %} + + MINVALUE {{data.serial_seq_create.minimum|int}}{% endif %}{% if data.serial_seq_create.maximum is not none %} + + MAXVALUE {{data.serial_seq_create.maximum|int}}{% endif %}{% if data.serial_seq_create.cache is not none %} + + CACHE {{data.serial_seq_create.cache|int}}{% endif %}; + +ALTER SEQUENCE {{data.serial_seq_create.name}} + OWNED BY {{conn|qtIdent(data.schema)}}.{{conn|qtIdent(data.table)}}.{% if data.name %}{{conn|qtIdent(data.name)}}{% else %}{{conn|qtIdent(o_data.name)}}{% endif %}; + +{% endif %} {### Alter column default value ###} {% if is_view_only and data.defval is defined and data.defval is not none and data.defval != '' and data.defval != o_data.defval %} ALTER VIEW {{conn|qtIdent(data.schema, data.table)}} @@ -35,6 +55,11 @@ ALTER TABLE IF EXISTS {{conn|qtIdent(data.schema, data.table)}} ALTER TABLE IF EXISTS {{conn|qtIdent(data.schema, data.table)}} ALTER COLUMN {% if data.name %}{{conn|qtTypeIdent(data.name)}}{% else %}{{conn|qtTypeIdent(o_data.name)}}{% endif %} DROP DEFAULT; +{% endif %} +{### Drop the now-unused sequence a column stops owning by leaving SERIAL; the DEFAULT above must already be gone, or PostgreSQL refuses to drop a sequence still referenced by it (#10292) ###} +{% if data.serial_seq_drop is defined %} +DROP SEQUENCE IF EXISTS {{data.serial_seq_drop}}; + {% endif %} {### Alter column not null value ###} {% if 'attnotnull' in data and data.attnotnull != o_data.attnotnull %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/default/update.sql b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/default/update.sql index 4722d3dd10a..60b66593246 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/default/update.sql +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/templates/columns/sql/default/update.sql @@ -20,6 +20,26 @@ ALTER TABLE IF EXISTS {{conn|qtIdent(data.schema, data.table)}} {% if data.col_type_conversion is defined and data.col_type_conversion == False %} -- {% endif %} ALTER COLUMN {% if data.name %}{{conn|qtTypeIdent(data.name)}}{% else %}{{conn|qtTypeIdent(o_data.name)}}{% endif %} TYPE {{ GET_TYPE.UPDATE_TYPE_SQL(conn, data, o_data) }}{% if data.collspcname and data.collspcname != o_data.collspcname and data.cltype != '"char"' %} COLLATE {{data.collspcname}}{% elif o_data.collspcname and data.cltype != '"char"' %} COLLATE {{o_data.collspcname}}{% endif %}; {% endif %} +{### Create the sequence a column becoming SERIAL needs, before its default below can reference it (#10292). IF NOT EXISTS is deliberately not used here: it would silently skip an existing, unrelated relation of the same name (without checking it is even a sequence), and the unconditional ALTER SEQUENCE ... OWNED BY below would then reassign ownership of that unrelated object instead of failing loudly (#10318). ###} +{% if data.serial_seq_create is defined %} +CREATE SEQUENCE {{data.serial_seq_create.name}}{% if data.serial_seq_create.cycled %} + + CYCLE{% endif %}{% if data.serial_seq_create.increment is not none %} + + INCREMENT {{data.serial_seq_create.increment|int}}{% endif %}{% if data.serial_seq_create.start is not none %} + + START {{data.serial_seq_create.start|int}}{% endif %}{% if data.serial_seq_create.minimum is not none %} + + MINVALUE {{data.serial_seq_create.minimum|int}}{% endif %}{% if data.serial_seq_create.maximum is not none %} + + MAXVALUE {{data.serial_seq_create.maximum|int}}{% endif %}{% if data.serial_seq_create.cache is not none %} + + CACHE {{data.serial_seq_create.cache|int}}{% endif %}; + +ALTER SEQUENCE {{data.serial_seq_create.name}} + OWNED BY {{conn|qtIdent(data.schema)}}.{{conn|qtIdent(data.table)}}.{% if data.name %}{{conn|qtIdent(data.name)}}{% else %}{{conn|qtIdent(o_data.name)}}{% endif %}; + +{% endif %} {### Alter column default value ###} {% if is_view_only and data.defval is defined and data.defval is not none and data.defval != '' and data.defval != o_data.defval %} ALTER VIEW {{conn|qtIdent(data.schema, data.table)}} @@ -35,6 +55,11 @@ ALTER TABLE IF EXISTS {{conn|qtIdent(data.schema, data.table)}} ALTER TABLE IF EXISTS {{conn|qtIdent(data.schema, data.table)}} ALTER COLUMN {% if data.name %}{{conn|qtTypeIdent(data.name)}}{% else %}{{conn|qtTypeIdent(o_data.name)}}{% endif %} DROP DEFAULT; +{% endif %} +{### Drop the now-unused sequence a column stops owning by leaving SERIAL; the DEFAULT above must already be gone, or PostgreSQL refuses to drop a sequence still referenced by it (#10292) ###} +{% if data.serial_seq_drop is defined %} +DROP SEQUENCE IF EXISTS {{data.serial_seq_drop}}; + {% endif %} {### Alter column not null value ###} {% if 'attnotnull' in data and data.attnotnull != o_data.attnotnull %} diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/tests/test_normalise_serial_column_unit.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/tests/test_normalise_serial_column_unit.py new file mode 100644 index 00000000000..4dfb0f25874 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/tests/test_normalise_serial_column_unit.py @@ -0,0 +1,123 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Unit tests for BaseTableView._normalise_serial_column(), covering both +directions of converting a column between a plain integer type and +SERIAL/BIGSERIAL/SMALLSERIAL (#10292), and guarding against the ordinary +(non Schema Diff) column PUT being mistaken for one. +""" + +from pgadmin.browser.server_groups.servers.databases.schemas.tables.utils \ + import BaseTableView +from pgadmin.utils.route import BaseTestGenerator + + +class TestNormaliseSerialColumn(BaseTestGenerator): + """Unit tests for BaseTableView._normalise_serial_column().""" + + scenarios = [ + ('Converting a plain column to SERIAL creates the sequence and ' + 'restores the default', + dict(test_method='test_becoming_serial')), + ('Converting a SERIAL column to plain queues the sequence for ' + 'dropping', + dict(test_method='test_leaving_serial')), + ('A genuine difference on a column that is SERIAL on both sides ' + 'is unaffected', + dict(test_method='test_both_sides_already_serial')), + ('A partial update that never mentions cltype leaves an ' + 'already-SERIAL column alone', + dict(test_method='test_partial_update_without_cltype_is_ignored')), + ] + + def runTest(self): + getattr(self, self.test_method)() + + def test_becoming_serial(self): + # Schema Diff's source column, reprojected as BIGSERIAL, with the + # real nextval() default preserved under 'serial_defval'. + data = { + 'cltype': 'bigserial', 'typname': 'bigserial', + 'serial_defval': "nextval('public.t_id_seq'::regclass)", + 'seqincrement': 1, 'seqstart': 1, 'seqmin': 1, + 'seqmax': 9223372036854775807, 'seqcache': 1, 'seqcycle': False, + } + # The target's current (plain, unreprojected) column. + old_col_data = { + 'cltype': 'integer', 'typname': 'integer', 'defval': None, + 'seqrelid': None, 'defseqrelid': None, 'attidentity': '', + } + + BaseTableView._normalise_serial_column(data, old_col_data) + + self.assertEqual(data['cltype'], 'bigint') + self.assertEqual(data['typname'], 'bigint') + self.assertEqual(data['defval'], + "nextval('public.t_id_seq'::regclass)") + self.assertEqual(data['serial_seq_create']['name'], 'public.t_id_seq') + self.assertEqual(data['serial_seq_create']['increment'], 1) + self.assertNotIn('serial_defval', data) + self.assertNotIn('seqincrement', data) + + def test_leaving_serial(self): + # Schema Diff's source column: a plain integer, never reprojected. + data = {'cltype': 'integer', 'typname': 'integer', 'defval': None} + # The target's current column genuinely is SERIAL. + old_col_data = { + 'cltype': 'integer', 'typname': 'integer', + 'defval': "nextval('public.t_id_seq'::regclass)", + 'seqrelid': 100, 'defseqrelid': 100, 'attidentity': '', + } + + BaseTableView._normalise_serial_column(data, old_col_data) + + self.assertEqual(data['serial_seq_drop'], 'public.t_id_seq') + # The type didn't really change; the default is still queued to + # be dropped by the generic template logic (data['defval'] stays + # None/empty and differs from o_data['defval']). + self.assertEqual(data['cltype'], 'integer') + + def test_both_sides_already_serial(self): + # Both sides are BIGSERIAL; only some other property (a comment, + # say) differs. The reprojection emptied 'defval' on the source + # side; that must not be read as a request to drop the real one, + # and no sequence should be created or dropped. + data = { + 'cltype': 'bigserial', 'typname': 'bigserial', + 'serial_defval': "nextval('public.t_id_seq'::regclass)", + 'seqincrement': 1, + } + old_col_data = { + 'cltype': 'integer', 'typname': 'integer', + 'defval': "nextval('public.t_id_seq'::regclass)", + 'seqrelid': 100, 'defseqrelid': 100, 'attidentity': '', + } + + BaseTableView._normalise_serial_column(data, old_col_data) + + self.assertNotIn('defval', data) + self.assertNotIn('serial_seq_create', data) + self.assertNotIn('serial_seq_drop', data) + self.assertNotIn('seqincrement', data) + + def test_partial_update_without_cltype_is_ignored(self): + # The ordinary column PUT (not Schema Diff) submits only the + # fields the user actually changed - e.g. a privilege - and omits + # 'cltype' entirely when the type itself wasn't touched, even if + # the column already is SERIAL. This must be a complete no-op. + data = {'attacl': {'added': []}} + old_col_data = { + 'cltype': 'integer', 'typname': 'integer', + 'defval': "nextval('public.t_id_seq'::regclass)", + 'seqrelid': 100, 'defseqrelid': 100, 'attidentity': '', + } + + BaseTableView._normalise_serial_column(data, old_col_data) + + self.assertEqual(data, {'attacl': {'added': []}}) diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/utils.py b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/utils.py index ba9edabcbc3..e4c477d19ea 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/utils.py +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/utils.py @@ -1304,24 +1304,80 @@ def _normalise_serial_column(data, old_col_data): The pseudo-type is shorthand for a declaration rather than a type ALTER COLUMN can be given, so compare and alter the underlying - integer type instead, drop the default the reprojection emptied, - and leave the owned sequence to be compared as the object it is in - its own right. + integer type instead. Three cases follow, distinguished by whether + each side is genuinely SERIAL (owns the sequence its own nextval() + default points at): + + * Both sides are SERIAL: the only differences are things like a + comment or NOT NULL, so drop the default the reprojection + emptied (it is not a request to drop the real one) and leave the + owned sequence to be compared as the object it is in its own + right. + * The column is becoming SERIAL: the sequence behind it does not + exist on the other side yet, so it must be created, and its + owner column given back its real ``nextval()`` default, before + `ALTER COLUMN ... SET DEFAULT` can reference it at all. + * The column is leaving SERIAL: PostgreSQL refuses to drop a + sequence that a column's default still references, so the + now-unused sequence must be dropped only once that default is + gone (#10292). :param data: The changed column, modified in place :param old_col_data: Properties of the column as it stands now """ cltype = data.get('cltype') - if cltype not in column_utils.UNDERLYING_SERIAL_TYPES: + becomes_serial = cltype in column_utils.UNDERLYING_SERIAL_TYPES + was_serial = column_utils.is_serial_column(old_col_data) + + # A column can only be "leaving SERIAL" when this update actually + # says something about its type at all. This function also runs + # for the ordinary (non-diff) column PUT, where a partial update + # that never mentions cltype (e.g. changing only a comment or a + # privilege on an already-SERIAL column) carries no 'cltype' key + # whatsoever, and is not a request to change the type, however + # SERIAL the column already is; unlike Schema Diff's columns, + # which always carry the full properties and so always have one. + leaving_serial = was_serial and not becomes_serial \ + and 'cltype' in data + + if not becomes_serial and not leaving_serial: return - data['cltype'] = column_utils.UNDERLYING_SERIAL_TYPES[cltype] - if data.get('typname') == cltype: - data['typname'] = data['cltype'] - - # The reprojection emptied the nextval() default; that is not a - # request to drop it. - data.pop('defval', None) + serial_defval = None + if becomes_serial: + data['cltype'] = column_utils.UNDERLYING_SERIAL_TYPES[cltype] + if data.get('typname') == cltype: + data['typname'] = data['cltype'] + serial_defval = data.pop('serial_defval', None) + + if becomes_serial and was_serial: + # The reprojection emptied the nextval() default on both + # sides; that is not a request to drop it. + data.pop('defval', None) + elif becomes_serial and not was_serial: + # Genuinely becoming SERIAL: recreate the sequence the + # reprojection's own default was emptied from, and restore + # that default so it can be set once the sequence exists. + data['defval'] = serial_defval or '' + seq_name = column_utils.parse_nextval_sequence(serial_defval) + if seq_name: + data['serial_seq_create'] = { + 'name': seq_name, + 'increment': data.get('seqincrement'), + 'start': data.get('seqstart'), + 'minimum': data.get('seqmin'), + 'maximum': data.get('seqmax'), + 'cache': data.get('seqcache'), + 'cycled': data.get('seqcycle'), + } + elif leaving_serial: + # Genuinely leaving SERIAL: the generic default handling + # below already drops the (empty, non-serial) default, so + # queue the now-unused sequence to be dropped afterwards. + seq_name = column_utils.parse_nextval_sequence( + old_col_data.get('defval')) + if seq_name: + data['serial_seq_drop'] = seq_name # Sequence options ride along with a column because it owns a # sequence, but ALTER COLUMN only accepts them for identity diff --git a/web/pgadmin/tools/schema_diff/tests/test_schema_diff_serial_conversion.py b/web/pgadmin/tools/schema_diff/tests/test_schema_diff_serial_conversion.py new file mode 100644 index 00000000000..ea380037dba --- /dev/null +++ b/web/pgadmin/tools/schema_diff/tests/test_schema_diff_serial_conversion.py @@ -0,0 +1,224 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Schema Diff tests for converting a column between a plain integer type +and SERIAL/BIGSERIAL/SMALLSERIAL (#10292). + +Schema Diff already detects that such a column differs, but the generated +script used to stop halfway: converting a plain column to SERIAL changed +the column's type and (separately) created the owned sequence, without +ever setting the column's DEFAULT to nextval(...), so the column never +actually became usable as a SERIAL. Converting a SERIAL column back to +plain needs its DROP DEFAULT and the sequence's DROP SEQUENCE issued in +that order, since PostgreSQL refuses to drop a sequence that a column's +default still references. +""" + +import json +import secrets +import uuid + +from pgadmin.utils.route import BaseSocketTestGenerator +from regression import parent_node_dict +from regression.python_test_utils import test_utils as utils + +SCHEMA_NAME = 'test_serial_conversion' + +SRC_DDL = """ +CREATE SCHEMA {0}; + +CREATE TABLE {0}.int_to_serial ( + id bigserial NOT NULL, + val text +); + +CREATE TABLE {0}.serial_to_int ( + id integer NOT NULL, + val text +); +""" + +TAR_DDL = """ +CREATE SCHEMA {0}; + +CREATE TABLE {0}.int_to_serial ( + id integer NOT NULL, + val text +); + +CREATE TABLE {0}.serial_to_int ( + id bigserial NOT NULL, + val text +); +""" + + +class SchemaDiffSerialConversionTestCase(BaseSocketTestGenerator): + """ This class tests converting a column between plain integer and + SERIAL in both directions. """ + scenarios = [ + ('Schema diff comparison converting between integer and SERIAL', + dict()) + ] + SOCKET_NAMESPACE = '/schema_diff' + + def setUp(self): + super().setUp() + self.src_database = "db_serial_conv_src_%s" % str(uuid.uuid4())[1:8] + self.tar_database = "db_serial_conv_tar_%s" % str(uuid.uuid4())[1:8] + + self.src_db_id = utils.create_database(self.server, self.src_database) + self.tar_db_id = utils.create_database(self.server, self.tar_database) + + self.server = parent_node_dict["server"][-1]["server"] + self.server_id = parent_node_dict["server"][-1]["server_id"] + + self.execute_sql(self.src_database, SRC_DDL.format(SCHEMA_NAME)) + self.execute_sql(self.tar_database, TAR_DDL.format(SCHEMA_NAME)) + + def execute_sql(self, db_name, sql): + """ + Run a statement batch against one of the test databases. + + :param db_name: Database to run against + :param sql: SQL to execute + """ + connection = utils.get_db_connection(db_name, + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + old_isolation_level = connection.isolation_level + utils.set_isolation_level(connection, 0) + pg_cursor = connection.cursor() + pg_cursor.execute(sql) + utils.set_isolation_level(connection, old_isolation_level) + connection.commit() + connection.close() + + def compare(self): + """ + Compare the two test databases and return the result. + + :return: List of compared objects + """ + data = { + 'trans_id': self.trans_id, + 'source_sid': self.server_id, + 'source_did': self.src_db_id, + 'target_sid': self.server_id, + 'target_did': self.tar_db_id, + 'ignore_owner': 0, + 'ignore_whitespaces': 0, + 'ignore_tablespace': 0, + 'ignore_grants': 0 + } + self.socket_client.emit('compare_database', data, + namespace=self.SOCKET_NAMESPACE) + received = self.socket_client.get_received(self.SOCKET_NAMESPACE) + response_data = received[-1]['args'][0] + self.assertEqual(received[-1]['name'], "compare_database_success", + response_data) + return response_data + + def find_object(self, response_data, node_type, title): + """ + Pick a single compared object out of the comparison result. + + :param response_data: Result of compare() + :param node_type: Node type, e.g. 'table' + :param title: Object name + :return: The compared object + """ + for diff in response_data: + if diff.get('type') == node_type and diff.get('title') == title: + return diff + + self.fail('{0} {1} was not compared'.format(node_type, title)) + + def runTest(self): + """ This function will test converting a column between integer + and SERIAL, in both directions. """ + self.trans_id = str(secrets.choice(range(1, 99999))) + response = self.tester.get( + 'schema_diff/initialize/{}'.format(self.trans_id)) + self.assertEqual(response.status_code, 200) + + received = self.socket_client.get_received(self.SOCKET_NAMESPACE) + self.assertEqual(received[0]['name'], 'connected') + + self.tester.post( + 'schema_diff/server/connect/{}'.format(self.server_id), + data=json.dumps({'password': self.server['db_password']}), + content_type='html/json') + self.tester.post('schema_diff/database/connect/{0}/{1}'.format( + self.server_id, self.src_db_id)) + self.tester.post('schema_diff/database/connect/{0}/{1}'.format( + self.server_id, self.tar_db_id)) + + response_data = self.compare() + + # Forward: target's plain integer column must become BIGSERIAL, + # which means the ALTER script must also create the owned + # sequence and set the column's DEFAULT to nextval() against it. + int_to_serial = self.find_object(response_data, 'table', + 'int_to_serial') + self.assertEqual(int_to_serial['status'], 'Different') + fwd_ddl = int_to_serial['diff_ddl'] + self.assertIn('TYPE bigint', fwd_ddl) + self.assertIn('CREATE SEQUENCE', fwd_ddl) + self.assertIn('SET DEFAULT nextval(', fwd_ddl) + # The sequence must be created before the column can default to + # nextval() against it. + self.assertLess(fwd_ddl.index('CREATE SEQUENCE'), + fwd_ddl.index('SET DEFAULT nextval(')) + + # Reverse: target's BIGSERIAL column must become plain integer, + # which means the ALTER script must drop the column's DEFAULT + # before it drops the now-unused sequence (PostgreSQL refuses to + # drop a sequence a column's default still references). + serial_to_int = self.find_object(response_data, 'table', + 'serial_to_int') + self.assertEqual(serial_to_int['status'], 'Different') + rev_ddl = serial_to_int['diff_ddl'] + self.assertIn('DROP DEFAULT', rev_ddl) + self.assertIn('DROP SEQUENCE', rev_ddl) + self.assertLess(rev_ddl.index('DROP DEFAULT'), + rev_ddl.index('DROP SEQUENCE')) + + # Applying both must succeed, and must settle the differences, + # including the underlying sequence objects. + self.execute_sql(self.tar_database, fwd_ddl) + self.execute_sql(self.tar_database, rev_ddl) + + response_data = self.compare() + for title in ('int_to_serial', 'serial_to_int'): + self.assertEqual( + self.find_object(response_data, 'table', title)['status'], + 'Identical') + + # The forward conversion must have made the column a genuine + # SERIAL: an insert omitting it must now succeed. + self.execute_sql( + self.tar_database, + "INSERT INTO {0}.int_to_serial (val) VALUES ('x')".format( + SCHEMA_NAME)) + + def tearDown(self): + """This function drops the added databases""" + super().tearDown() + for db_name in (self.src_database, self.tar_database): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port'], + self.server['sslmode']) + utils.drop_database(connection, db_name)