django-fast-treenode 3.0.0__tar.gz → 3.0.2__tar.gz

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.
Files changed (119) hide show
  1. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/LICENSE +1 -0
  2. {django_fast_treenode-3.0.0/django_fast_treenode.egg-info → django_fast_treenode-3.0.2}/PKG-INFO +2 -1
  3. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2/django_fast_treenode.egg-info}/PKG-INFO +2 -1
  4. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/index.md +9 -9
  5. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/pyproject.toml +2 -1
  6. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/setup.py +2 -1
  7. django_fast_treenode-3.0.2/tests/test_suite.py +99 -0
  8. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/apps.py +1 -1
  9. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/managers/queries.py +43 -0
  10. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/descendants.py +17 -11
  11. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/utils/db/compiler.py +24 -25
  12. django_fast_treenode-3.0.2/treenode/utils/db/sqlcompat.py +145 -0
  13. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/version.py +2 -3
  14. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/views/crud.py +31 -10
  15. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/widgets.py +2 -0
  16. django_fast_treenode-3.0.0/tests/test_suite.py +0 -91
  17. django_fast_treenode-3.0.0/treenode/utils/db/sqlcompat.py +0 -60
  18. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/MANIFEST.in +0 -0
  19. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/README.md +0 -0
  20. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/django_fast_treenode.egg-info/SOURCES.txt +0 -0
  21. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/django_fast_treenode.egg-info/dependency_links.txt +0 -0
  22. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/django_fast_treenode.egg-info/requires.txt +0 -0
  23. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/django_fast_treenode.egg-info/top_level.txt +0 -0
  24. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/.gitignore +0 -0
  25. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/.nojekyll +0 -0
  26. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/about.md +0 -0
  27. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/admin.md +0 -0
  28. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/api.md +0 -0
  29. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/apifirst.md +0 -0
  30. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/cache.md +0 -0
  31. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/customization.md +0 -0
  32. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/dnd.md +0 -0
  33. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/import_export.md +0 -0
  34. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/insert-after.jpg +0 -0
  35. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/insert-as-child.jpg +0 -0
  36. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/installation.md +0 -0
  37. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/migration.md +0 -0
  38. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/models.md +0 -0
  39. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/requirements.txt +0 -0
  40. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/roadmap.md +0 -0
  41. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/docs/using.md +0 -0
  42. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/setup.cfg +0 -0
  43. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/__init__.py +0 -0
  44. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/admin/__init__.py +0 -0
  45. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/admin/admin.py +0 -0
  46. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/admin/changelist.py +0 -0
  47. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/admin/exporter.py +0 -0
  48. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/admin/importer.py +0 -0
  49. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/admin/mixin.py +0 -0
  50. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/cache.py +0 -0
  51. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/forms.py +0 -0
  52. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/managers/__init__.py +0 -0
  53. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/managers/managers.py +0 -0
  54. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/managers/tasks.py +0 -0
  55. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/__init__.py +0 -0
  56. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/decorators.py +0 -0
  57. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/factory.py +0 -0
  58. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/__init__.py +0 -0
  59. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/ancestors.py +0 -0
  60. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/children.py +0 -0
  61. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/family.py +0 -0
  62. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/logical.py +0 -0
  63. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/node.py +0 -0
  64. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/properties.py +0 -0
  65. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/roots.py +0 -0
  66. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/siblings.py +0 -0
  67. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/tree.py +0 -0
  68. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/mixins/update.py +0 -0
  69. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/models/models.py +0 -0
  70. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/settings.py +0 -0
  71. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/signals.py +0 -0
  72. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/.gitkeep +0 -0
  73. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/css/.gitkeep +0 -0
  74. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/css/tree_widget.css +0 -0
  75. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/css/treenode_admin.css +0 -0
  76. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/css/treenode_tabs.css +0 -0
  77. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/js/.gitkeep +0 -0
  78. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/js/lz-string.min.js +0 -0
  79. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/js/tree_widget.js +0 -0
  80. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/js/treenode_admin.js +0 -0
  81. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/AUTHORS.txt +0 -0
  82. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/LICENSE.txt +0 -0
  83. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/external/jquery/jquery.js +0 -0
  84. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/images/ui-icons_444444_256x240.png +0 -0
  85. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/images/ui-icons_555555_256x240.png +0 -0
  86. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/images/ui-icons_777620_256x240.png +0 -0
  87. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/images/ui-icons_777777_256x240.png +0 -0
  88. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/images/ui-icons_cc0000_256x240.png +0 -0
  89. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/images/ui-icons_ffffff_256x240.png +0 -0
  90. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/index.html +0 -0
  91. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/jquery-ui.css +0 -0
  92. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/jquery-ui.js +0 -0
  93. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/jquery-ui.min.css +0 -0
  94. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/jquery-ui.min.js +0 -0
  95. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/jquery-ui.structure.css +0 -0
  96. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/jquery-ui.structure.min.css +0 -0
  97. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/jquery-ui.theme.css +0 -0
  98. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/jquery-ui.theme.min.css +0 -0
  99. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/static/vendors/jquery-ui/package.json +0 -0
  100. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/templates/.gitkeep +0 -0
  101. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/templates/admin/.gitkeep +0 -0
  102. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/templates/admin/treenode_ajax_rows.html +0 -0
  103. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/templates/admin/treenode_changelist.html +0 -0
  104. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/templates/admin/treenode_import_export.html +0 -0
  105. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/templates/admin/treenode_rows.html +0 -0
  106. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/templates/widgets/tree_widget.html +0 -0
  107. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/tests.py +0 -0
  108. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/urls.py +0 -0
  109. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/utils/__init__.py +0 -0
  110. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/utils/db/__init__.py +0 -0
  111. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/utils/db/db_vendor.py +0 -0
  112. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/utils/db/service.py +0 -0
  113. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/utils/db/sqlquery.py +0 -0
  114. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/views/__init__.py +0 -0
  115. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/views/autoapi.py +0 -0
  116. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/views/autocomplete.py +0 -0
  117. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/views/children.py +0 -0
  118. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/views/common.py +0 -0
  119. {django_fast_treenode-3.0.0 → django_fast_treenode-3.0.2}/treenode/views/search.py +0 -0
