django-fast-treenode 3.0.3__py3-none-any.whl → 3.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-fast-treenode
3
- Version: 3.0.3
3
+ Version: 3.0.4
4
4
  Summary: Treenode Framework for supporting tree (hierarchical) data structure in Django projects
5
5
  Home-page: https://django-fast-treenode.readthedocs.io/
6
6
  Author: Timur Kady
@@ -53,7 +53,7 @@ Classifier: Operating System :: OS Independent
53
53
  Requires-Python: >=3.9
54
54
  Description-Content-Type: text/markdown
55
55
  License-File: LICENSE
56
- Requires-Dist: Django>=4.0
56
+ Requires-Dist: Django>=5.0
57
57
  Requires-Dist: msgpack>=1.0.0
58
58
  Requires-Dist: openpyxl>=3.0.0
59
59
  Requires-Dist: pyyaml>=5.1
@@ -1,4 +1,4 @@
1
- django_fast_treenode-3.0.3.dist-info/licenses/LICENSE,sha256=SSYqS84FCnAW7tAxmjBKU8qAa8Jv4VGPuSSGeHwWtJE,1095
1
+ django_fast_treenode-3.0.4.dist-info/licenses/LICENSE,sha256=SSYqS84FCnAW7tAxmjBKU8qAa8Jv4VGPuSSGeHwWtJE,1095
2
2
  treenode/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  treenode/apps.py,sha256=QlwjNDM9rkUoWB8Vm8-OkS6lNx0-aTByuGZlu9wrQMs,1832
4
4
  treenode/cache.py,sha256=2jUiiecfFxwB7QFukpU4u0FnDzGH6hNRfo6KAYvs6vM,8447
@@ -7,18 +7,18 @@ treenode/settings.py,sha256=oSkcKXNVd28HXrlWZIH2VYinCMq-UdCDlX4KD0Qc_Xk,631
7
7
  treenode/signals.py,sha256=ERrlKjGqhYaPYVKKRk1JBBlPFOmJKpJ6bXsJavcTlo0,518
8
8
  treenode/tests.py,sha256=2uDafv3Ns6f7Vy1ekUtgYxCZEi1KRyesZDTAFhYcX-E,63
9
9
  treenode/urls.py,sha256=krHvVigc_dxC0z5hEd2rgeH6th8jW7qJY3Qbia-419Y,240
10
- treenode/version.py,sha256=UQel-ilvX8HzIC5S8Odk9qyR41TRS2ikVETb9NGTXN0,220
10
+ treenode/version.py,sha256=agtMVvI22kmKiOiAyXlZeP14ej675T1Yd8F0FzHfXXk,220
11
11
  treenode/widgets.py,sha256=61ed16bVqb1_R97jekDrbKS5pDVK4nzXSDwL3CDBYEk,4075
12
12
  treenode/admin/__init__.py,sha256=XNEYHdF5lKb0vpdlVxdR2fxj5oUgzyx1YyCwsv0gxHw,100
13
13
  treenode/admin/admin.py,sha256=9_GC6JjWRiG_G84royRGAr10BSNIAj4N_33JXdzP8IY,7733
14
14
  treenode/admin/changelist.py,sha256=KUYS9MaR8Ck_1xmMqupobxWKarrJEqmHuEG32CL01Bo,1662
15
15
  treenode/admin/exporter.py,sha256=QE74V6W3tvwA5kCvBt1MmVlLOaWh-o8EU63cgmiwD5Q,5724
16
16
  treenode/admin/importer.py,sha256=hK3D-1DZcoowGblRluGzng3n5Bf__hMsbNaIGXRpRdg,6263
17
- treenode/admin/mixin.py,sha256=yXcSpBEfoMYT7tuAbHhGbuqlVQkCR5RizW2bNWJ0QNM,10639
17
+ treenode/admin/mixin.py,sha256=o83zTETOjvdHRjGR2pGthsY_RO7GVEimNUISgqqOBLs,10642
18
18
  treenode/managers/__init__.py,sha256=c7F9Ku9489Hv6lTpUY2nbyBlWFCXBWAkNBm4xTKcjL8,186
