Skip to content

Commit eab584e

Browse files
committed
[ADD] util/bulk_update_table
A recurrent challenge in writing upgrade scripts is that of updating values in a table based on some form of already available mapping from the id (or another identifier) to the new value, this is often addressed with an iterative solution in the form: ```python for key, value in mapping.items(): cr.execute( """ UPDATE table SET col = %s WHERE key_col = %s """, [value, key], ) ``` or in a more efficient (only issuing a single query) but hacky way: ```python cr.execute( """ UPDATE table SET col = (%s::jsonb)->>(key_col::text) WHERE key_col = ANY(%s) """, [json.dumps(mapping), list(mapping)], ) ``` With the former being ineffective for big mappings and the latter often requiring some comments at review time to get it right. This commit introduces a util meant to make it easier to efficiently perform such updates. closes #297 Signed-off-by: Christophe Simonis (chs) <[email protected]>
1 parent 1ff209a commit eab584e

File tree

2 files changed

+183
-0
lines changed

2 files changed

+183
-0
lines changed

src/base/tests/test_util.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,115 @@ def test_parallel_execute_retry_on_serialization_failure(self):
883883
cr.execute(util.format_query(cr, "SELECT 1 FROM {}", TEST_TABLE_NAME))
884884
self.assertFalse(cr.rowcount)
885885

886+
def test_update_one_col_from_dict(self):
887+
TEST_TABLE_NAME = "_upgrade_bulk_update_one_col_test_table"
888+
N_ROWS = 10
889+
890+
cr = self._get_cr()
891+
892+
cr.execute(
893+
util.format_query(
894+
cr,
895+
"""
896+
DROP TABLE IF EXISTS {table};
897+
898+
CREATE TABLE {table} (
899+
id SERIAL PRIMARY KEY,
900+
col1 INTEGER,
901+
col2 INTEGER
902+
);
903+
904+
INSERT INTO {table} (col1, col2) SELECT v, v FROM GENERATE_SERIES(1, %s) as v;
905+
""",
906+
table=TEST_TABLE_NAME,
907+
),
908+
[N_ROWS],
909+
)
910+
mapping = {id: id * 2 for id in range(1, N_ROWS + 1, 2)}
911+
util.bulk_update_table(cr, TEST_TABLE_NAME, "col1", mapping)
912+
913+
cr.execute(
914+
util.format_query(
915+
cr,
916+
"SELECT id FROM {table} WHERE col2 != id",
917+
table=TEST_TABLE_NAME,
918+
)
919+
)
920+
self.assertFalse(cr.rowcount, "unintended column 'col2' is affected")
921+
922+
cr.execute(
923+
util.format_query(
924+
cr,
925+
"SELECT id FROM {table} WHERE col1 != id AND MOD(id, 2) = 0",
926+
table=TEST_TABLE_NAME,
927+
)
928+
)
929+
self.assertFalse(cr.rowcount, "unintended rows are affected")
930+
931+
cr.execute(
932+
util.format_query(
933+
cr,
934+
"SELECT id FROM {table} WHERE col1 != 2 * id AND MOD(id, 2) = 1",
935+
table=TEST_TABLE_NAME,
936+
)
937+
)
938+
self.assertFalse(cr.rowcount, "partial/incorrect updates are performed")
939+
940+
def test_update_multiple_cols_from_dict(self):
941+
TEST_TABLE_NAME = "_upgrade_bulk_update_multiple_cols_test_table"
942+
N_ROWS = 10
943+
944+
cr = self._get_cr()
945+
946+
cr.execute(
947+
util.format_query(
948+
cr,
949+
"""
950+
DROP TABLE IF EXISTS {table};
951+
952+
CREATE TABLE {table} (
953+
id SERIAL PRIMARY KEY,
954+
col1 INTEGER,
955+
col2 INTEGER,
956+
col3 INTEGER
957+
);
958+
959+
INSERT INTO {table} (col1, col2, col3) SELECT v, v, v FROM GENERATE_SERIES(1, %s) as v;
960+
""",
961+
table=TEST_TABLE_NAME,
962+
),
963+
[N_ROWS],
964+
)
965+
mapping = {id: [id * 2, id * 3] for id in range(1, N_ROWS + 1, 2)}
966+
util.bulk_update_table(cr, TEST_TABLE_NAME, ["col1", "col2"], mapping)
967+
968+
cr.execute(
969+
util.format_query(
970+
cr,
971+
"SELECT id FROM {table} WHERE col3 != id",
972+
table=TEST_TABLE_NAME,
973+
)
974+
)
975+
self.assertFalse(cr.rowcount, "unintended column 'col3' is affected")
976+
977+
cr.execute(
978+
util.format_query(
979+
cr,
980+
"SELECT id FROM {table} WHERE col1 != id AND MOD(id, 2) = 0",
981+
table=TEST_TABLE_NAME,
982+
)
983+
)
984+
self.assertFalse(cr.rowcount, "unintended rows are affected")
985+
986+
cr.execute(
987+
util.format_query(
988+
cr,
989+
"SELECT id FROM {table} WHERE (col1 != 2 * id OR col2 != 3 * id) AND MOD(id, 2) = 1",
990+
table=TEST_TABLE_NAME,
991+
)
992+
)
993+
self.assertFalse(cr.rowcount, "partial/incorrect updates are performed")
994+
886995
def test_create_column_with_fk(self):
887996
cr = self.env.cr
888997
self.assertFalse(util.column_exists(cr, "res_partner", "_test_lang_id"))