@@ -19,3 +19,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-fast-treenode
3
- Version: 3.0.0
3
+ Version: 3.0.2
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
@@ -27,6 +27,7 @@ License: MIT License
27
27
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
28
  SOFTWARE.
29
29
 
30
+
30
31
  Project-URL: Homepage, https://github.com/TimurKady/django-fast-treenode
31
32
  Project-URL: Documentation, https://django-fast-treenode.readthedocs.io/
32
33
  Project-URL: Source, https://github.com/TimurKady/django-fast-treenode
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-fast-treenode
3
- Version: 3.0.0
3
+ Version: 3.0.2
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
@@ -27,6 +27,7 @@ License: MIT License
27
27
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
28
  SOFTWARE.
29
29
 
30
+
30
31
  Project-URL: Homepage, https://github.com/TimurKady/django-fast-treenode
31
32
  Project-URL: Documentation, https://django-fast-treenode.readthedocs.io/
32
33
  Project-URL: Source, https://github.com/TimurKady/django-fast-treenode
@@ -1,4 +1,4 @@
1
- # TreeNode Framework
1
+ # Treenode Framework
2
2
  **A hybrid open-source framework for working with trees in Django**
3
3
 
4
4
  Welcome to the **django-fast-treenode** documentation!
@@ -147,14 +147,14 @@ The project is designed for long-term compatibility with future Django versions
147
147
 
148
148
  All SQL queries are adapted through a universal compatibility layer, ensuring support for major databases without the need to rewrite SQL code.
149
149
 
150
- | Database | Minimum Version | Status | Notes |
151
- |------------------|------------------|---------------------|-------|
152
- | **PostgreSQL** | ≥ 9.4 | Full tested | Recommended ≥ 12 |
153
- | **MySQL** | ≥ 8.0 | Partially tested | |
154
- | **MariaDB** | ≥ 10.2.2 | Not tested | |
155
- | **SQLite** | ≥ 3.25.0 | Partially tested | |
156
- | **Oracle** | ≥ 12c | Full tested | Recommended ≥ 19c |
157
- | **MS SQL Server** | ≥ 2012 | Partially tested| |
150
+ | Database | Minimum Version | Status | Notes |
151
+ |:------------------|:------------------|:----------------|:-------------------|
152
+ | **PostgreSQL** | ≥ 10.0 | Full tested | Recommended ≥ 12 |
153
+ | **MySQL** | ≥ 8.0 | Partially tested | |
154
+ | **MariaDB** | ≥ 10.5 | Not tested | |
155
+ | **SQLite** | ≥ 3.35.0 | Partially tested | |
156
+ | **Oracle** | ≥ 12c R2 | Full tested | Recommended ≥ 19c |
157
+ | **MS SQL Server** | ≥ 2016 | Partially tested | |
158
158
 
159
159
 
160
160
  The project is **ready for production use** across all modern versions of Django and major relational databases without manual SQL corrections.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "django-fast-treenode"
7
- version = "3.0.0"
7
+ version = "3.0.2"
8
8
  description = "Treenode Framework for supporting tree (hierarchical) data structure in Django projects"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Timur Kady", email = "timurkady@yandex.com" }]
@@ -43,3 +43,4 @@ Documentation = "https://django-fast-treenode.readthedocs.io/"
43
43
  Source = "https://github.com/TimurKady/django-fast-treenode"
44
44
  Issues = "https://github.com/TimurKady/django-fast-treenode/issues"
45
45
 