19
19
  treenode/managers/managers.py,sha256=8OaFxtajyR1d7-UHyiUbifMBEF9cjfHTIEYPkYUWmt0,7166
20
- treenode/managers/queries.py,sha256=Kepax8SDn7G5tOlPRWBCp5Oyp49O5iMITCMBoNCm_Ak,10655
21
- treenode/managers/tasks.py,sha256=gqZfqeOrdPtTJZ1q83aCFh6htfxP0XMTOkd1KWL9PVU,5227
20
+ treenode/managers/queries.py,sha256=zZYeDVFXl-Hro9ubv0_zuN7XaVXQhyeeFx_GVZSMi30,10273
21
+ treenode/managers/tasks.py,sha256=b8deUAbCpD1Yov-PpjKNAx49rL4fejkGf64ih5JziF0,7138
22
22
  treenode/models/__init__.py,sha256=iR4ksCKoayvkIWWgGk6OUGHZC3D0mzAtgdBcS2vQPBw,188
23
23
  treenode/models/decorators.py,sha256=N2dcnWqSCiEXDcYCf0zVijrbGUC8kYlqOLi_GKFmECU,1457
24
24
  treenode/models/factory.py,sha256=sPUSrvo1za-r6ny3B8ptwevyjO8-iUpPNrT0eSD2kvI,1786
@@ -75,8 +75,8 @@ treenode/utils/db/__init__.py,sha256=RwicAcJSI1nhIPWLdT7j9TFsgOc9834VDn9lVn54GlY
75
75
  treenode/utils/db/compiler.py,sha256=PgD9ybS5H8OUHw1gkFBQHhnrf5HiCx8QXUMRhydwh7o,3824
76
76
  treenode/utils/db/db_vendor.py,sha256=4SyEHl51jVCDB3is4omHCf2bTB_QV3RvemUQYxJP5m0,930
77
77
  treenode/utils/db/service.py,sha256=PF85Yhz2xUWFFCzpLYotmiNTZXXEH61rhswslSxEUds,2640
78
- treenode/utils/db/sqlcompat.py,sha256=K71ggkKIvpdTtHQ6Y4qcbo6cj2eYiEfy6DlVBr8Po1E,4460
79
- treenode/utils/db/sqlquery.py,sha256=KXcfKbbaBF-D134H_2DiPQtjedR79SJNXPJc0msZYEc,1938
78
+ treenode/utils/db/sqlcompat.py,sha256=FTgEMu3kQ1c96nB1sUW-SFOmYEvdl8JlD9HvsRj_dO0,5704
79
+ treenode/utils/db/sqlquery.py,sha256=jHI0gLkdJCD6ulOenO4b0aSjyAxtJ4ZGvsQa0i3Aj8E,2958
80
80
  treenode/views/__init__.py,sha256=ppxbBx51TUaKstJFpAd_DTmbKjbZGmVMLNYSpgUKnd0,111
81
81
  treenode/views/autoapi.py,sha256=o75e8IFsogbhZN_rbx3BKVnoruD96nWelnC5UzOqUDw,3628
82
82
  treenode/views/autocomplete.py,sha256=Z7cBnC4Ihdyxm8zlbnG6CkZdVkM3TOTWRpw5mdhaIVA,1469
@@ -84,7 +84,7 @@ treenode/views/children.py,sha256=bygXaEBExxG3zIPL34_PYHLFFIqlQU2naqPIlyQ6e-s,11
84
84
  treenode/views/common.py,sha256=mrmr40R91XVbMWcz5GZT-OjpnQ87F7XQZxu1W6rqpqI,617
85
85
  treenode/views/crud.py,sha256=RI5rdyD4hZTszjZFThByxi_lkAeJlqbDCXFkD8iyzKE,7424
86
86
  treenode/views/search.py,sha256=c_GyooT3jyoNa96bBxfoWruRN1wIw-ZGYvwGKkGojTs,1501