src/util/pg.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import psycopg2
3333
from psycopg2 import errorcodes, sql
3434
from psycopg2.extensions import quote_ident
35+
from psycopg2.extras import Json
3536

3637
try:
3738
from odoo.modules import module as odoo_module
@@ -1698,3 +1699,76 @@ def create_id_sequence(cr, table, set_as_default=True):
16981699
table=table_sql,
16991700
)
17001701
)
1702+
1703+
1704+
def bulk_update_table(cr, table, columns, mapping, key_col="id"):
1705+
"""
1706+
Update table based on mapping.
1707+
1708+
Each `mapping` entry defines the new values for the specified `columns` for the row(s)
1709+
whose `key_col` value matches the key.
1710+
1711+
.. example::
1712+
1713+
.. code-block:: python
1714+
1715+
# single column update
1716+
util.bulk_update_table(cr, "res_users", "active", {42: False, 27: True})
1717+
1718+
# multi-column update
1719+
util.bulk_update_table(
1720+
cr,
1721+
"res_users",
1722+
["active", "password"],
1723+
{
1724+
"admin": [True, "1234"],
1725+
"demo": [True, "5678"],
1726+
},
1727+
key_col="login",
1728+
)
1729+
1730+
:param str table: table to update.
1731+
:param str | list(str) columns: columns spec for the update. It could be a single
1732+
column name or a list of column names. The `mapping`
1733+
must match the spec.
1734+
:param dict mapping: values to set, which must match the spec in `columns`,
1735+
following the **same** order
1736+
:param str key_col: column used as key to get the values from `mapping` during the
1737+
update.
1738+
1739+
.. warning::
1740+
1741+
The values in the mapping will be casted to the type of the target column.
1742+
This function is designed to update scalar values, avoid setting arrays or json
1743+
data via the mapping.
1744+
"""
1745+
_validate_table(table)
1746+
if not columns or not mapping:
1747+
return
1748+
1749+
assert isinstance(mapping, dict)
1750+
if isinstance(columns, str):
1751+
columns = [columns]
1752+
else:
1753+
n_columns = len(columns)
1754+
assert all(isinstance(value, (list, tuple)) and len(value) == n_columns for value in mapping.values())
1755+
1756+
query = format_query(
1757+
cr,
1758+
"""
1759+
UPDATE {table} t
1760+
SET ({cols}) = ROW({cols_values})
1761+
FROM JSONB_EACH(%s) m
1762+
WHERE t.{key_col}::text = m.key
1763+
""",
1764+
table=table,
1765+
cols=ColumnList.from_unquoted(cr, columns),
1766+
cols_values=SQLStr(
1767+
", ".join(
1768+
"(m.value->>{:d})::{}".format(col_idx, column_type(cr, table, col_name))
1769+
for col_idx, col_name in enumerate(columns)
1770+
)
1771+
),
1772+
key_col=key_col,
1773+
)
1774+
cr.execute(query, [Json(mapping)])

0 commit comments

Comments
 (0)