46
+
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='django-fast-treenode',
5
- version='3.0.0',
5
+ version='3.0.2',
6
6
  description='Treenode Framework for supporting tree (hierarchical) data structure in Django projects',
7
7
  long_description=open('README.md', encoding='utf-8').read(),
8
8
  long_description_content_type='text/markdown',
@@ -41,3 +41,4 @@ setup(
41
41
  ],
42
42
  python_requires='>=3.9',
43
43
  )
44
+
@@ -0,0 +1,99 @@
1
+ # tests/test_treenode.py
2
+ from django.test import TestCase
3
+ from django.db import transaction
4
+ from .models import TestModel
5
+
6
+ PATH_DIGITS = 3
7
+
8
+
9
+ def hex_path(parts):
10
+ """Преобразуем массив индексов в нулепад-hex-строку."""
11
+ return ".".join(f"{n:0{PATH_DIGITS}X}" for n in parts)
12
+
13
+
14
+ class TreeNodeModelTests(TestCase):
15
+ """Проверяем основные операции TreeNodeModel."""
16
+
17
+ @classmethod
18
+ def setUpTestData(cls):
19
+ """
20
+ Создаём тестовое дерево один раз на весь класс.
21
+ Django сделает его снимок, и в каждом тесте база
22
+ будет возвращаться в это состояние автоматически.
23
+ """
24
+ cls.root = TestModel.objects.create(name="root", priority=0)
25
+ cls.a = TestModel.objects.create(name="A", parent=cls.root, priority=1)
26
+ cls.b = TestModel.objects.create(name="B", parent=cls.root, priority=2)
27
+ cls.c = TestModel.objects.create(name="C", parent=cls.a, priority=1)
28
+ cls.d = TestModel.objects.create(name="D", parent=cls.a, priority=2)
29
+
30
+ _ = cls.a.get_order()
31
+ _ = cls.c.get_order()
32
+
33
+ # --- 1. Creating nodes -------------------------------------------------
34
+
35
+ def test_count_after_creation(self):
36
+ self.assertEqual(TestModel.objects.count(), 5)
37
+
38
+ # --- 2. _path and _depth --------------------------------------------------
39
+
40
+ def test_path_and_depth_saved(self):
41
+ with self.subTest("Depth values"):
42
+ self.assertEqual(self.a.get_depth(), 1)
43
+ self.assertEqual(self.c.get_depth(), 2)
44
+
45
+ with self.subTest("Path format"):
46
+ self.assertIn(".", self.a.get_order())
47
+ self.assertIn(".", self.c.get_order())
48
+
49
+ # --- 3. Ancestors and Descendants ------------------------------------------------
50
+
51
+ def test_ancestors_and_descendants(self):
52
+ TestModel.tasks.add("update", None)
53
+ TestModel.tasks.run()
54
+
55
+ ancestors = set(
56
+ self.c.get_ancestors_queryset().values_list("pk", flat=True)
57
+ )
58
+ expected_anc = {self.root.pk, self.a.pk, self.c.pk}
59
+ self.assertEqual(ancestors, expected_anc)
60
+
61
+ descendants = set(
62
+ self.root.get_descendants_queryset(include_self=True)
63
+ .values_list("pk", flat=True)
64
+ )
65
+ expected_desc = {
66
+ self.root.pk, self.a.pk, self.b.pk, self.c.pk, self.d.pk
67
+ }
68
+
69
+ test = descendants == expected_desc
70
+
71
+ self.assertEqual(test, True)
72
+
73
+ # --- 4. Moving a node ------------------------------------------------
74
+
75
+ def test_move_node(self):
76
+ # перемещаем c под b и проверяем
77
+ with transaction.atomic():
78
+ self.c.move_to(self.b)
79
+
80
+ self.c.refresh_from_db()
81
+ self.assertEqual(self.c.parent_id, self.b.pk)
82
+ self.assertEqual(self.c.get_depth(), self.b.get_depth() + 1)
83
+ self.assertTrue(
84
+ self.c.get_order().startswith(f"{self.b.get_order()}.")
85
+ )
86
+ self.assertEqual(self.c.get_order().count("."), self.c.get_depth())
87
+
88
+ # --- 5. Removing a node ---------------------------------------------------
89
+
90
+ def test_delete_subtree(self):
91
+ self.a.delete(cascade=False)
92
+
93
+ tree_data = TestModel.get_tree_json()
94
+
95
+ self.root.check_tree_integrity()
96
+ qs = TestModel.objects.filter(pk__in=[self.a.pk, self.c.pk]).all()
97
+
98
+ self.assertFalse(TestModel.objects.filter(pk=self.a.pk).exists())
99
+ self.assertTrue(TestModel.objects.filter(pk=self.c.pk).exists())
@@ -18,7 +18,7 @@ class TreeNodeConfig(AppConfig):
18
18
  """Config Class."""
19
19
 
20
20
  default_auto_field = "django.db.models.BigAutoField"