87
- django_fast_treenode-3.0.3.dist-info/METADATA,sha256=kbt-jrE-L0YYYUrHJKdvCOMe-GiSnKdT3LhebaWCcqM,10249
88
- django_fast_treenode-3.0.3.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
89
- django_fast_treenode-3.0.3.dist-info/top_level.txt,sha256=fmgxHbXyx1O2MPi_9kjx8aL9L-8TmV0gre4Go8XgqFk,9
90
- django_fast_treenode-3.0.3.dist-info/RECORD,,
87
+ django_fast_treenode-3.0.4.dist-info/METADATA,sha256=NbOy_OHnyPjubLhbssbpdM2jwGiModfoiE_CPNvQpM8,10249
88
+ django_fast_treenode-3.0.4.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
89
+ django_fast_treenode-3.0.4.dist-info/top_level.txt,sha256=fmgxHbXyx1O2MPi_9kjx8aL9L-8TmV0gre4Go8XgqFk,9
90
+ django_fast_treenode-3.0.4.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.3.1)
2
+ Generator: setuptools (80.7.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
treenode/admin/mixin.py CHANGED
@@ -84,7 +84,7 @@ class AdminMixin(admin.ModelAdmin):
84
84
  value = field(obj)
85
85
  field_name = getattr(field, "__name__", "field")
86
86
  else:
87
- attr, value = lookup_field(field, obj, self)
87
+ r, attr, value = lookup_field(field, obj, self)
88
88
  field_name = field
89
89
 
90
90
  row_data.append(value)
@@ -5,13 +5,14 @@ Low-level SQL Query Manager.
5
5
  Encapsulates all logic to retrieve related primary keys based on relationships
6
6
  (e.g., ancestors, children, descendants, siblings, family, root) using raw SQL.
7
7
 
8
- Version: 3.0.0
8
+ Version: 3.0.4
9
9
  Author: Timur Kady
10
10
  Email: timurkady@yandex.com
11
11
  """
12
12
 
13
13
 
14
14
  from django.db import connection
15
+ from ..utils.db.sqlcompat import SQLCompat
15
16
 
16
17
 
17
18
  class TreeQuery:
@@ -32,19 +33,6 @@ class TreeQuery:
32
33
  cursor.execute(sql, params)
33
34
  return cursor.fetchall()
34
35
 
35
- def wrap_union_all(self, queries):
36
- """
37
- Combine multiple SQL queries using UNION ALL.
38
-
39
- Each query is a tuple: (sql, params).
40
- Returns a tuple: (combined_sql, combined_params).
41
- """
42
- union_query = " UNION ALL ".join(f"({q[0]})" for q in queries)
43
- combined_params = []
44
- for q in queries:
45
- combined_params.extend(q[1])
46
- return union_query, combined_params
47
-
48
36
  def order_by(self, sql, order_by_clause):
49
37
  """Wrap the SQL in an outer query to enforce ordering."""
50
38
  return f"SELECT * FROM ({sql}) AS combined ORDER BY {order_by_clause}"
@@ -76,7 +64,7 @@ class TreeQuery:
76
64
  if include_self:
77
65
  sql2 = f"SELECT id, priority FROM {self.db_table} WHERE id = %s"
78
66
  params2 = [self.node.pk]
79
- combined_sql, combined_params = self.wrap_union_all(
67
+ combined_sql, combined_params = SQLCompat.wrap_union_all(
80
68
  [(sql1, params1), (sql2, params2)])
81
69
  sql = self.order_by(combined_sql, "priority")
82
70
  return sql, combined_params
@@ -115,7 +103,7 @@ class TreeQuery:
115
103
  FROM {self.db_table}
116
104
  WHERE id = %s
117
105
  """
118
- union_sql, union_params = self.wrap_union_all([
106
+ union_sql, union_params = SQLCompat.wrap_union_all([
119
107
  (base_sql, params),
120
108
  (sql_self, [self.node.pk])
121
109
  ])
@@ -148,7 +136,7 @@ class TreeQuery:
148
136
 
149
137
  if include_self:
150
138
  sql_self = f"SELECT id, _depth, priority FROM {self.db_table} WHERE id = %s" # noqa: D501
151
- union_sql, union_params = self.wrap_union_all(
139
+ union_sql, union_params = SQLCompat.wrap_union_all(
152
140
  [(base_sql, params), (sql_self, [self.node.pk])])
153
141
  else:
154
142
  union_sql, union_params = base_sql, params
@@ -198,7 +186,7 @@ class TreeQuery:
198
186
  if include_self:
199
187
  sql_self = f"SELECT id, _depth, priority FROM {self.db_table} WHERE id = %s" # noqa: D501
200
188
  queries.append((sql_self, [self.node.pk]))
201
- combined_sql, combined_params = self.wrap_union_all(queries)
189
+ combined_sql, combined_params = SQLCompat.wrap_union_all(queries)
202
190
  combined_sql = self.order_by(combined_sql, "_depth, priority")
203
191
  return combined_sql, combined_params
204
192
 
@@ -2,24 +2,16 @@
2
2
  """
3
3
  TreeNode TaskQuery manager
4
4
 
5
- Version: 3.0.0
5
+ Version: 3.0.4
6
6
  Author: Timur Kady
7
7
  Email: timurkady@yandex.com
8
8
  """
9
9
 
10
- from django.db import connection
10
+ import atexit
11
+ from django.db import connection, transaction
11
12
 
12
13
  from ..utils.db import TreePathCompiler
13
14
 
14
- '''
15
- try:
16
- profile
17
- except NameError:
18
- def profile(func):
19
- """Profile."""
20
- return func
21
- '''
22
-
23
15
 
24
16
  class TreeTaskQueue:
25
17
  """TreeTaskQueue Class."""
@@ -28,31 +20,93 @@ class TreeTaskQueue:
28
20
  """Init the task query."""
29
21
  self.model = model
30
22
  self.queue = []
23
+ self._running = False
24
+
25
+ # Register the execution queue when the interpreter exits
26
+ atexit.register(self._atexit_run)
27
+
28
+ def _atexit_run(self):
29
+ """Run queue on interpreter exit if pending tasks exist."""
30
+ if self.queue and not self._running:
31
+ try:
32
+ self.run()
33
+ except Exception as e:
34
+ # Don't crash on completion, just log
35
+ print(f"[TreeTaskQueue] Error during atexit: {e}")
31
36
 
32
37
  def add(self, mode, parent_id):
33
- """Add task to the query."""
38
+ """Add task to the queue.
39
+
40
+ Parameters:
41
+ mode (str): Task type (currently only "update").
42
+ parent_id (int|None): ID of parent node to update from (None = full tree).
43
+ """
34
44
  self.queue.append({"mode": mode, "parent_id": parent_id})
35
45
 
36
46
  def run(self):
37
- """Run task queue."""
47
+ """Run task queue.
48
+
49
+ This method collects all queued tasks, optimizes them, and performs
50
+ a recursive rebuild of tree paths and depths using SQL. Locks the
51
+ required rows before running.
52
+
53
+ Uses Django's `transaction.atomic()` to ensure that any recursive CTE
54
+ execution or SAVEPOINT creation works properly under PostgreSQL.
55
+ """
38
56
  if len(self.queue) == 0:
39
57
  return
58
+
59
+ self._running = True
40
60
  try:
41
61
  optimized = self._optimize()
42
- for task in optimized:
43
- if task["mode"] == "update":
44
- parent_id = task["parent_id"]
45
- TreePathCompiler.update_path(
46
- model=self.model,
47
- parent_id=parent_id
48
- )
62
+ if not optimized:
63
+ return
64
+
65
+ parent_ids = [t["parent_id"] for t in optimized if t["parent_id"] is not None]
66
+
67
+ with transaction.atomic():
68
+ if any(t["parent_id"] is None for t in optimized):
69
+ try:
70
+ with connection.cursor() as cursor:
71
+ cursor.execute(
72
+ f"SELECT id FROM {self.model._meta.db_table} WHERE parent_id IS NULL FOR UPDATE NOWAIT"
73
+ )
74
+ except Exception as e:
75
+ print(f"[TreeTaskQueue] Skipped (root locked): {e}")
76
+ return
77
+ else:
78
+ try:
79
+ with connection.cursor() as cursor:
80
+ for parent_id in parent_ids:
81
+ cursor.execute(
82
+ f"SELECT id FROM {self.model._meta.db_table} WHERE id = %s FOR UPDATE NOWAIT",
83
+ [parent_id],
84
+ )
85
+ except Exception as e:
86
+ print(f"[TreeTaskQueue] Skipped (parent locked): {e}")
87
+ return
88
+
89
+ for task in optimized:
90
+ if task["mode"] == "update":
91
+ TreePathCompiler.update_path(
92
+ model=self.model,
93
+ parent_id=task["parent_id"]
94
+ )
95
+
96
+ except Exception as e:
97
+ print(f"[TreeTaskQueue] Error in run: {e}")
98
+ connection.rollback()
49
99
  finally:
50
100
  self.queue.clear()
51
101
  self._running = False
52
102
 
53
- # @profile
54
103
  def _optimize(self):
55
- """Return optimized task queue (ID-only logic)."""
104
+ """Return optimized task queue (ID-only logic).
105
+
106
+ Attempts to merge redundant or overlapping subtree updates into
107
+ the minimal set of unique parent IDs that need to be rebuilt.
108
+ If it finds a common root, it returns a single task for full rebuild.
109
+ """
56
110
  result_set = set()
57
111
  id_set = set()
58
112
 
@@ -60,8 +114,6 @@ class TreeTaskQueue:
60
114
  if task["mode"] == "update":
61
115
  parent_id = task["parent_id"]
62
116
  if parent_id is None:
63
- # If we are already updating the entire tree, then
64
- # the remaining tasks are meaningless # noqa: D501
65
117
  return [{"mode": "update", "parent_id": None}]
66
118
  else:
67
119
  id_set.add(parent_id)
@@ -74,8 +126,6 @@ class TreeTaskQueue:
74
126
  for other in id_list[:]:
75
127
  ancestor = self._get_common_ancestor(current, other)
76
128
  if ancestor is not None:
77
- # If the common ancestor is the root, then we update
78
- # the entire tree
79
129
  if ancestor in self._get_root_ids():
80
130
  return [{"mode": "update", "parent_id": None}]
81
131
  if ancestor not in id_set:
@@ -87,36 +137,25 @@ class TreeTaskQueue:
87
137
  if not merged:
88
138
  result_set.add(current)
89
139
 
90
- return [{"mode": "update", "parent_id": pk} for pk in sorted(result_set)] # noqa: D501
140
+ return [{"mode": "update", "parent_id": pk} for pk in sorted(result_set)]
91
141
 
92
142
  def _get_root_ids(self):
93
143
  """Return root node IDs."""
94
144
  with connection.cursor() as cursor:
95
145
  cursor.execute(
96
- f"SELECT id FROM {self.model._meta.db_table} WHERE parent_id IS NULL") # noqa: D501
146
+ f"SELECT id FROM {self.model._meta.db_table} WHERE parent_id IS NULL")
97
147
  return [row[0] for row in cursor.fetchall()]
98
148
 
99
149
  def _get_parent_id(self, node_id):
100
150
  """Return parent ID for a given node."""
101
151
  with connection.cursor() as cursor:
102
152
  cursor.execute(
103
- f"SELECT parent_id FROM {self.model._meta.db_table} WHERE id = %s", [node_id]) # noqa: D501
153
+ f"SELECT parent_id FROM {self.model._meta.db_table} WHERE id = %s", [node_id])
104
154
  row = cursor.fetchone()
105
155
  return row[0] if row else None
106
156
 
107
- '''
108
- def _get_ancestor_path(self, node_id):
109
- """Return list of ancestor IDs including the node itself."""
110
- path = []
111
- while node_id is not None:
112
- path.append(node_id)
113
- node_id = self._get_parent_id(node_id)
114
- return path[::-1] # root to leaf
115
- '''
116
-
117
- # @profile
118
157
  def _get_ancestor_path(self, node_id):
119
- """Return list of ancestor IDs including the node itself, using recursive SQL.""" # noqa: D501
158
+ """Return list of ancestor IDs including the node itself, using recursive SQL."""
120
159
  table = self.model._meta.db_table
121
160
 
122
161
  sql = f"""
@@ -140,7 +179,6 @@ class TreeTaskQueue:
140
179
 
141
180
  return [row[0] for row in rows]
142
181
 
143
- # @profile
144
182
  def _get_common_ancestor(self, id1, id2):
145
183
  """Return common ancestor ID between two nodes."""
146
184
  path1 = self._get_ancestor_path(id1)
@@ -125,12 +125,44 @@ class SQLCompat:
125
125
  {set_clause};
126
126
  """
127
127
 
128
+ elif connection.vendor == "sqlite":
129
+ # SQLite workaround via temporary table
130
+ temp_table = "temp_tree_update"
131
+ cols = ["id"] + [cte_alias.get(f, f) for f in update_fields]
132
+ col_defs = ", ".join(f"{c} TEXT" for c in cols)
133
+ insert_cols = ", ".join(cols)
134
+ select_cols = ", ".join(cols)
135
+
136
+ set_clause = ", ".join(
137
+ f"{qf(f)} = (SELECT t.{cte_alias.get(f, f)} FROM {temp_table} t WHERE t.id = {qt}.id)" # noqa
138
+ for f in update_fields
139
+ )
140
+
141
+ return f"""
142
+ DROP TABLE IF EXISTS {temp_table};
143
+ CREATE TEMP TABLE {temp_table} ({col_defs});
144
+
145
+ WITH RECURSIVE tree_cte {cte_header} AS (
146
+ {base_sql}
147
+ UNION ALL
148
+ {recursive_sql}
149
+ )
150
+ INSERT INTO {temp_table} ({insert_cols})
151
+ SELECT {select_cols} FROM tree_cte;
152
+
153
+ UPDATE {qt}
154
+ SET {set_clause}
155
+ WHERE id IN (SELECT id FROM {temp_table});
156
+ """
157
+
128
158
  else:
159
+ # Fallback: subqueries
160
+ # (still buggy in SQLite, hence above workaround)
129
161
  set_clause = ", ".join(
130
162
  f"{qf(f)} = (SELECT t.{f} FROM tree_cte t WHERE t.id = {qt}.id)"
131
163
  for f in update_fields
132
164
  )
133
- where_clause = f"id IN (SELECT id FROM tree_cte)"
165
+ where_clause = "id IN (SELECT id FROM tree_cte)"
134
166
  return f"""
135
167
  WITH RECURSIVE tree_cte {cte_header} AS (
136
168
  {base_sql}
@@ -66,5 +66,29 @@ class SQLQueue:
66
66
  raise
67
67
  self._items.clear()
68
68
 
69
+ @staticmethod
70
+ def wrap_union_all(queries):
71
+ """
72
+ Combine multiple SQL queries using UNION ALL with vendor-specific handling.
73
+ Each query is a tuple: (sql, params).
74
+ Returns a tuple: (combined_sql, combined_params).
75
+ """
76
+ if is_sqlite():
77
+ # SQLite требует одинаковое число и порядок столбцов. Добавим к каждому SELECT псевдонимы.
78
+ def alias_select(sql, alias_prefix, idx):
79
+ return f"SELECT * FROM ({sql}) AS {alias_prefix}_{idx}"
80
+
81
+ wrapped_queries = [
82
+ alias_select(q[0], "q", i) for i, q in enumerate(queries)
83
+ ]
84
+ combined_sql = " UNION ALL ".join(wrapped_queries)
85
+ else:
86
+ combined_sql = " UNION ALL ".join(f"({q[0]})" for q in queries)
87
+
88
+ combined_params = []
89
+ for q in queries:
90
+ combined_params.extend(q[1])
91
+
92
+ return combined_sql, combined_params
69
93
 
70
94
  # The End
treenode/version.py CHANGED
@@ -9,4 +9,4 @@ Author: Timur Kady
9
9
  Email: timurkady@yandex.com
10
10
  """
11
11
 
12
- __version__ = '3.0.3'
12
+ __version__ = '3.0.4'