Repository: mathesar-foundation/mathesar
Pull request #4331: Backend functions to modify table primary keys
Author: login: mathemancer
Labels: name: pr-status: review
description: A PR awaiting review
Created: 2025-03-13T15:29:41Z | Merged: 2025-03-19T02:53:35Z

<!-- If PR doesn't fully resolve the issue, replace 'Fixes' below with 'Related to'. -->
<!-- If there is no issue being resolved, open one before creating this pull request. -->
Fixes #4334 
Fixes #4335 
Fixes #4336 

<!-- Concisely describe what the pull request does. -->

This PR adds
- an attribute to `TableMetaData` to track the attnum of the most recent Mathesar-added primary key column,
- A parameter `drop_existing_primary_key_column` parameter to the `columns.add_primary_key_column` RPC function.
- A new `data_modeling.change_primary_key` function that lets a user set a current column of a table as its primary key.

**Technical details**
<!-- Add any other information or technical details about the implementation; or delete the section entirely. -->

- All new RPC functionality is documented in the standard way for our RPC functions
- There are tests to verify things are working as expected.
- These functions drop the previous primary key, whether it's single- or multi-column.
- The `mathesar_added_pkey_attnum` attribute of `TableMetaData` is automatically maintained by any back end RPC function that adds a primary key column:
  - `tables.add`
  - `tables.import`
  - `columns.add_primary_key_column`
- The `mathesar_added_pkey_attnum` attribute is available through any table-metadata-listing RPC function.

## Checklist
<!-- Replace  the [ ] with [x] to check the boxes. --> 
- [x] My pull request has a descriptive title (not a vague title like `Update
  index.md`).
- [x] My pull request targets the `develop` branch of the repository
- [x] My commit messages follow [best practices][best_practices].
- [x] My code follows the established code style of the repository.
- [x] I added tests for the changes I made (if applicable).
- [x] I added or updated documentation (if applicable).
- [x] I tried running the project locally and verified that there are no
  visible errors.

[best_practices]:https://gist.github.com/robertpainsi/b632364184e70900af4ab688decf6f53

## Developer Certificate of Origin
<!-- You must read and understand the following attestation. -->

<details>
<summary>Developer Certificate of Origin</summary>

```
Developer Certificate of Origin
Version 1.1

Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
1 Letterman Drive
Suite D4700
San Francisco, CA, 94129

Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.


Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
```

</details>


Linked issue #4334: Add pk_column_added_during_import table metadata field
For #4313 I need a new table metadata field.

I'm proposing the name: `pk_column_added_during_import`

It should store an integer representing the column attnum of a column that we added when importing a table.

The front end will be responsible for setting the value during the import process (within `createTableFromDataFile`).