21
- name = "supertree"
21
+ name = "treenode"
22
22
 
23
23
  def ready(self):
24
24
  """Ready method."""
@@ -84,6 +84,48 @@ class TreeQuery:
84
84
  sql = self.order_by(sql1, "priority")
85
85
  return sql, params1
86
86
 
87
+ def get_descendants(self, include_self, depth):
88
+ """
89
+ Build SQL for the 'descendants'.
90
+
91
+ Relationship using startswith-like logic.
92
+ Avoids locale-dependent string comparison issues by relying on
93
+ _path LIKE 'xxx.%'.
94
+ """
95
+ like_pattern = self.node._path + '.%' # emulate startswith
96
+
97
+ base_sql = f"""
98
+ SELECT id, _depth, priority
99
+ FROM {self.db_table}
100
+ WHERE _path LIKE %s
101
+ """
102
+ params = [like_pattern]
103
+
104
+ if depth is not None:
105
+ depth_val = getattr(self.node, "_depth", None)
106
+ if depth_val is None:
107
+ depth_val = type(self.node).objects.values_list(
108
+ "_depth", flat=True).get(pk=self.node.pk)
109
+ base_sql += " AND _depth <= %s"
110
+ params.append(depth_val + depth)
111
+
112
+ if include_self:
113
+ sql_self = f"""
114
+ SELECT id, _depth, priority
115
+ FROM {self.db_table}
116
+ WHERE id = %s
117
+ """
118
+ union_sql, union_params = self.wrap_union_all([
119
+ (base_sql, params),
120
+ (sql_self, [self.node.pk])
121
+ ])
122
+ else:
123
+ union_sql, union_params = base_sql, params
124
+
125
+ union_sql = self.order_by(union_sql, "_depth, priority")
126
+ return union_sql, union_params
127
+
128
+ '''
87
129
  def get_descendants(self, include_self, depth):
88
130
  """
89
131
  Build SQL for the 'descendants' relationship.
@@ -113,6 +155,7 @@ class TreeQuery:
113
155
 
114
156
  union_sql = self.order_by(union_sql, "_depth, priority")
115
157
  return union_sql, union_params
158
+ '''
116
159
 
117
160
  def get_ancestors(self, include_self):
118
161
  """
@@ -32,17 +32,23 @@ class TreeNodeDescendantsMixin(models.Model):
32
32
  def get_descendants_queryset(self, include_self=False, depth=None):
33
33
  """Get the descendants queryset."""
34
34
  path = self.get_order() # calls refresh and gets the current _path
35
- from_path = path + '.'
36
- to_path = path + '/'
37
- options = {'_path__gte': from_path, '_path__lt': to_path}
38
- if depth:
39
- options["_depth__lt"] = depth
40
- queryset = self._meta.model.objects.filter(**options)
41
-
42
- if include_self:
43
- return self._meta.model.objects.filter(pk=self.pk) | queryset
44
- else:
45
- return queryset
35
+
36
+ # from_path = path + '.'
37
+ # to_path = path + '/'
38
+
39
+ # options = {'_path__gte': from_path, '_path__lt': to_path}
40
+ # if depth:
41
+ # options["_depth__lt"] = depth
42
+ # queryset = self._meta.model.objects.filter(**options)
43
+ # if include_self:
44
+ # return self._meta.model.objects.filter(pk=self.pk) | queryset
45
+ # else:
46
+ # return queryset
47
+
48
+ suffix = "" if include_self else '.'
49
+ path += suffix
50
+ queryset = self._meta.model.objects.filter(_path__startswith=path)
51
+ return queryset
46
52
 
47
53
  @cached_method
48
54
  def get_descendants_pks(self, include_self=False, depth=None):
@@ -5,7 +5,7 @@ Tree update task compiler class.
5
5
  Compiles tasks to low-level SQL to update the materialized path (_path), depth
6
6
  (_depth), and node order (priority) when they are shifted or moved.
7
7
 
8
- Version: 3.0.0
8
+ Version: 3.1.0
9
9
  Author: Timur Kady
10
10
  Email: timurkady@yandex.com
11
11
  """
@@ -34,9 +34,11 @@ class TreePathCompiler:
34
34
  _depth) are recalculated.
35
35
  """
36
36
  db_table = model._meta.db_table
37
+ # Will eliminate the risk if the user names the model order or user.
38
+ qname = connection.ops.quote_name(db_table)
37
39
 
38
40
  sorting_field = model.sorting_field
39
- sorting_fields = ["priority", "id"] if sorting_field == "priority" else [sorting_field] # noqa: D501
41
+ sorting_fields = ["priority", "id"] if sorting_field == "priority" else [sorting_field] # noqa: D5017
40
42
  sort_expr = ", ".join([
41
43
  f"c.{field}" if "." not in field else field
42
44
  for field in sorting_fields
@@ -44,7 +46,7 @@ class TreePathCompiler:
44
46
 
45
47
  cte_header = "(id, parent_id, new_priority, new_path, new_depth)"
46
48
 
47
- row_number_expr = "ROW_NUMBER() OVER (ORDER BY {sort_expr}) - 1"
49
+ row_number_expr = f"ROW_NUMBER() OVER (ORDER BY {sort_expr}) - 1"
48
50
  hex_expr = SQLCompat.to_hex(row_number_expr)
49
51
  lpad_expr = SQLCompat.lpad(hex_expr, SEGMENT_LENGTH, "'0'")
50
52
 
@@ -57,7 +59,7 @@ class TreePathCompiler:
57
59
  {row_number_expr} AS new_priority,
58
60
  {new_path_expr} AS new_path,
59
61
  0 AS new_depth
60
- FROM {db_table} AS c
62
+ FROM {qname} AS c
61
63
  WHERE c.parent_id IS NULL
62
64
  """
63
65
  params = []
@@ -70,16 +72,18 @@ class TreePathCompiler:
70
72
  {row_number_expr} AS new_priority,
71
73
  {path_expr} AS new_path,
72
74
  p._depth + 1 AS new_depth
73
- FROM {db_table} c
74
- JOIN {db_table} p ON c.parent_id = p.id
75
+ FROM {qname} c
76
+ JOIN {qname} p ON c.parent_id = p.id
75
77
  WHERE p.id = %s
76
78
  """
77
79
  params = [parent_id]
78
80
 
79
- recursive_row_number_expr = "ROW_NUMBER() OVER (PARTITION BY c.parent_id ORDER BY {sort_expr}) - 1" # noqa: D501
81
+ recursive_row_number_expr = f"ROW_NUMBER() OVER (PARTITION BY c.parent_id ORDER BY {sort_expr}) - 1"
80
82
  recursive_hex_expr = SQLCompat.to_hex(recursive_row_number_expr)
81
- recursive_lpad_expr = SQLCompat.lpad(recursive_hex_expr, SEGMENT_LENGTH, "'0'") # noqa: D501
82
- recursive_path_expr = SQLCompat.concat("t.new_path", "'.'", recursive_lpad_expr) # noqa: D501
83
+ recursive_lpad_expr = SQLCompat.lpad(
84
+ recursive_hex_expr, SEGMENT_LENGTH, "'0'")
85
+ recursive_path_expr = SQLCompat.concat(
86
+ "t.new_path", "'.'", recursive_lpad_expr)
83
87
 
84
88
  recursive_sql = f"""
85
89
  SELECT
@@ -88,27 +92,22 @@ class TreePathCompiler:
88
92
  {recursive_row_number_expr} AS new_priority,
89
93
  {recursive_path_expr} AS new_path,
90
94
  t.new_depth + 1 AS new_depth
91
- FROM {db_table} c
95
+ FROM {qname} c
92
96
  JOIN tree_cte t ON c.parent_id = t.id