(As discussed in [call](https://tldv.io/app/meetings/67d211e114c6960013ea72e9))


Linked issue comments: user: login: mathemancer
created_at: 2025-03-17T02:03:19Z
body: I ended up going with `mathesar_added_pkey_attnum`. 
- It mentions `attnum`, which implies it's a column and conforms with id naming conventions
- It's more general and opens up the possibility of tracking a pkey column we add at any point, not just import.
author_association: CONTRIBUTOR

Diff:
diff --git a/db/columns.py b/db/columns.py
index c70896fac7..48f0013fd8 100644
--- a/db/columns.py
+++ b/db/columns.py
@@ -121,13 +121,18 @@ def _transform_column_alter_dict(data):
     return alter_def
 
 
-def add_pkey_column_to_table(table_oid, pkey_type, conn, name="id"):
+def add_pkey_column_to_table(
+        table_oid, pkey_type, conn, drop_old_pkey_column=False, name="id"
+):
     """
     Add a primary key column to a table.
 
     See the `msar.add_pkey_column` function for info on the arguments.
     """
-    db_conn.exec_msar_func(conn, 'add_pkey_column', table_oid, pkey_type, name)
+    return db_conn.exec_msar_func(
+        conn, 'add_pkey_column',
+        table_oid, pkey_type, drop_old_pkey_column, name
+    ).fetchone()[0]
 
 
 def add_columns_to_table(table_oid, column_data_list, conn):
diff --git a/db/sql/00_msar_all_objects_table.sql b/db/sql/00_msar_all_objects_table.sql
index 43b69dfe95..1f48aca89a 100644
--- a/db/sql/00_msar_all_objects_table.sql
+++ b/db/sql/00_msar_all_objects_table.sql
@@ -570,6 +570,7 @@ INSERT INTO msar.all_mathesar_objects VALUES
   ('msar', 'msar.add_mathesar_table(oid,text,jsonb,jsonb,regrole,text)', 'FUNCTION', NULL),
   ('msar', 'msar.add_mathesar_table(oid,text,jsonb,jsonb,text)', 'FUNCTION', NULL),
   ('msar', 'msar.add_month_to_vector(point,date)', 'FUNCTION', NULL),
+  ('msar', 'msar.add_pkey_column(regclass,msar.pkey_kind,boolean,text)', 'FUNCTION', NULL),
   ('msar', 'msar.add_pkey_column(regclass,msar.pkey_kind,text)', 'FUNCTION', NULL),
   ('msar', 'msar.add_record_to_table(oid,jsonb,boolean)', 'FUNCTION', NULL),
   ('msar', 'msar.add_record_to_table(oid,jsonb,boolean,jsonb)', 'FUNCTION', NULL),
@@ -1171,6 +1172,7 @@ INSERT INTO msar.all_mathesar_objects VALUES
   ('msar', 'msar.search_records_from_table(oid,jsonb,integer,boolean)', 'FUNCTION', NULL),
   ('msar', 'msar.search_records_from_table(oid,jsonb,integer,boolean,jsonb)', 'FUNCTION', NULL),
   ('msar', 'msar.set_members_to_role(regrole,oid[])', 'FUNCTION', NULL),
+  ('msar', 'msar.set_pkey_column(regclass,integer,msar.pkey_kind,boolean)', 'FUNCTION', NULL),
   ('msar', 'msar.set_schema_description(oid,text)', 'FUNCTION', NULL),
   ('msar', 'msar.time_to_degrees(time without time zone)', 'FUNCTION', NULL),
   ('msar', 'msar.top_level_domains', 'TABLE', NULL),
diff --git a/db/sql/05_msar.sql b/db/sql/05_msar.sql
index 6a4e76e72e..5e0ee0c878 100644
--- a/db/sql/05_msar.sql
+++ b/db/sql/05_msar.sql
@@ -2111,16 +2111,18 @@ Args:
   tab_id: OID of the table whose columns we'll drop.
   col_ids: The attnums of the columns to drop.
 */
-DECLARE col_names text[];
 BEGIN
-  EXECUTE format(
-    'ALTER TABLE %I.%I %s',
-    msar.get_relation_schema_name(tab_id),
-    msar.get_relation_name(tab_id),
-    string_agg(format('DROP COLUMN %I', attname), ', ')
-  )
-  FROM pg_catalog.pg_attribute AS pga INNER JOIN unnest(col_ids) AS x(col) ON pga.attnum=x.col
-  WHERE attrelid=tab_id AND NOT attisdropped;
+  col_ids := array_remove(col_ids, null);
+  IF array_length(col_ids, 1) IS NOT NULL THEN
+    EXECUTE format(
+      'ALTER TABLE %I.%I %s',
+      msar.get_relation_schema_name(tab_id),
+      msar.get_relation_name(tab_id),
+      string_agg(format('DROP COLUMN %I', attname), ', ')
+    )
+    FROM pg_catalog.pg_attribute AS pga INNER JOIN unnest(col_ids) AS x(col) ON pga.attnum=x.col
+    WHERE attrelid=tab_id AND NOT attisdropped;
+  END IF;
 END;
 $$ LANGUAGE plpgsql RETURNS NULL ON NULL INPUT;
 
@@ -2576,8 +2578,11 @@ $$ LANGUAGE SQL;
 
 CREATE OR REPLACE FUNCTION
 msar.add_pkey_column(
-  tab_id regclass, pkey_type msar.pkey_kind, col_name text DEFAULT 'id'
-) RETURNS void AS $$/*
+  tab_id regclass,
+  pkey_type msar.pkey_kind,
+  drop_old_pkey_col boolean DEFAULT false,
+  col_name text DEFAULT 'id'
+) RETURNS integer AS $$/*
 Add a primary key column with a predefined default to a table.
 
 Any name collisions for the column are resolved automatically, with this column's name deferring to
@@ -2588,22 +2593,77 @@ Args:
   pkey_type: The "type" of the pkey column. 'UUIDv4' means a `uuid` column using the
              `gen_random_uuid()` function for its default values. 'IDENTITY' means an `integer`
              using `GENERATED BY DEFAULT AS IDENTITY` for default values.
+  drop_old_pkey_col: Whether we should drop the current primary key column during this operation.
   col_name: This optional value will set the name of the column.
 */
-DECLARE
-  pkey_text text;
 BEGIN
-  pkey_text := CASE pkey_type
-    WHEN 'IDENTITY' THEN 'integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY'
-    WHEN 'UUIDv4' THEN 'uuid PRIMARY KEY DEFAULT gen_random_uuid()'
-  END;
+  IF drop_old_pkey_col THEN
+    PERFORM msar.drop_columns(tab_id, msar.get_pk_column(tab_id));
+  END IF;
+  PERFORM msar.drop_constraint(tab_id, oid)
+    FROM pg_constraint WHERE conrelid=tab_id AND contype='p';
   EXECUTE format(
     'ALTER TABLE %I.%I ADD COLUMN %I %s;',
     msar.get_relation_schema_name(tab_id),
     msar.get_relation_name(tab_id),
     msar.build_unique_column_name(tab_id, col_name),
-    pkey_text
+    CASE pkey_type
+      WHEN 'IDENTITY' THEN 'integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY'
+      WHEN 'UUIDv4' THEN 'uuid PRIMARY KEY DEFAULT gen_random_uuid()'
+    END
+  );
+  RETURN msar.get_pk_column(tab_id);
+END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE FUNCTION
+msar.set_pkey_column(
+  tab_id regclass,
+  col_id integer,
+  default_type msar.pkey_kind DEFAULT null,
+  drop_old_pkey_col boolean DEFAULT false
+) RETURNS void AS $$/*
+Set a primary key column with an optional predefined default on a table.
+
+Args:
+  tab_id: This is the OID or name of the table for which we'll set the primary key column.
+  col_id: This is the attnum of the column we'll set as the primary key column.
+  pkey_type: The "type" of the pkey column. 'UUIDv4' means a `uuid` column using the
+             `gen_random_uuid()` function for its default values. 'IDENTITY' means an `integer`
+             using `GENERATED BY DEFAULT AS IDENTITY` for default values. If `null` is passed, we
+             do not set any default, or change the column type.
+  drop_old_pkey_col: Whether we should drop the current primary key column during this operation.
+*/
+DECLARE
+  pkey_col_max_val integer;
+BEGIN
+  IF drop_old_pkey_col THEN
+    PERFORM msar.drop_columns(tab_id, msar.get_pk_column(tab_id));
+  END IF;
+  PERFORM msar.drop_constraint(tab_id, oid)
+    FROM pg_constraint WHERE conrelid=tab_id AND contype='p';
+  PERFORM msar.add_constraints(
+    tab_id,
+    jsonb_build_array(jsonb_build_object('type', 'p', 'columns', jsonb_build_array(col_id)))
   );
+  IF default_type IS NOT NULL THEN
+    EXECUTE format(
+      CASE default_type WHEN 'IDENTITY' THEN
+        $s$
+        ALTER TABLE %1$I.%2$I
+          ALTER COLUMN %3$I TYPE integer USING msar.cast_to_integer(%3$I),
+          ALTER COLUMN %3$I ADD GENERATED BY DEFAULT AS IDENTITY;
+        SELECT setval(pg_get_serial_sequence('%1$I.%2$I', '%3$s'), max(%3$I)) FROM %1$I.%2$I;
+        $s$
+      WHEN 'UUIDv4' THEN
+        'ALTER TABLE %1$I.%2$I ALTER COLUMN %3$I SET DEFAULT gen_random_uuid();'
+      END,
+      msar.get_relation_schema_name(tab_id),
+      msar.get_relation_name(tab_id),
+      msar.get_column_name(tab_id, col_id)
+    );
+  END IF;
 END;
 $$ LANGUAGE plpgsql;
 
@@ -3232,7 +3292,8 @@ BEGIN
   RETURN jsonb_build_object(
     'oid', created_table_id::bigint,
     'name', relname,
-    'renamed_columns', renamed_columns::jsonb
+    'renamed_columns', renamed_columns::jsonb,
+    'pkey_column_attnum', msar.get_pk_column(created_table_id)
   ) FROM pg_catalog.pg_class WHERE oid = created_table_id;
 END;
 $$ LANGUAGE plpgsql;
@@ -3292,7 +3353,8 @@ BEGIN
     'copy_sql', copy_sql,
     'table_oid', rel_id::bigint,
     'table_name', relname,
-    'renamed_columns', (mathesar_table ->> 'renamed_columns')::jsonb
+    'renamed_columns', mathesar_table -> 'renamed_columns',
+    'pkey_column_attnum', mathesar_table -> 'pkey_column_attnum'
   ) FROM pg_catalog.pg_class WHERE oid = rel_id;
 END;
 $$ LANGUAGE plpgsql;
diff --git a/db/sql/test_sql_functions.sql b/db/sql/test_sql_functions.sql
index bc8cd3d6ae..963f50db20 100644
--- a/db/sql/test_sql_functions.sql
+++ b/db/sql/test_sql_functions.sql
@@ -192,10 +192,13 @@ $$ LANGUAGE plpgsql;
 CREATE OR REPLACE FUNCTION test_add_pkey_column_uuid() RETURNS SETOF TEXT AS $f$
 BEGIN
   PERFORM __setup_add_pkey_col();
-  PERFORM msar.add_pkey_column(
-    tab_id => 'add_pkey_col_testable'::regclass,
-    pkey_type => 'UUIDv4',
-    col_name => 'User Id'
+  RETURN NEXT is(
+    msar.add_pkey_column(
+      tab_id => 'add_pkey_col_testable'::regclass,
+      pkey_type => 'UUIDv4',
+      drop_old_pkey_col => true,
+      col_name => 'User Id'
+    ), 3, 'Should return correct attnum'
   );
   RETURN NEXT col_is_pk('add_pkey_col_testable', 'User Id');
   RETURN NEXT col_type_is('add_pkey_col_testable', 'User Id', 'uuid');
@@ -206,10 +209,13 @@ $f$ LANGUAGE plpgsql;
 CREATE OR REPLACE FUNCTION test_add_pkey_column_identity() RETURNS SETOF TEXT AS $f$
 BEGIN
   PERFORM __setup_add_pkey_col();
-  PERFORM msar.add_pkey_column(
-    tab_id => 'add_pkey_col_testable'::regclass,
-    pkey_type => 'IDENTITY',
-    col_name => 'Identity'
+  RETURN NEXT is(
+    msar.add_pkey_column(
+      tab_id => 'add_pkey_col_testable'::regclass,
+      pkey_type => 'IDENTITY',
+      drop_old_pkey_col => true,
+      col_name => 'Identity'
+    ), 3, 'Should return correct attnum'
   );
   RETURN NEXT col_is_pk('add_pkey_col_testable', 'Identity');
   RETURN NEXT col_type_is('add_pkey_col_testable', 'Identity', 'integer');
@@ -217,13 +223,74 @@ END;
 $f$ LANGUAGE plpgsql;
 
 
+CREATE OR REPLACE FUNCTION test_add_pkey_column_collision() RETURNS SETOF TEXT AS $f$
+BEGIN
+  PERFORM __setup_add_pkey_col();
+  RETURN NEXT is(
+    msar.add_pkey_column(
+      tab_id => 'add_pkey_col_testable'::regclass,
+      pkey_type => 'IDENTITY',
+      drop_old_pkey_col => true,
+      col_name => 'col1'
+    ), 3, 'Should return correct attnum'
+  );
+  RETURN NEXT col_is_pk('add_pkey_col_testable', 'col1 1', 'rename when collision');
+  RETURN NEXT is(
+    msar.add_pkey_column(
+      tab_id => 'add_pkey_col_testable'::regclass,
+      pkey_type => 'IDENTITY',
+      drop_old_pkey_col => true,
+      col_name => 'col1'
+    ), 4, 'Should return correct attnum'
+  );
+  RETURN NEXT col_is_pk(
+    'add_pkey_col_testable', 'col1 1', 'do not rename when collision with previous dropped pkey'
+  );
+  RETURN NEXT is(
+    msar.add_pkey_column(
+      tab_id => 'add_pkey_col_testable'::regclass,
+      pkey_type => 'IDENTITY',
+      drop_old_pkey_col => false,
+      col_name => 'col1'
+    ), 5, 'Should return correct attnum'
+  );
+  RETURN NEXT col_is_pk(
+    'add_pkey_col_testable', 'col1 2', 'rename when collision with previous undropped pkey'
+  );
+  RETURN NEXT columns_are('add_pkey_col_testable', ARRAY['col1', 'col2', 'col1 1', 'col1 2']);
+END;
+$f$ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE FUNCTION test_add_pkey_column_defaults_collide() RETURNS SETOF TEXT AS $f$
+BEGIN
+  PERFORM __setup_add_pkey_col();
+  RETURN NEXT is(
+    msar.add_pkey_column(
+      tab_id => 'add_pkey_col_testable'::regclass,
+      pkey_type => 'IDENTITY'
+    ), 3, 'Should return correct attnum'
+  );
+  RETURN NEXT col_is_pk('add_pkey_col_testable', 'id');
+  RETURN NEXT is(
+    msar.add_pkey_column(
+      tab_id => 'add_pkey_col_testable'::regclass,
+      pkey_type => 'IDENTITY'
+    ), 4, 'Should return correct attnum'
+  );
+  RETURN NEXT col_is_pk('add_pkey_col_testable', 'id 1');
+END;
+$f$ LANGUAGE plpgsql;
+
+
 CREATE OR REPLACE FUNCTION test_add_pkey_column_malformed() RETURNS SETOF TEXT AS $f$
 BEGIN
   PERFORM __setup_add_pkey_col();
   RETURN NEXT throws_like(
     $s$SELECT msar.add_pkey_column(
         tab_id => 'add_pkey_col_testable'::regclass,
-        pkey_type => 'ident'
+        pkey_type => 'ident',
+        drop_old_pkey_col => false
     );$s$,
     'invalid input value for enum%'
   );
@@ -231,6 +298,61 @@ END;
 $f$ LANGUAGE plpgsql;
 
 
+-- msar.set_pkey_column ----------------------------------------------------------------------------
+
+CREATE OR REPLACE FUNCTION __setup_set_pkey_col() RETURNS SETOF TEXT AS $$
+BEGIN
+  CREATE TABLE set_pkey_col_testable (
+    id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
+    col1 numeric,
+    col2 varchar,
+    "Column 3" uuid
+  );
+  INSERT INTO set_pkey_col_testable (col1, col2, "Column 3") VALUES
+    (324, 'abc', 'f2539a15-1644-413e-91e1-36008c6700e5'),
+    (567, 'def', 'e4c7d5d9-4eb3-4274-91f8-6af32c013f3a');
+END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE FUNCTION test_set_pkey_column_numeric() RETURNS SETOF TEXT AS $f$
+BEGIN
+  PERFORM __setup_set_pkey_col();
+  PERFORM msar.set_pkey_column(
+    tab_id => 'set_pkey_col_testable'::regclass,
+    col_id => 2,
+    default_type => 'IDENTITY',
+    drop_old_pkey_col => true
+  );
+  RETURN NEXT col_is_pk('set_pkey_col_testable', 'col1');
+  RETURN NEXT col_type_is('set_pkey_col_testable', 'col1', 'integer');
+  RETURN NEXT columns_are('set_pkey_col_testable', ARRAY['col1', 'col2', 'Column 3']);
+  INSERT INTO set_pkey_col_testable(col2) VALUES ('ghi');
+  RETURN NEXT results_eq('SELECT max(col1) FROM set_pkey_col_testable', 'VALUES (568)');
+END;
+$f$ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE FUNCTION test_set_pkey_column_uuid() RETURNS SETOF TEXT AS $f$
+BEGIN
+  PERFORM __setup_set_pkey_col();
+  PERFORM msar.set_pkey_column(
+    tab_id => 'set_pkey_col_testable'::regclass,
+    col_id => 4,
+    default_type => 'UUIDv4',
+    drop_old_pkey_col => false
+  );
+  RETURN NEXT col_is_pk('set_pkey_col_testable', 'Column 3');
+  RETURN NEXT columns_are('set_pkey_col_testable', ARRAY['id', 'col1', 'col2', 'Column 3']);
+  INSERT INTO set_pkey_col_testable(col2) VALUES ('ghi');
+  RETURN NEXT results_eq(
+    'SELECT COUNT(DISTINCT "Column 3") FROM set_pkey_col_testable',
+    'VALUES (3::bigint)'
+  );
+END;
+$f$ LANGUAGE plpgsql;
+
+
 -- msar.add_columns --------------------------------------------------------------------------------
 
 CREATE OR REPLACE FUNCTION __setup_add_columns() RETURNS SETOF TEXT AS $$
@@ -1705,6 +1827,7 @@ BEGIN
     response ->> 'copy_sql', 'COPY tab_create_schema.anewtable () FROM STDIN'
   );
   RETURN NEXT is(response -> 'renamed_columns', '{}'::jsonb);
+  RETURN NEXT is((response ->> 'pkey_column_attnum')::integer, 1);
 END;
 $f$ LANGUAGE plpgsql;
 
@@ -1730,6 +1853,7 @@ BEGIN
     response ->> 'copy_sql', 'COPY tab_create_schema.anewtable () FROM STDIN'
   );
   RETURN NEXT is(response -> 'renamed_columns', '{}'::jsonb);
+  RETURN NEXT is((response ->> 'pkey_column_attnum')::integer, 1);
 END;
 $f$ LANGUAGE plpgsql;
 
@@ -1762,6 +1886,7 @@ BEGIN
     'COPY tab_create_schema.anewtable ("My Col", col2) FROM STDIN'
   );
   RETURN NEXT is(response -> 'renamed_columns', '{}'::jsonb);