93
97
  """
94
98
 
95
- final_sql = f"""
96
- WITH RECURSIVE tree_cte {cte_header} AS (
97
- {base_sql}
98
- UNION ALL
99
- {recursive_sql}
100
- )
101
- UPDATE {db_table} AS orig
102
- SET
103
- priority = t.new_priority,
104
- _path = t.new_path,
105
- _depth = t.new_depth
106
- FROM tree_cte t
107
- WHERE orig.id = t.id;
108
- """
99
+ final_sql = SQLCompat.update_from(
100
+ db_table=db_table,
101
+ cte_header=cte_header,
102
+ base_sql=base_sql,
103
+ recursive_sql=recursive_sql,
104
+ update_fields=["priority", "_path", "_depth"]
105
+ )
109
106
 
110
107
  with connection.cursor() as cursor:
111
- cursor.execute(final_sql.format(sort_expr=sort_expr), params)
108
+ # Make params read-only
109
+ params = tuple(params)
110
+ cursor.execute(final_sql, params)
112
111
 
113
112
 
114
113
  # The End
@@ -0,0 +1,145 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Database compatibility extension module.
4
+
5
+ Adapts SQL code to the specific features of SQL syntax of various
6
+ Database vendors.
7
+
8
+ Instead of direct concatenation:
9
+ old: p._path || '.' || LPAD(...)
10
+ new: SQLCompat.concat("p._path", "'.'", SQLCompat.lpad(...))
11
+
12
+ Instead of TO_HEX(...)
13
+ old: TO_HEX(...)
14
+ new: SQLCompat.to_hex(...)
15
+
16
+ Instead of LPAD(...)
17
+ old: LPAD(...)
18
+ new: SQLCompat.lpad(...)
19
+
20
+ Version: 3.1.0
21
+ Author: Timur Kady
22
+ Email: timurkady@yandex.com
23
+ """
24
+
25
+ from django.db import connection
26
+ from .db_vendor import is_mysql, is_mariadb, is_sqlite, is_mssql
27
+ from ...settings import TREENODE_PAD_CHAR
28
+
29
+
30
+ class SQLCompat:
31
+ """Vendor-Specific SQL Compatibility Layer."""
32
+
33
+ @staticmethod
34
+ def concat(*args):
35
+ """Adapt string concatenation to the vendor-specific syntax."""
36
+ args_joined = ", ".join(args)
37
+ if is_mysql() or is_mariadb():
38
+ return f"CONCAT({args_joined})"
39
+ elif is_mssql():
40
+ return " + ".join(args)
41
+ else:
42
+ return " || ".join(args)
43
+
44
+ @staticmethod
45
+ def to_hex(value):
46
+ """Convert integer to uppercase hexadecimal string."""
47
+ if is_sqlite():
48
+ return f"UPPER(printf('%x', {value}))"
49
+ elif is_mysql() or is_mariadb():
50
+ return f"UPPER(CONV({value}, 10, 16))"
51
+ else:
52
+ return f"UPPER(TO_HEX({value}))"
53
+
54
+ @staticmethod
55
+ def lpad(value, length, char=TREENODE_PAD_CHAR):
56
+ """Pad string to the specified length."""
57
+ if is_sqlite():
58
+ return f"printf('%0{length}s', {value})"
59
+ else:
60
+ return f"LPAD({value}, {length}, {char})"
61
+
62
+ @staticmethod
63
+ def update_from(db_table, cte_header, base_sql, recursive_sql, update_fields):
64
+ """
65
+ Generate final SQL for updating via recursive CTE.
66
+
67
+ PostgreSQL uses UPDATE ... FROM.
68
+ Other engines use vendor-specific strategies.
69
+ """
70
+ qt = connection.ops.quote_name(db_table)
71
+ def qf(f): return connection.ops.quote_name(f)
72
+
73
+ cte_alias = {
74
+ "priority": "new_priority",
75
+ "_path": "new_path",
76
+ "_depth": "new_depth",
77
+ }
78
+
79
+ if connection.vendor == "postgresql":
80
+ set_clause = ", ".join(
81
+ f"{qf(f)} = t.{cte_alias.get(f, f)}" for f in update_fields
82
+ )
83
+ return f"""
84
+ WITH RECURSIVE tree_cte {cte_header} AS (
85
+ {base_sql}
86
+ UNION ALL
87
+ {recursive_sql}
88
+ )
89
+ UPDATE {qt} AS orig
90
+ SET {set_clause}
91
+ FROM tree_cte t
92
+ WHERE orig.id = t.id;
93
+ """
94
+
95
+ elif connection.vendor in {"microsoft", "mssql"}:
96
+ set_clause = ", ".join(
97
+ f"{qt}.{f} = t.{f}" for f in update_fields
98
+ )
99
+ return f"""
100
+ WITH tree_cte {cte_header} AS (
101
+ {base_sql}
102
+ UNION ALL
103
+ {recursive_sql}
104
+ )
105
+ UPDATE orig
106
+ SET {set_clause}
107
+ FROM {qt} AS orig
108
+ JOIN tree_cte t ON orig.id = t.id;
109
+ """
110
+
111
+ elif connection.vendor == "oracle":
112
+ set_clause = ", ".join(
113
+ f"orig.{f} = t.{f}" for f in update_fields
114
+ )
115
+ return f"""
116
+ WITH tree_cte {cte_header} AS (
117
+ {base_sql}
118
+ UNION ALL
119
+ {recursive_sql}
120
+ )
121
+ MERGE INTO {qt} orig
122
+ USING tree_cte t
123
+ ON (orig.id = t.id)
124
+ WHEN MATCHED THEN UPDATE SET
125
+ {set_clause};
126
+ """
127
+
128
+ else:
129
+ set_clause = ", ".join(
130
+ f"{qf(f)} = (SELECT t.{f} FROM tree_cte t WHERE t.id = {qt}.id)"
131
+ for f in update_fields
132
+ )
133
+ where_clause = f"id IN (SELECT id FROM tree_cte)"
134
+ return f"""
135
+ WITH RECURSIVE tree_cte {cte_header} AS (
136
+ {base_sql}
137
+ UNION ALL
138
+ {recursive_sql}
139
+ )
140
+ UPDATE {qt}
141
+ SET {set_clause}
142
+ WHERE {where_clause};
143
+ """
144
+
145
+ # The End
@@ -4,10 +4,9 @@ TreeNode Version Module
4
4
 
5
5
  This module defines the current version of the TreeNode package.
6
6
 
7
- Version: 3.0.0
7
+ Version: 3.0.2
8
8
  Author: Timur Kady
9
9
  Email: timurkady@yandex.com
10
10
  """
11
11
 
12
-
13
- __version__ = '3.0.0'
12
+ __version__ = '3.0.2'
@@ -10,14 +10,17 @@ Email: timurkady@yandex.com
10
10
  """
11
11
 
12
12
  import json
13
-
13
+ import logging
14
+ from django.core.exceptions import ValidationError
14
15
  from django.forms.models import model_to_dict
15
16
  from django.http import (
16
17
  JsonResponse, HttpResponseBadRequest, HttpResponseNotFound,
17
18
  )
18
-
19
+ from django.utils.translation import gettext_lazy as _
19
20
  from django.views import View
20
21
 
22
+ logger = logging.getLogger("treenode.views.crud")
23
+
21
24
 
22
25
  class TreeNodeBaseAPIView(View):
23
26
  """Simple API View for TreeNode-based models."""
@@ -128,8 +131,14 @@ class TreeNodeBaseAPIView(View):
128
131
  obj.full_clean()
129
132
  obj.save()
130
133
  return JsonResponse(model_to_dict(obj), status=201)
131
- except Exception as e:
132
- return HttpResponseBadRequest(str(e))
134
+ except ValidationError as ve:
135
+ # give the client clear field errors
136
+ return JsonResponse({"errors": ve.message_dict}, status=400)
137
+ except Exception:
138
+ # log full information for development
139
+ logger.exception("Failed to create node")
140
+ return JsonResponse(
141
+ {"error": _("Failed to create node")}, status=400)
133
142
 
134
143
  def put(self, request, pk):
135
144
  """
@@ -155,10 +164,16 @@ class TreeNodeBaseAPIView(View):
155
164
  obj.full_clean()
156
165
  obj.save()
157
166
  return JsonResponse(model_to_dict(obj))
167
+ except ValidationError as ve:
168
+ return JsonResponse({"errors": ve.message_dict}, status=400)
158
169
  except self.model.DoesNotExist:
159
- return HttpResponseNotFound("Node not found.")
160
- except Exception as e:
161
- return HttpResponseBadRequest(str(e))
170
+ # give the client clear field errors
171
+ return HttpResponseNotFound(_("Node not found (pk={pk})."))
172
+ except Exception:
173
+ # log full information for development
174
+ logger.exception("Error replacing node %s", pk)
175
+ return JsonResponse(
176
+ {"error": _("Failed to update node")}, status=400)
162
177
 
163
178
  def patch(self, request, pk):
164
179
  """
@@ -200,10 +215,16 @@ class TreeNodeBaseAPIView(View):
200
215
  "id": obj.pk,
201
216
  "cascade": cascade
202
217
  })
218
+ except ValidationError as ve:
219
+ # give the client clear field errors|
220
+ return JsonResponse({"errors": ve.message_dict}, status=400)
203
221
  except self.model.DoesNotExist:
204
- return HttpResponseNotFound("Node not found.")
205
- except Exception as e:
206
- return HttpResponseBadRequest(str(e))
222
+ return HttpResponseNotFound(_("Node not found (pk={pk})."))
223
+ except Exception:
224
+ # log full information for development
225
+ logger.exception("Error deleting node %s", pk)
226
+ return JsonResponse(
227
+ {"error": _("Error deleting node")}, status=400)
207
228
 
208
229
 
209
230
  # The End
@@ -21,6 +21,8 @@ from django.utils.translation import gettext_lazy as _
21
21
  class TreeWidget(forms.Widget):
22
22
  """Custom widget for hierarchical tree selection."""
23
23
 
24
+ template_name = "django/forms/widgets/select.html"
25
+
24
26
  class Media:
25
27
  """Meta class to define required CSS and JS files."""
26
28
 