+  RETURN NEXT is((response ->> 'pkey_column_attnum')::integer, 1);
 END;
 $f$ LANGUAGE plpgsql;
 
diff --git a/db/tables.py b/db/tables.py
index 4803d0f005..e680b2bd8e 100644
--- a/db/tables.py
+++ b/db/tables.py
@@ -98,7 +98,8 @@ def create_table_on_database(
         comment: The comment for the new table. (optional)
 
     Returns:
-        Returns the OID and name of the created table.
+        An object with the OID, name, renamed_columns, and primary key
+        column attnum  of the created table.
     """
     return db_conn.exec_msar_func(
         conn,
@@ -143,11 +144,8 @@ def create_and_import_from_rows(
     with cursor.copy(import_info['copy_sql']) as copy:
         for row in rows:
             copy.write_row(row)
-    return (
-        import_info['table_oid'],
-        import_info['table_name'],
-        import_info['renamed_columns'],
-    )
+
+    return import_info
 
 
 def drop_table_from_database(table_oid, conn, cascade=False):
@@ -246,3 +244,20 @@ def fetch_table_in_chunks(
                 if not records:
                     break
                 yield [record[0] for record in records]
+
+
+def set_primary_key_column_on_table(
+        conn,
+        table_oid,
+        column_attnum,
+        default_type=None,
+        drop_old_pkey_column=False,
+):
+    db_conn.exec_msar_func(
+        conn,
+        'set_pkey_column',
+        table_oid,
+        column_attnum,
+        default_type,
+        drop_old_pkey_column
+    )
diff --git a/mathesar/imports/datafile.py b/mathesar/imports/datafile.py
index 13eb337715..b91cd92fe3 100644
--- a/mathesar/imports/datafile.py
+++ b/mathesar/imports/datafile.py
@@ -29,7 +29,7 @@ def copy_datafile_to_table(
                 f"{COLUMN_NAME_TEMPLATE}{i}" for i in range(len(next(reader)))
             ]
             reader.seek(0)
-        table_oid, db_table_name, renamed_columns = create_and_import_from_rows(
+        import_info = create_and_import_from_rows(
             reader,
             table_name,
             schema_oid,
@@ -38,7 +38,12 @@ def copy_datafile_to_table(
             comment=comment,
         )
 
-    return {"oid": table_oid, "name": db_table_name, "renamed_columns": renamed_columns}
+    return {
+        "oid": import_info['table_oid'],
+        "name": import_info['table_name'],
+        "renamed_columns": import_info['renamed_columns'],
+        "pkey_column_attnum": import_info['pkey_column_attnum'],
+    }
 
 
 def _process_column_names(column_names):
diff --git a/mathesar/migrations/0004_tablemetadata_mathesar_added_pkey_attnum.py b/mathesar/migrations/0004_tablemetadata_mathesar_added_pkey_attnum.py
new file mode 100644
index 0000000000..def50d0045
--- /dev/null
+++ b/mathesar/migrations/0004_tablemetadata_mathesar_added_pkey_attnum.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.18 on 2025-03-14 02:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('mathesar', '0003_v021'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='tablemetadata',
+            name='mathesar_added_pkey_attnum',
+            field=models.PositiveIntegerField(null=True),
+        ),
+    ]
diff --git a/mathesar/models/base.py b/mathesar/models/base.py
index f75d2bf8a5..6701788762 100644
--- a/mathesar/models/base.py
+++ b/mathesar/models/base.py
@@ -242,6 +242,7 @@ class TableMetaData(BaseModel):
     import_verified = models.BooleanField(null=True)
     column_order = models.JSONField(null=True)
     record_summary_template = models.JSONField(null=True)
+    mathesar_added_pkey_attnum = models.PositiveIntegerField(null=True)
 
     class Meta:
         constraints = [
diff --git a/mathesar/rpc/columns/base.py b/mathesar/rpc/columns/base.py
index 4ab0def8e4..74be647f55 100644
--- a/mathesar/rpc/columns/base.py
+++ b/mathesar/rpc/columns/base.py
@@ -16,6 +16,7 @@
 from mathesar.rpc.decorators import mathesar_rpc_method
 from mathesar.rpc.utils import connect
 from mathesar.utils.columns import get_columns_meta_data
+from mathesar.utils.tables import set_table_meta_data
 
 
 class TypeOptions(TypedDict, total=False):
@@ -233,7 +234,8 @@ def add_primary_key_column(
         pkey_type: Literal["IDENTITY", "UUIDv4"],
         table_oid: int,
         database_id: int,
-        name: Optional[str] = "id",
+        drop_existing_pkey_column: bool = False,
+        name: str = "id",
         **kwargs
 ) -> None:
     """
@@ -259,11 +261,21 @@ def add_primary_key_column(
         pkey_type: Defines the type and default of the primary key.
         table_oid: The OID of the table getting a primary key.
         database_id: The Django id of the database containing the table.
+        drop_existing_pkey_column: Whether to drop the old pkey column.
         name: A custom name for the added primary key column.
     """
     user = kwargs.get(REQUEST_KEY).user
     with connect(database_id, user) as conn:
-        add_pkey_column_to_table(table_oid, pkey_type, conn, name=name)
+        pkey_attnum = add_pkey_column_to_table(
+            table_oid,
+            pkey_type,
+            conn,
+            drop_old_pkey_column=drop_existing_pkey_column,
+            name=name
+        )
+    set_table_meta_data(
+        table_oid, {"mathesar_added_pkey_attnum": pkey_attnum}, database_id
+    )
 
 
 @mathesar_rpc_method(name="columns.add", auth="login")
diff --git a/mathesar/rpc/data_modeling.py b/mathesar/rpc/data_modeling.py
index eb069744fc..1539abc394 100644
--- a/mathesar/rpc/data_modeling.py
+++ b/mathesar/rpc/data_modeling.py
@@ -1,7 +1,7 @@
 """
 Classes and functions exposed to the RPC endpoint for managing data models.
 """
-from typing import TypedDict
+from typing import Literal, Optional, TypedDict
 
 from modernrpc.core import REQUEST_KEY
 
@@ -171,3 +171,47 @@ def move_columns(
             target_table_oid,
             move_column_attnums
         )
+
+
+@mathesar_rpc_method(name="data_modeling.change_primary_key_column", auth="login")
+def change_primary_key_column(
+        *,
+        column_attnum: int,
+        table_oid: int,
+        database_id: int,
+        default: Optional[Literal['IDENTITY', 'UUIDv4']] = None,
+        drop_existing_pk_column: bool = False,
+        **kwargs
+) -> None:
+    """
+    Change which column is used for a single-column primary key.
+
+    The `default` settings map as follows:
+        - 'IDENTITY': `GENERATED BY DEFAULT AS IDENTITY`
+        - 'UUIDv4': `gen_random_uuid()`
+        - null: No default set
+
+    Note that for clarity and safety, we *do not* drop any preexisitng
+    default on the column targeted.
+
+    Note that in cases where 'IDENTITY' is requested, but the column is
+    not an integer, we first attempt to cast the column to `integer`,
+    and then proceed only if that succeeds.
+
+    Args:
+        column_attnum: The attnum of the column to use for the pkey.
+        table_oid: The OID of the table whose primary key we'll set.
+        database_id: The Django id of the database containing the table.
+        default: A flag specifying the default generating function.
+        drop_existing_pk_column: Whether we should drop the current pkey
+            column.
+    """
+    user = kwargs.get(REQUEST_KEY).user
+    with connect(database_id, user) as conn:
+        tables.set_primary_key_column_on_table(
+            conn,
+            table_oid,
+            column_attnum,
+            default_type=default,
+            drop_old_pkey_column=drop_existing_pk_column,
+        )
diff --git a/mathesar/rpc/tables/base.py b/mathesar/rpc/tables/base.py
index f320579b83..65aa3a0c52 100644
--- a/mathesar/rpc/tables/base.py
+++ b/mathesar/rpc/tables/base.py
@@ -25,7 +25,11 @@
 from mathesar.rpc.decorators import mathesar_rpc_method
 from mathesar.rpc.tables.metadata import TableMetaDataBlob
 from mathesar.rpc.utils import connect
-from mathesar.utils.tables import list_tables_meta_data, get_table_meta_data
+from mathesar.utils.tables import (
+    list_tables_meta_data,
+    get_table_meta_data,
+    set_table_meta_data
+)
 
 
 class TableInfo(TypedDict):
@@ -235,14 +239,21 @@ def add(
         comment: The comment for the new table.
 
     Returns:
-        The `oid` of the created table.
+        The `oid`, `name`, and `renamed_columns` of the created table.
     """
     user = kwargs.get(REQUEST_KEY).user
     with connect(database_id, user) as conn:
-        created_table_oid = create_table_on_database(
+        created_table_info = create_table_on_database(
             table_name, schema_oid, conn, pkey_column_info, column_data_list, constraint_data_list, owner_oid, comment
         )
-    return created_table_oid
+
+    set_table_meta_data(
+        created_table_info['oid'],
+        {'mathesar_added_pkey_attnum': created_table_info['pkey_column_attnum']},
+        database_id,
+    )
+
+    return AddedTableInfo.from_dict(created_table_info)
 
 
 @mathesar_rpc_method(name="tables.delete", auth="login")
@@ -310,17 +321,23 @@ def import_(
     """
     user = kwargs.get(REQUEST_KEY).user
     with connect(database_id, user) as conn:
-        return AddedTableInfo.from_dict(
-            copy_datafile_to_table(
-                user,
-                data_file_id,
-                table_name,
-                schema_oid,
-                conn,
-                comment=comment,
-            )
+        import_result = copy_datafile_to_table(
+            user,
+            data_file_id,
+            table_name,
+            schema_oid,
+            conn,
+            comment=comment,
         )
 
+    set_table_meta_data(
+        import_result['oid'],
+        {'mathesar_added_pkey_attnum': import_result['pkey_column_attnum']},
+        database_id,
+    )
+
+    return AddedTableInfo.from_dict(import_result)
+
 
 @mathesar_rpc_method(name="tables.get_import_preview", auth="login")
 def get_import_preview(
diff --git a/mathesar/rpc/tables/metadata.py b/mathesar/rpc/tables/metadata.py
index 99e2bbbad0..1a6d2c352a 100644
--- a/mathesar/rpc/tables/metadata.py
+++ b/mathesar/rpc/tables/metadata.py
@@ -20,7 +20,8 @@ class TableMetaDataRecord(TypedDict):
         data_file_id: Specifies the DataFile model id used for the import.
         import_verified: Specifies whether a file has been successfully imported into a table.
         column_order: The order in which columns of a table are displayed.
-        record_summary_template: The record summary template
+        record_summary_template: The record summary template.
+        mathesar_added_pkey_attnum: The attnum of the most recently-set pkey column.
     """
     id: int
     database_id: int
@@ -29,6 +30,7 @@ class TableMetaDataRecord(TypedDict):
     import_verified: Optional[bool]
     column_order: Optional[list[int]]
     record_summary_template: Optional[dict[str, Union[str, list[int]]]]
+    mathesar_added_pkey_attnum: Optional[int]
 
     @classmethod
     def from_model(cls, model):
@@ -40,6 +42,7 @@ def from_model(cls, model):
             import_verified=model.import_verified,
             column_order=model.column_order,
             record_summary_template=model.record_summary_template,
+            mathesar_added_pkey_attnum=model.mathesar_added_pkey_attnum,
         )
 
 
@@ -52,11 +55,13 @@ class TableMetaDataBlob(TypedDict):
         import_verified: Specifies whether a file has been successfully imported into a table.
         column_order: The order in which columns of a table are displayed.
         record_summary_template: The record summary template
+        mathesar_added_pkey_attnum: The attnum of the most recently-set pkey column.
     """
     data_file_id: Optional[int]
     import_verified: Optional[bool]
     column_order: Optional[list[int]]
     record_summary_template: Optional[dict[str, Union[str, list[int]]]]
+    mathesar_added_pkey_attnum: Optional[int]
 
     @classmethod
     def from_model(cls, model):
@@ -65,6 +70,7 @@ def from_model(cls, model):
             import_verified=model.import_verified,
             column_order=model.column_order,
             record_summary_template=model.record_summary_template,
+            mathesar_added_pkey_attnum=model.mathesar_added_pkey_attnum,
         )
 
 
diff --git a/mathesar/tests/rpc/columns/test_c_base.py b/mathesar/tests/rpc/columns/test_c_base.py
index 1d89286360..0641916160 100644
--- a/mathesar/tests/rpc/columns/test_c_base.py
+++ b/mathesar/tests/rpc/columns/test_c_base.py
@@ -181,3 +181,54 @@ def mock_connect(_database_id, user):
     assert actual_result == 3
     assert call_args[2] == table_oid
     assert call_args[3:6] == tuple(column_attnums)
+
+
+def test_add_primary_key_column(rf, monkeypatch, mocked_exec_msar_func):
+    request = rf.post('/api/rpc/v0/', data={})
+    request.user = User(username='alice', password='pass1234')
+    table_oid = 23457
+    database_id = 2
+
+    @contextmanager
+    def mock_connect(_database_id, user):
+        if _database_id == 2 and user.username == 'alice':
+            try:
+                yield True
+            finally:
+                pass
+        else:
+            raise AssertionError('incorrect parameters passed')
+
+    def mock_set_meta_data(table_oid, metadata, _database_id):
+        assert table_oid == 23457
+        assert metadata == {"mathesar_added_pkey_attnum": 3}
+        assert _database_id == 2
+
+    monkeypatch.setattr(columns.base, 'connect', mock_connect)
+    monkeypatch.setattr(columns.base, 'set_table_meta_data', mock_set_meta_data)
+    mocked_exec_msar_func.fetchone.return_value = [3]
+    columns.add_primary_key_column(
+        pkey_type="IDENTITY",
+        table_oid=table_oid,
+        database_id=database_id,
+        request=request
+    )
+    call_args = mocked_exec_msar_func.call_args_list[0][0]
+    assert call_args[2] == table_oid
+    assert call_args[3] == "IDENTITY"
+    assert call_args[4] is False  # This should be the default
+    assert call_args[5] == 'id'  # This should be the default
+
+    columns.add_primary_key_column(
+        pkey_type="IDENTITY",
+        table_oid=table_oid,
+        database_id=database_id,
+        drop_existing_pkey_column=True,
+        name="Identity",
+        request=request
+    )
+    call_args = mocked_exec_msar_func.call_args_list[1][0]
+    assert call_args[2] == table_oid
+    assert call_args[3] == "IDENTITY"
+    assert call_args[4] is True
+    assert call_args[5] == 'Identity'
diff --git a/mathesar/tests/rpc/tables/test_t_base.py b/mathesar/tests/rpc/tables/test_t_base.py
index c4dc75554e..54b981c36c 100644
--- a/mathesar/tests/rpc/tables/test_t_base.py
+++ b/mathesar/tests/rpc/tables/test_t_base.py
@@ -123,11 +123,19 @@ def mock_connect(_database_id, user):
         else:
             raise AssertionError('incorrect parameters passed')
 
+    def mock_set_meta_data(table_oid, metadata, _database_id):
+        assert table_oid == 1964474
+        assert metadata == {"mathesar_added_pkey_attnum": 1}
+        assert _database_id == 11
+
     monkeypatch.setattr(tables.base, 'connect', mock_connect)
-    mocked_exec_msar_func.fetchone.return_value = [{"oid": 1964474, "name": "newtable"}]
+    monkeypatch.setattr(tables.base, 'set_table_meta_data', mock_set_meta_data)
+    mocked_exec_msar_func.fetchone.return_value = [
+        {"oid": 1964474, "name": "newtable", "pkey_column_attnum": 1}
+    ]
     actual_table_info = tables.add(table_name='newtable', schema_oid=2200, database_id=11, request=request)
     call_args = mocked_exec_msar_func.call_args_list[0][0]
-    assert actual_table_info == {"oid": 1964474, "name": "newtable"}
+    assert actual_table_info == {"oid": 1964474, "name": "newtable", "renamed_columns": None}
     assert call_args[2] == schema_oid
     assert call_args[3] == 'newtable'
     assert call_args[4] == json.dumps({})
@@ -193,6 +201,11 @@ def mock_connect(_database_id, user):
         else:
             raise AssertionError('incorrect parameters passed')
 
+    def mock_set_meta_data(table_oid, metadata, _database_id):
+        assert table_oid == 1964474
+        assert metadata == {"mathesar_added_pkey_attnum": 1}
+        assert _database_id == 11
+
     def mock_table_import(_user, _data_file_id, table_name, _schema_oid, conn, comment):
         if (
             _user != request.user
@@ -200,8 +213,9 @@ def mock_table_import(_user, _data_file_id, table_name, _schema_oid, conn, comme
             and _data_file_id != data_file_id
         ):
             raise AssertionError('incorrect parameters passed')
-        return {"oid": 1964474, "name": "imported_table"}
+        return {"oid": 1964474, "name": "imported_table", "pkey_column_attnum": 1}
     monkeypatch.setattr(tables.base, 'connect', mock_connect)
+    monkeypatch.setattr(tables.base, 'set_table_meta_data', mock_set_meta_data)
     monkeypatch.setattr(tables.base, 'copy_datafile_to_table', mock_table_import)
     imported_table_info = tables.import_(
         data_file_id=10,
diff --git a/mathesar/tests/rpc/tables/test_t_metadata.py b/mathesar/tests/rpc/tables/test_t_metadata.py
index 6b848e9234..303c8511a9 100644
--- a/mathesar/tests/rpc/tables/test_t_metadata.py
+++ b/mathesar/tests/rpc/tables/test_t_metadata.py
@@ -46,6 +46,7 @@ def mock_list_tables_meta_data(_database_id):
             import_verified=True,
             column_order=[8, 9, 10],
             record_summary_template=None,
+            mathesar_added_pkey_attnum=None,
         ),
         metadata.TableMetaDataRecord(
             id=2,
@@ -55,6 +56,7 @@ def mock_list_tables_meta_data(_database_id):
             import_verified=False,
             column_order=[],
             record_summary_template=None,
+            mathesar_added_pkey_attnum=None,
         ),
     ]
     actual_metadata_list = metadata.list_(database_id=database_id)
diff --git a/mathesar/tests/rpc/test_data_modeling.py b/mathesar/tests/rpc/test_data_modeling.py
index 9f43bee5ae..fb2f371552 100644
--- a/mathesar/tests/rpc/test_data_modeling.py
+++ b/mathesar/tests/rpc/test_data_modeling.py
@@ -183,3 +183,39 @@ def mock_connect(database_id, user):
     assert call_args[2] == _source_table_oid
     assert call_args[3] == _target_table_oid
     assert call_args[4] == _move_column_attnums
+
+
+def test_change_primary_key_column(rf, monkeypatch, mocked_exec_msar_func):
+    _username = 'alice'
+    _password = 'pass1234'
+    _column_attnum = 3
+    _table_oid = 12345
+    _default = 'UUIDv4'
+    _database_id = 2
+    request = rf.post('/api/rpc/v0/', data={})
+    request.user = User(username=_username, password=_password)
+
+    @contextmanager
+    def mock_connect(database_id, user):
+        if database_id == _database_id and user.username == _username:
+            try:
+                yield True
+            finally:
+                pass
+        else:
+            raise AssertionError('incorrect parameters passed')
+
+    monkeypatch.setattr(data_modeling, 'connect', mock_connect)
+    data_modeling.change_primary_key_column(
+        column_attnum=_column_attnum,
+        table_oid=_table_oid,
+        database_id=_database_id,
+        default=_default,
+        drop_existing_pk_column=True,
+        request=request,
+    )
+    call_args = mocked_exec_msar_func.call_args_list[0][0]
+    assert call_args[2] == _table_oid
+    assert call_args[3] == _column_attnum
+    assert call_args[4] == _default
+    assert call_args[5] is True
diff --git a/mathesar/tests/rpc/test_endpoints.py b/mathesar/tests/rpc/test_endpoints.py
index 49be1b5295..83a3532bb3 100644
--- a/mathesar/tests/rpc/test_endpoints.py
+++ b/mathesar/tests/rpc/test_endpoints.py
@@ -137,6 +137,11 @@
         "data_modeling.add_mapping_table",
         [user_is_authenticated]
     ),
+    (
+        data_modeling.change_primary_key_column,
+        "data_modeling.change_primary_key_column",
+        [user_is_authenticated]
+    ),
     (
         data_modeling.suggest_types,
         "data_modeling.suggest_types",


Reviews:
user: login: Anish9901
body: Looks great! I added a code suggestion, should be good to merge after that.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T19:08:20Z
user: login: seancolsen
body: I've now opened #4353 on top of this. This PR seems to give me everything I need. Thanks @mathemancer!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T23:38:10Z
user: login: mathemancer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T02:19:35Z

Review comments:
diff_hunk: @@ -2590,20 +2595,67 @@ Args:
              using `GENERATED BY DEFAULT AS IDENTITY` for default values.
   col_name: This optional value will set the name of the column.
 */
-DECLARE
-  pkey_text text;
 BEGIN
-  pkey_text := CASE pkey_type
-    WHEN 'IDENTITY' THEN 'integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY'
-    WHEN 'UUIDv4' THEN 'uuid PRIMARY KEY DEFAULT gen_random_uuid()'
-  END;
+  IF drop_old_pkey_col THEN
+    PERFORM msar.drop_columns(tab_id, msar.get_pk_column(tab_id));
+  END IF;
+  PERFORM msar.drop_constraint(tab_id, oid)
+    FROM pg_constraint WHERE conrelid=tab_id AND contype='p';
   EXECUTE format(
     'ALTER TABLE %I.%I ADD COLUMN %I %s;',
     msar.get_relation_schema_name(tab_id),
     msar.get_relation_name(tab_id),
     msar.build_unique_column_name(tab_id, col_name),
-    pkey_text
+    CASE pkey_type
+      WHEN 'IDENTITY' THEN 'integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY'
+      WHEN 'UUIDv4' THEN 'uuid PRIMARY KEY DEFAULT gen_random_uuid()'
+    END
   );
+  RETURN msar.get_pk_column(tab_id);
+END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE FUNCTION
+msar.set_pkey_column(
+  tab_id regclass,
+  col_id integer,
+  default_type msar.pkey_kind DEFAULT null,
+  drop_old_pkey_col boolean DEFAULT false
+) RETURNS void AS $$/*
+*/
+DECLARE
+  pkey_col_max_val integer;
+BEGIN
+  IF drop_old_pkey_col THEN
+    PERFORM msar.drop_columns(tab_id, msar.get_pk_column(tab_id));
+  END IF;
+  PERFORM msar.drop_constraint(tab_id, oid)
+    FROM pg_constraint WHERE conrelid=tab_id AND contype='p';
+  PERFORM msar.add_constraints(
+    tab_id,
+    jsonb_build_array(jsonb_build_object('type', 'p', 'columns', jsonb_build_array(col_id)))
+  );
+  IF default_type = 'IDENTITY' THEN
+    EXECUTE format(
+      $s$
+      ALTER TABLE %1$I.%2$I
+        ALTER COLUMN %3$I TYPE integer USING msar.cast_to_integer(%3$I),
+        ALTER COLUMN %3$I ADD GENERATED BY DEFAULT AS IDENTITY;
+      SELECT setval(pg_get_serial_sequence('%1$I.%2$I', '%3$s'), max(%3$I)) FROM %1$I.%2$I;
+      $s$,
+      msar.get_relation_schema_name(tab_id),
+      msar.get_relation_name(tab_id),
+      msar.get_column_name(tab_id, col_id)
+    );
+  ELSIF default_type = 'UUIDv4' THEN
+    EXECUTE format(
+      'ALTER TABLE %1$I.%2$I ALTER COLUMN %3$I SET DEFAULT gen_random_uuid();',
+      msar.get_relation_schema_name(tab_id),
+      msar.get_relation_name(tab_id),
+      msar.get_column_name(tab_id, col_id)
+    );
+  END IF;
path: db/sql/05_msar.sql
user: login: Anish9901
body: ```suggestion
  IF default_type IS NOT NULL THEN
    EXECUTE format(
      CASE default_type WHEN 'IDENTITY' THEN
        $s$
        ALTER TABLE %1$I.%2$I
          ALTER COLUMN %3$I TYPE integer USING msar.cast_to_integer(%3$I),
          ALTER COLUMN %3$I ADD GENERATED BY DEFAULT AS IDENTITY;
        SELECT setval(pg_get_serial_sequence('%1$I.%2$I', '%3$s'), max(%3$I)) FROM %1$I.%2$I;
        $s$
      WHEN 'UUIDv4' THEN
        'ALTER TABLE %1$I.%2$I ALTER COLUMN %3$I SET DEFAULT gen_random_uuid();'
      END,
      msar.get_relation_schema_name(tab_id),
      msar.get_relation_name(tab_id),
      msar.get_column_name(tab_id, col_id)
    );
  END IF;
```
created_at: 2025-03-18T18:36:43Z
author_association: MEMBER
diff_hunk: @@ -2590,20 +2595,67 @@ Args:
              using `GENERATED BY DEFAULT AS IDENTITY` for default values.
   col_name: This optional value will set the name of the column.
 */
-DECLARE
-  pkey_text text;
 BEGIN
-  pkey_text := CASE pkey_type
-    WHEN 'IDENTITY' THEN 'integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY'
-    WHEN 'UUIDv4' THEN 'uuid PRIMARY KEY DEFAULT gen_random_uuid()'
-  END;
+  IF drop_old_pkey_col THEN
+    PERFORM msar.drop_columns(tab_id, msar.get_pk_column(tab_id));
+  END IF;
+  PERFORM msar.drop_constraint(tab_id, oid)
+    FROM pg_constraint WHERE conrelid=tab_id AND contype='p';
   EXECUTE format(
     'ALTER TABLE %I.%I ADD COLUMN %I %s;',
     msar.get_relation_schema_name(tab_id),
     msar.get_relation_name(tab_id),
     msar.build_unique_column_name(tab_id, col_name),
-    pkey_text
+    CASE pkey_type
+      WHEN 'IDENTITY' THEN 'integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY'
+      WHEN 'UUIDv4' THEN 'uuid PRIMARY KEY DEFAULT gen_random_uuid()'
+    END
   );
+  RETURN msar.get_pk_column(tab_id);
+END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE FUNCTION
+msar.set_pkey_column(
+  tab_id regclass,
+  col_id integer,
+  default_type msar.pkey_kind DEFAULT null,
+  drop_old_pkey_col boolean DEFAULT false
+) RETURNS void AS $$/*
+*/
path: db/sql/05_msar.sql
user: login: Anish9901
body: Can you write up this comment?
created_at: 2025-03-18T19:07:35Z
author_association: MEMBER
diff_hunk: @@ -2590,20 +2595,67 @@ Args:
              using `GENERATED BY DEFAULT AS IDENTITY` for default values.
   col_name: This optional value will set the name of the column.
 */
-DECLARE
-  pkey_text text;
 BEGIN
-  pkey_text := CASE pkey_type
-    WHEN 'IDENTITY' THEN 'integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY'
-    WHEN 'UUIDv4' THEN 'uuid PRIMARY KEY DEFAULT gen_random_uuid()'
-  END;
+  IF drop_old_pkey_col THEN
+    PERFORM msar.drop_columns(tab_id, msar.get_pk_column(tab_id));
+  END IF;
+  PERFORM msar.drop_constraint(tab_id, oid)
+    FROM pg_constraint WHERE conrelid=tab_id AND contype='p';
   EXECUTE format(
     'ALTER TABLE %I.%I ADD COLUMN %I %s;',
     msar.get_relation_schema_name(tab_id),
     msar.get_relation_name(tab_id),
     msar.build_unique_column_name(tab_id, col_name),
-    pkey_text
+    CASE pkey_type
+      WHEN 'IDENTITY' THEN 'integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY'
+      WHEN 'UUIDv4' THEN 'uuid PRIMARY KEY DEFAULT gen_random_uuid()'
+    END
   );
+  RETURN msar.get_pk_column(tab_id);
+END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE FUNCTION
+msar.set_pkey_column(
+  tab_id regclass,
+  col_id integer,
+  default_type msar.pkey_kind DEFAULT null,
+  drop_old_pkey_col boolean DEFAULT false
+) RETURNS void AS $$/*
+*/
path: db/sql/05_msar.sql
user: login: mathemancer
body: done.
created_at: 2025-03-19T02:19:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: seancolsen
created_at: 2025-03-17T16:33:56Z
body: @mathemancer can you please sync this up with `develop`? Merge or rebase is fine with me — whatever you prefer.

_(I'd merge develop into this branch myself, but since it's still in draft state I want to leave this choice up to you. Personally sometimes I like to retain the ability to easily rebase PRs when in draft state.)_

For #4313, I need to base my work on this PR plus some of my work from #4327 (which is now in `develop`).
author_association: CONTRIBUTOR
user: login: mathemancer
created_at: 2025-03-18T02:07:51Z
body: > @mathemancer can you please sync this up with `develop`? Merge or rebase is fine with me — whatever you prefer.
> 
> _(I'd merge develop into this branch myself, but since it's still in draft state I want to leave this choice up to you. Personally sometimes I like to retain the ability to easily rebase PRs when in draft state.)_
> 
> For #4313, I need to base my work on this PR plus some of my work from #4327 (which is now in `develop`).

It's merged in now. 
author_association: CONTRIBUTOR