@@ -1,91 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- """
3
- Automated tests for TreeNodeModel.
4
- """
5
-
6
- import unittest
7
- from django.db import transaction
8
- from supertree.models import TestNode # путь поправь, если нужно
9
-
10
- PATH_DIGITS = 3
11
-
12
-
13
- def _set_path(array):
14
- """Store the path as a zero-padded hex string."""
15
- return '.'.join(f"{n:0{PATH_DIGITS}X}" for n in array)
16
-
17
-
18
- class TreeNodeModelTests(unittest.TestCase):
19
- """Test cases for the TreeNodeModel behavior."""
20
-
21
- def setUp(self):
22
- # Чистим таблицу перед каждым тестом
23
- TestNode.objects.all().delete()
24
-
25
- @transaction.atomic
26
- def test_tree_operations(self):
27
- """Main test covering tree creation, move, and serialization."""
28
-
29
- # 1️⃣ Create a test tree
30
- root = TestNode.objects.create(name="root", priority=0)
31
- node_a = TestNode.objects.create(name="A", parent=root, priority=1)
32
- node_b = TestNode.objects.create(name="B", parent=root, priority=2)
33
- node_c = TestNode.objects.create(name="C", parent=node_a, priority=1)
34
- node_d = TestNode.objects.create(name="D", parent=node_a, priority=2)
35
-
36
- self.assertEqual(TestNode.objects.count(), 5)
37
-
38
- # 2️⃣ Check the saving of `_path`
39
- path_a = node_a.get_order()
40
- path_c = node_c.get_order()
41
-
42
- self.assertEqual(node_a.get_depth(), 1)
43
- self.assertEqual(node_c._depth, 2)
44
- self.assertTrue(isinstance(path_a, str) and '.' in path_a)
45
- self.assertTrue(isinstance(path_c, str) and '.' in path_c)
46
-
47
- # 3️⃣ Check ancestors and descendants
48
- ancestor_ids = set(
49
- node_c.get_ancestors_queryset().values_list("pk", flat=True))
50
- self.assertEqual(ancestor_ids, {root.pk, node_a.pk, node_c.pk})
51
-
52
- descendant_ids = set(root.get_descendants_queryset(
53
- include_self=True).values_list("pk", flat=True))
54
- self.assertEqual(descendant_ids, {
55
- root.pk, node_a.pk, node_b.pk, node_c.pk, node_d.pk})
56
-
57
- # 4️⃣ Move node_c under node_b
58
- node_c.move_to(node_b)
59
- self.assertEqual(node_c.parent.pk, node_b.pk)
60
- self.assertEqual(node_c.get_depth(), node_b.get_depth() + 1)
61
- self.assertTrue(node_c.get_order().startswith(node_b.get_order() + '.'))
62
- self.assertEqual(node_c.get_order().count('.'), node_c.get_depth())
63
-
64
- # 5️⃣ Check deletion
65
- node_a.delete()
66
- self.assertFalse(TestNode.objects.filter(pk=node_a.pk).exists())
67
- self.assertTrue(TestNode.objects.filter(pk=node_c.pk).exists())
68
-
69
- # 6️⃣ Check save and load operations
70
- TestNode.objects.all().delete()
71
- self.assertFalse(TestNode.objects.exists())
72
-
73
- # Recreate the tree
74
- root = TestNode.objects.create(name="root", priority=0)
75
- node_a = TestNode.objects.create(name="A", parent=root, priority=1)
76
- node_b = TestNode.objects.create(name="B", parent=root, priority=2)
77
- node_c = TestNode.objects.create(name="C", parent=node_a, priority=1)
78
- node_d = TestNode.objects.create(name="D", parent=node_a, priority=2)
79
-
80
- original_tree = TestNode.get_tree()
81
- tree_json = TestNode.get_tree_json()
82
- tree_data = TestNode.load_tree_json(tree_json)
83
-
84
- TestNode.objects.all().delete()
85
- self.assertFalse(TestNode.objects.exists())
86
-
87
- TestNode.load_tree(tree_data)
88
- new_tree = TestNode.get_tree_json()
89
-
90
- # ✅ Checking the integrity of the tree after all operations
91
- self.assertEqual(root.check_tree_integrity(verbose=False), [])
@@ -1,60 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- """
3
- Database compatibility extension module.
4
-
5
- Adapts SQL code to the specific features of SQL syntax of various
6
- Database vendors.
7
-
8
- Instead of direct concatenation:
9
- old: p._path || '.' || LPAD(...)
10
- new: SQLCompat.concat("p._path", "'.'", SQLCompat.lpad(...))
11
-
12
- Instead of TO_HEX(...)
13
- old: TO_HEX(...)
14
- new: SQLCompat.to_hex(...)
15
-
16
- Instead of LPAD(...)
17
- old: LPAD(...)
18
- new: SQLCompat.lpad(...)
19
-
20
- Version: 3.0.0
21
- Author: Timur Kady
22
- Email: timurkady@yandex.com
23
- """
24
-
25
- from .db_vendor import is_mysql, is_mariadb, is_sqlite, is_mssql
26
- from ...settings import TREENODE_PAD_CHAR
27
-
28
-
29
- class SQLCompat:
30
- """Vendor-Specific SQL Compatibility Layer."""
31
-
32
- @staticmethod
33
- def concat(*args):
34
- """Adapt string concatenation to the vendor-specific syntax."""
35
- args_joined = ", ".join(args)
36
- if is_mysql() or is_mariadb():
37
- return f"CONCAT({args_joined})"
38
- elif is_mssql():
39
- return " + ".join(args)
40
- else:
41
- return " || ".join(args)
42
-
43
- @staticmethod
44
- def to_hex(value):
45
- """Convert integer to hexadecimal string."""
46
- if is_sqlite():
47
- return f"printf('%x', {value})"
48
- else:
49
- return f"TO_HEX({value})"
50
-
51
- @staticmethod
52
- def lpad(value, length, char=TREENODE_PAD_CHAR):
53
- """Pad string to the specified length."""
54
- if is_sqlite():
55
- return (f"substr(replace(hex(zeroblob({length})), '00', {char}), "
56
- f"1, {length} - length({value})) || {value}")
57
- else:
58
- return f"LPAD({value}, {length}, {char})"
59
-
60
- # The End